Skip to content

scripts/archon-run-state.mjs

Source location: docs/source-files/scripts/archon-run-state.mjs — this page is a rendered mirror; the file is the source of truth.

archon-run-state.mjs
js
#!/usr/bin/env node
import { existsSync } from 'node:fs'
import { mkdir, readFile, readdir, rm, writeFile, rename } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { execFileSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'

const ROOT = resolve(process.cwd())
const RUNS_DIR = resolve(ROOT, '.archon/runs')
const LEGACY_RUN = resolve(ROOT, '.archon/run.md')
const REQUIRED_STATUS = [
  'boot.soul_loaded',
  'boot.mode_extension_loaded',
  'boot.manifest_loaded',
  'prescan.memos_scanned',
  'prescan.archive_scanned',
  'prescan.adrs_scanned',
  'prescan.extensions_hooked',
  'decision.fastpath_assessed',
  'decision.convergence_classified',
  'decision.plan_mode_declared',
  'decision.verdict_output',
  'execute.changes_applied',
  'validate.validation_green',
  'closeout.manifest_synced',
  'closeout.subagent_dispatched',
  'closeout.auditor_ran',
  'closeout.auditor_processed',
  'closeout.drift_updated',
  'closeout.milestone_gate',
  'closeout.memos_appended',
  'closeout.extensions_hooked',
  'closeout.statement_output',
]

function usage() {
  return `Usage:
  node scripts/archon-run-state.mjs init --demand "<text>"
  node scripts/archon-run-state.mjs set <run-id> <status-key> <value>
  node scripts/archon-run-state.mjs set-many <run-id> <status-key>=<value> [...]
  node scripts/archon-run-state.mjs check [run-id]
  node scripts/archon-run-state.mjs resolve-for-commit
  node scripts/archon-run-state.mjs cleanup <run-id>
`
}

function git(args) {
  return execFileSync('git', args, { cwd: ROOT, encoding: 'utf-8' }).trim()
}

function currentBranch() {
  try {
    return git(['branch', '--show-current']) || 'detached'
  } catch {
    return 'unknown'
  }
}

function currentHead() {
  try {
    return git(['rev-parse', 'HEAD'])
  } catch {
    return 'unknown'
  }
}

function changedPaths() {
  try {
    const tracked = git(['diff', '--name-only', 'HEAD', '--'])
    const untracked = git(['ls-files', '--others', '--exclude-standard'])
    return `${tracked}\n${untracked}`
      .split(/\r?\n/)
      .map((line) => line.trim().replace(/\\/g, '/'))
      .filter(Boolean)
      .filter((path) => !path.startsWith('.archon/runs/'))
  } catch {
    return []
  }
}

function stagedPaths() {
  try {
    return git(['diff', '--cached', '--name-only', '--'])
      .split(/\r?\n/)
      .map((line) => line.trim().replace(/\\/g, '/'))
      .filter(Boolean)
      .filter((path) => !path.startsWith('.archon/runs/'))
  } catch {
    return []
  }
}

function statePath(runId) {
  return resolve(RUNS_DIR, runId, 'state.json')
}

function eventPath(runId) {
  return resolve(RUNS_DIR, runId, 'events.ndjson')
}

async function writeJsonAtomic(filePath, data) {
  await mkdir(dirname(filePath), { recursive: true })
  const tmpPath = `${filePath}.${process.pid}.tmp`
  await writeFile(tmpPath, `${JSON.stringify(data, null, 2)}\n`, 'utf-8')
  await rename(tmpPath, filePath)
}

async function appendEvent(runId, event) {
  const line = JSON.stringify({ at: new Date().toISOString(), ...event })
  await writeFile(eventPath(runId), `${line}\n`, { encoding: 'utf-8', flag: 'a' })
}

async function readState(runId) {
  return JSON.parse(await readFile(statePath(runId), 'utf-8'))
}

async function listRunIds() {
  if (!existsSync(RUNS_DIR)) return []
  const entries = await readdir(RUNS_DIR, { withFileTypes: true })
  return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)
}

function emptyStatus() {
  return Object.fromEntries(REQUIRED_STATUS.map((key) => [key, '0']))
}

function isComplete(value) {
  return value === '1' || value === '2' || /^skip:[a-z0-9-]+$/.test(value)
}

function pendingKeys(state) {
  return REQUIRED_STATUS.filter((key) => !isComplete(state.status?.[key] ?? '0'))
}

function validateState(state) {
  const errors = []
  if (state.schemaVersion !== 2) errors.push('schemaVersion must be 2')
  for (const key of ['runId', 'demand', 'worktree', 'branch', 'baseHead', 'status']) {
    if (!(key in state)) errors.push(`missing ${key}`)
  }
  for (const key of REQUIRED_STATUS) {
    const value = state.status?.[key]
    if (typeof value !== 'string') {
      errors.push(`missing status ${key}`)
    } else if (!/^(?:0|1|2|skip:[a-z0-9-]+)$/.test(value)) {
      errors.push(`invalid status ${key}=${value}`)
    }
  }
  if (state.permitCommit === true && pendingKeys(state).length > 0) {
    errors.push(`permitCommit=true but pending keys remain: ${pendingKeys(state).slice(0, 5).join(', ')}`)
  }
  return errors
}

async function init(args) {
  const demandIndex = args.indexOf('--demand')
  const demand = demandIndex >= 0 ? args[demandIndex + 1] : ''
  if (!demand) throw new Error('init requires --demand "<text>"')
  const runId = `${new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z')}-${randomUUID().slice(0, 8)}`
  const state = {
    schemaVersion: 2,
    runId,
    demand,
    mode: 'standard',
    startedAt: new Date().toISOString(),
    worktree: ROOT.replace(/\\/g, '/'),
    branch: currentBranch(),
    baseHead: currentHead(),
    status: emptyStatus(),
    changedPaths: [],
    permitCommit: false,
  }
  await writeJsonAtomic(statePath(runId), state)
  await appendEvent(runId, { type: 'init', demand })
  console.log(runId)
}

async function setStatus(args) {
  const [runId, key, value] = args
  if (!runId || !key || !value) throw new Error('set requires <run-id> <status-key> <value>')
  if (!REQUIRED_STATUS.includes(key)) throw new Error(`unknown status key: ${key}`)
  if (!/^(?:0|1|2|skip:[a-z0-9-]+)$/.test(value)) throw new Error(`invalid status value: ${value}`)
  const state = await readState(runId)
  state.status[key] = value
  state.changedPaths = changedPaths()
  state.permitCommit = pendingKeys(state).length === 0
  await writeJsonAtomic(statePath(runId), state)
  await appendEvent(runId, { type: 'set', key, value, permitCommit: state.permitCommit })
}

async function setMany(args) {
  const [runId, ...pairs] = args
  if (!runId || pairs.length === 0) throw new Error('set-many requires <run-id> <status-key>=<value> [...]')
  const updates = pairs.map((pair) => {
    const separatorIndex = pair.indexOf('=')
    if (separatorIndex <= 0) throw new Error(`invalid set-many pair: ${pair}`)
    const key = pair.slice(0, separatorIndex)
    const value = pair.slice(separatorIndex + 1)
    if (!REQUIRED_STATUS.includes(key)) throw new Error(`unknown status key: ${key}`)
    if (!/^(?:0|1|2|skip:[a-z0-9-]+)$/.test(value)) throw new Error(`invalid status value: ${value}`)
    return { key, value }
  })
  const state = await readState(runId)
  for (const { key, value } of updates) {
    state.status[key] = value
  }
  state.changedPaths = changedPaths()
  state.permitCommit = pendingKeys(state).length === 0
  await writeJsonAtomic(statePath(runId), state)
  await appendEvent(runId, { type: 'set-many', updates, permitCommit: state.permitCommit })
}

async function check(args) {
  const runIds = args[0] ? [args[0]] : await listRunIds()
  const errors = []
  for (const runId of runIds) {
    try {
      const state = await readState(runId)
      for (const error of validateState(state)) errors.push(`${runId}: ${error}`)
    } catch (error) {
      errors.push(`${runId}: ${error.message}`)
    }
  }
  if (errors.length > 0) throw new Error(errors.join('\n'))
  console.log(runIds.length === 0 ? '[archon-run-state] no v2 runs' : `[archon-run-state] OK: ${runIds.length} v2 run(s)`)
}

async function resolveForCommit() {
  const explicit = process.env.ARCHON_RUN_ID
  const runIds = explicit ? [explicit] : await listRunIds()
  const candidates = []
  const pending = []
  for (const runId of runIds) {
    const state = await readState(runId)
    const errors = validateState(state)
    if (errors.length > 0) throw new Error(`${runId}: ${errors.join('; ')}`)
    if (state.worktree !== ROOT.replace(/\\/g, '/')) continue
    if (state.permitCommit === true) {
      candidates.push(state)
    } else {
      pending.push({ runId, pending: pendingKeys(state).slice(0, 5) })
    }
  }
  if (explicit && candidates.length === 0) {
    const match = pending.find((item) => item.runId === explicit)
    if (match) {
      throw new Error(`v2 run ${explicit} is pending: ${match.pending.join(', ') || 'permitCommit=false'}`)
    }
    throw new Error(`ARCHON_RUN_ID=${explicit} did not resolve to a commit-ready v2 run`)
  }
  if (pending.length > 0) {
    throw new Error(
      `pending v2 run(s) block commit: ${pending
        .map((item) => `${item.runId}${item.pending.length > 0 ? ` (${item.pending.join(', ')})` : ''}`)
        .join(', ')}`,
    )
  }
  if (candidates.length === 0) {
    if (existsSync(LEGACY_RUN)) {
      console.log('[archon-run-state] no v2 commit candidate; legacy run.md gate remains authoritative')
      return
    }
    console.log('[archon-run-state] no active v2 run')
    return
  }
  if (candidates.length > 1) {
    throw new Error(
      `multiple v2 commit candidates: ${candidates.map((state) => state.runId).join(', ')}; set ARCHON_RUN_ID`,
    )
  }
  const staged = stagedPaths()
  if (staged.length > 0) {
    const allowed = new Set((candidates[0].changedPaths ?? []).map((path) => path.replace(/\\/g, '/')))
    const outsideRun = staged.filter((path) => !allowed.has(path))
    if (outsideRun.length > 0) {
      throw new Error(
        `staged paths outside v2 run ${candidates[0].runId}: ${outsideRun.slice(0, 10).join(', ')}`,
      )
    }
  }
  console.log(`[archon-run-state] commit candidate: ${candidates[0].runId}`)
}

async function cleanup(args) {
  const runId = args[0] || process.env.ARCHON_RUN_ID
  if (!runId) throw new Error('cleanup requires <run-id> or ARCHON_RUN_ID')
  await rm(resolve(RUNS_DIR, runId), { recursive: true, force: true })
  console.log(`[archon-run-state] cleaned ${runId}`)
}

async function main() {
  const [command, ...args] = process.argv.slice(2)
  if (!command || command === '--help' || command === '-h') {
    console.log(usage())
    return
  }
  if (command === 'init') return init(args)
  if (command === 'set') return setStatus(args)
  if (command === 'set-many') return setMany(args)
  if (command === 'check') return check(args)
  if (command === 'resolve-for-commit') return resolveForCommit()
  if (command === 'cleanup') return cleanup(args)
  throw new Error(`unknown command: ${command}\n${usage()}`)
}

main().catch((error) => {
  console.error(`[archon-run-state] FAIL: ${error.message}`)
  process.exitCode = 1
})

Released under the Apache-2.0 License.