Skip to content

tools/archon-cli/lib/doctor.mjs

Source location: docs/source-files/tools/archon-cli/lib/doctor.mjs — this page is a rendered mirror; the file is the source of truth.

doctor.mjs
js
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { spawn } from 'node:child_process'

import { parseFlags } from './common.mjs'
import {
  fetchManifest,
  resolveBaseUrl,
  classifyFile,
  detectInstalledModules,
  flattenFiles,
} from './manifest.mjs'

/**
 * `archon doctor [project-dir]`
 *
 * Four-layer health check for an Archon-governed project:
 *   L1 — Structural:  required files present (soul.md, manifest.md, drift.md, ...)
 *   L2 — Contract:    delegates to scripts/archon-check.py when available
 *   L3 — Hints:       cheap readability signals (unfilled template placeholders,
 *                     missing validation command, empty concept glossary)
 *   L4 — Canonical:   diff vs aaep.site/manifest.json (opt-out with --offline)
 *
 * Exit code 0 = green, 1 = any failure. Non-fatal warnings print but do not fail.
 */
export async function runDoctor({ args }) {
  const { flags, positional } = parseFlags(args)
  const projectDir = path.resolve(positional[0] ?? '.')

  console.log(`[archon doctor] Auditing: ${projectDir}`)
  console.log('')

  const results = { fail: 0, warn: 0 }

  await checkStructure(projectDir, results)
  await checkContract(projectDir, results, flags)
  await checkManifestHints(projectDir, results)
  if (!flags.offline) {
    await checkCanonical(projectDir, results, flags)
  }

  console.log('')
  if (results.fail === 0 && results.warn === 0) {
    console.log('[archon doctor] All checks passed. Governance contract is healthy.')
    return
  }
  if (results.fail === 0) {
    console.log(`[archon doctor] ${results.warn} warning(s), 0 failures. Project is usable; address warnings before the next close-out.`)
    return
  }
  console.error(`[archon doctor] ${results.fail} failure(s), ${results.warn} warning(s). Fix failures before running /archon.`)
  process.exitCode = 1
}

const REQUIRED_FILES = [
  '.archon/soul.md',
  '.archon/manifest.md',
  '.archon/drift.md',
  '.archon/debt.md',
  '.archon/memos.md',
  '.archon/decisions.md',
  '.archon/VERSION',
]

const REQUIRED_PLATFORM_HINT = [
  { any: ['.cursor/', '.claude/'], label: 'platform directory (.cursor/ or .claude/)' },
]

async function checkStructure(root, results) {
  console.log('[L1 Structural]')
  for (const rel of REQUIRED_FILES) {
    const abs = path.join(root, rel)
    if (existsSync(abs)) {
      console.log(`  [OK]   ${rel}`)
    } else {
      console.log(`  [FAIL] ${rel} (missing — run \`archon init\` inside this directory first)`)
      results.fail += 1
    }
  }
  for (const { any, label } of REQUIRED_PLATFORM_HINT) {
    const present = any.find((p) => existsSync(path.join(root, p)))
    if (present) {
      console.log(`  [OK]   ${label} → ${present}`)
    } else {
      console.log(`  [FAIL] ${label} (missing)`)
      results.fail += 1
    }
  }
  console.log('')
}

async function checkContract(root, results, flags) {
  console.log('[L2 Contract]')
  const checkScript = path.join(root, 'scripts/archon-check.py')
  if (!existsSync(checkScript)) {
    console.log(`  [WARN] scripts/archon-check.py not found in ${root}. Skipping portable contract check.`)
    results.warn += 1
    console.log('')
    return
  }

  const python = flags.python ?? (process.platform === 'win32' ? 'python' : 'python3')

  try {
    await new Promise((resolve, reject) => {
      const child = spawn(python, [checkScript, '--root', root], {
        cwd: root,
        stdio: 'inherit',
      })
      child.on('exit', (code) => {
        if (code === 0) {
          console.log('  [OK]   archon-check.py passed.')
          resolve()
        } else {
          console.log(`  [FAIL] archon-check.py exited with code ${code}.`)
          results.fail += 1
          resolve()
        }
      })
      child.on('error', (err) => reject(err))
    })
  } catch (err) {
    console.log(`  [WARN] Could not run python contract check: ${err.message}`)
    console.log('         Pass --python=<path> to pick an explicit interpreter.')
    results.warn += 1
  }
  console.log('')
}

async function checkManifestHints(root, results) {
  console.log('[L3 Hints]')
  const manifestPath = path.join(root, '.archon/manifest.md')
  if (!existsSync(manifestPath)) {
    console.log('  [SKIP] .archon/manifest.md not readable (L1 already flagged this).')
    console.log('')
    return
  }

  const content = await readFile(manifestPath, 'utf8')
  const placeholders = (content.match(/<!-- [^>]*? -->/g) ?? []).length
  if (placeholders > 10) {
    console.log(`  [WARN] ${placeholders} unfilled \`<!-- ... -->\` placeholders in manifest.md — fill at least Product / Tech Stack / Validation Command before the first delivery.`)
    results.warn += 1
  } else if (placeholders > 0) {
    console.log(`  [INFO] ${placeholders} remaining \`<!-- ... -->\` placeholders in manifest.md (non-blocking).`)
  } else {
    console.log('  [OK]   manifest.md has no remaining template placeholders.')
  }

  if (!/^## Validation Command/m.test(content)) {
    console.log('  [WARN] Missing `## Validation Command` section in manifest.md.')
    results.warn += 1
  } else {
    const validationBlock = content.split(/^## /m).find((block) => block.startsWith('Validation Command'))
    const body = validationBlock ? validationBlock.replace(/^Validation Command\n/, '').trim() : ''
    if (!body || /^<!--/.test(body)) {
      console.log('  [WARN] `## Validation Command` is empty or still a placeholder. Define the project\'s lint + typecheck + test command.')
      results.warn += 1
    } else {
      console.log('  [OK]   Validation Command declared.')
    }
  }
  console.log('')
}

async function checkCanonical(root, results, flags) {
  console.log('[L4 Canonical diff (vs aaep.site/manifest.json)]')
  let manifest
  try {
    manifest = await fetchManifest({ baseUrl: resolveBaseUrl({ flags }) })
  } catch (err) {
    console.log(`  [WARN] could not fetch canonical manifest: ${err.message}`)
    console.log('         pass --offline to skip this layer.')
    results.warn += 1
    console.log('')
    return
  }
  const installedMods = await detectInstalledModules({ projectRoot: root, manifest })
  const files = flattenFiles(manifest, { moduleIds: installedMods })
  let modified = 0
  let missing = 0
  for (const f of files) {
    const c = await classifyFile({ projectRoot: root, manifestFile: f })
    if (c === 'modified') modified += 1
    else if (c === 'missing') missing += 1
  }
  if (modified === 0 && missing === 0) {
    console.log(`  [OK]   ${files.length}/${files.length} files match canonical v${manifest.version}`)
  } else {
    if (missing > 0) {
      console.log(`  [WARN] ${missing} file(s) missing vs canonical v${manifest.version}. Run \`archon sync\` for details.`)
      results.warn += 1
    }
    if (modified > 0) {
      console.log(`  [WARN] ${modified} file(s) modified vs canonical v${manifest.version}. Run \`archon sync\` for details.`)
      results.warn += 1
    }
  }
  console.log('')
}

Released under the Apache-2.0 License.