pantheon-opencode 1.6.0-beta.10 → 1.6.0-beta.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/AGENTS.global.md +42 -18
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +46 -0
  4. package/README.md +122 -38
  5. package/README.pt-BR.md +5 -4
  6. package/bin/pantheon-init.mjs +322 -101
  7. package/package.json +7 -6
  8. package/plugin.json +1 -1
  9. package/pyproject.toml +1 -1
  10. package/scripts/build-agents-md.mjs +128 -10
  11. package/scripts/check-plugin-v2-coverage.mjs +7 -7
  12. package/scripts/doctor.mjs +242 -19
  13. package/scripts/install/backup.mjs +648 -0
  14. package/scripts/install/cli-ui.mjs +79 -1
  15. package/scripts/install/exit-codes.mjs +26 -0
  16. package/scripts/install/file-inventory.mjs +555 -0
  17. package/scripts/install/interactive.mjs +15 -1
  18. package/scripts/install/migrate.mjs +32 -6
  19. package/scripts/install/model-picker.mjs +63 -22
  20. package/scripts/install/opencode-version.mjs +9 -1
  21. package/scripts/install/opencode.mjs +761 -63
  22. package/scripts/install/plan-artifact.mjs +517 -0
  23. package/scripts/install/plan.mjs +449 -0
  24. package/scripts/install/plugin.mjs +136 -18
  25. package/scripts/install/shared.mjs +249 -94
  26. package/scripts/install/state.mjs +63 -8
  27. package/scripts/install/strings.mjs +20 -0
  28. package/scripts/install/venv.mjs +25 -6
  29. package/scripts/install/verify.mjs +128 -0
  30. package/scripts/install/vision-prompt.mjs +13 -1
  31. package/scripts/install-mcp.mjs +1 -1
  32. package/scripts/manifest-inventory.mjs +49 -3
  33. package/scripts/mcp_persistence.py +50 -15
  34. package/scripts/node-engine-policy.mjs +74 -0
  35. package/scripts/package-evidence.mjs +6 -0
  36. package/scripts/postinstall.mjs +3 -4
  37. package/scripts/prune.mjs +9 -3
  38. package/scripts/reconcile.mjs +325 -0
  39. package/scripts/restore.mjs +222 -0
  40. package/scripts/uninstall.mjs +258 -34
  41. package/scripts/update.mjs +164 -0
  42. package/scripts/validate-node-engine-policy.mjs +13 -0
  43. package/scripts/version-check.mjs +34 -1
  44. package/scripts/versioning.mjs +9 -1
  45. package/skills-lock.json +4 -4
  46. package/src/agents/iris.md +6 -0
  47. package/src/agents/mnemosyne.md +7 -2
  48. package/src/agents/zeus.md +19 -62
  49. package/src/instructions/agent-return-format.instructions.md +17 -15
  50. package/src/instructions/memory-protocol.instructions.md +3 -2
  51. package/src/instructions/test-economy.instructions.md +21 -0
  52. package/src/instructions/zeus-anti-stall.instructions.md +6 -6
  53. package/src/instructions/zeus-council-synthesis.instructions.md +10 -11
  54. package/src/instructions/zeus-memory-operations.instructions.md +11 -6
  55. package/src/instructions/zeus-timeout-retry.instructions.md +28 -9
  56. package/src/mcp/mcp_persistence.py +50 -15
  57. package/src/pantheon/cost-command.ts +5 -2
  58. package/src/pantheon/model-agents.ts +19 -0
  59. package/src/pantheon/model-command.ts +218 -56
  60. package/src/pantheon/v2-model-overrides.ts +275 -0
  61. package/src/pantheon/v2-tools.ts +18 -29
  62. package/src/pantheon/v2-unsupported.mjs +2 -3
  63. package/src/plugin-v2.ts +13 -0
  64. package/src/plugins/tui/dist/tui-v2.js +2 -2
  65. package/src/plugins/tui/dist/tui.js +1 -1
  66. package/src/plugins/tui/package-lock.json +3 -3
  67. package/src/plugins/tui/package.json +2 -2
  68. package/src/skills/artifact-management/SKILL.md +5 -1
  69. package/src/skills/auto-continue/SKILL.md +14 -6
  70. package/src/skills/memory-bank/SKILL.md +5 -1
  71. package/src/skills/worktrees/SKILL.md +30 -11
@@ -35,6 +35,8 @@ import { execFileSync } from 'node:child_process'
35
35
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
36
36
  import { dirname, join, resolve } from 'node:path'
37
37
  import { fileURLToPath } from 'node:url'
38
+ import { ACTIONS, fingerprintOutput, isRecording, recordChange } from './install/plan.mjs'
39
+ import { assertNoSymlinkAncestors, assertPathWithin, verifyRegularFile } from './install/verify.mjs'
38
40
 
39
41
  const __dirname = dirname(fileURLToPath(import.meta.url))
40
42
  export const ROOT = join(__dirname, '..')
@@ -323,16 +325,41 @@ export function generateProjectAgentsMd(git = readGitInfo()) {
323
325
  * @param {boolean} [dryRun=false] - Do not write when true
324
326
  * @returns {'created' | 'skipped' | 'updated' | 'upgraded' | 'merged'}
325
327
  */
326
- export function writeAgentsMdManaged(filePath, generated, dryRun = false) {
328
+ export function writeAgentsMdManaged(filePath, generated, dryRun = false, recorder = null) {
329
+ assertPathWithin(dirname(filePath), filePath)
330
+ assertNoSymlinkAncestors(filePath)
327
331
  const existing = existsSync(filePath) ? readFileSync(filePath, 'utf8') : null
328
332
  if (existing === null) {
329
333
  if (!dryRun) {
334
+ assertNoSymlinkAncestors(dirname(filePath))
330
335
  mkdirSync(dirname(filePath), { recursive: true })
336
+ assertNoSymlinkAncestors(filePath)
331
337
  writeFileSync(filePath, generated, 'utf8')
338
+ verifyRegularFile(filePath, generated)
332
339
  }
340
+ recordChange(
341
+ {
342
+ path: filePath,
343
+ action: ACTIONS.CREATE,
344
+ bytes: Buffer.byteLength(generated, 'utf8'),
345
+ outputFingerprint: fingerprintOutput(generated),
346
+ },
347
+ recorder,
348
+ )
333
349
  return 'created'
334
350
  }
335
- if (existing === generated) return 'skipped'
351
+ if (existing === generated) {
352
+ recordChange(
353
+ {
354
+ path: filePath,
355
+ action: ACTIONS.UNCHANGED,
356
+ bytes: Buffer.byteLength(existing, 'utf8'),
357
+ outputFingerprint: fingerprintOutput(existing),
358
+ },
359
+ recorder,
360
+ )
361
+ return 'skipped'
362
+ }
336
363
 
337
364
  const begin = existing.indexOf(MANAGED_BEGIN)
338
365
  const end = existing.indexOf(MANAGED_END)
@@ -340,22 +367,71 @@ export function writeAgentsMdManaged(filePath, generated, dryRun = false) {
340
367
  const before = existing.slice(0, begin)
341
368
  const after = existing.slice(end + MANAGED_END.length)
342
369
  const next = `${before}${generated}${after}`
343
- if (next === existing) return 'skipped'
344
- if (!dryRun) writeFileSync(filePath, next, 'utf8')
370
+ if (next === existing) {
371
+ recordChange(
372
+ {
373
+ path: filePath,
374
+ action: ACTIONS.UNCHANGED,
375
+ bytes: Buffer.byteLength(existing, 'utf8'),
376
+ outputFingerprint: fingerprintOutput(existing),
377
+ },
378
+ recorder,
379
+ )
380
+ return 'skipped'
381
+ }
382
+ if (!dryRun) {
383
+ assertNoSymlinkAncestors(filePath)
384
+ writeFileSync(filePath, next, 'utf8')
385
+ verifyRegularFile(filePath, next)
386
+ }
387
+ recordChange(
388
+ {
389
+ path: filePath,
390
+ action: ACTIONS.UPDATE,
391
+ bytes: Buffer.byteLength(next, 'utf8'),
392
+ outputFingerprint: fingerprintOutput(next),
393
+ },
394
+ recorder,
395
+ )
345
396
  return before.trim() === '' && after.trim() === '' ? 'updated' : 'merged'
346
397
  }
347
398
 
348
399
  if (existing.startsWith(GENERATED_BY)) {
349
400
  // Legacy format: the header declares the whole file machine-generated.
350
- if (!dryRun) writeFileSync(filePath, generated, 'utf8')
401
+ if (!dryRun) {
402
+ assertNoSymlinkAncestors(filePath)
403
+ writeFileSync(filePath, generated, 'utf8')
404
+ verifyRegularFile(filePath, generated)
405
+ }
406
+ recordChange(
407
+ {
408
+ path: filePath,
409
+ action: ACTIONS.UPDATE,
410
+ bytes: Buffer.byteLength(generated, 'utf8'),
411
+ outputFingerprint: fingerprintOutput(generated),
412
+ },
413
+ recorder,
414
+ )
351
415
  return 'upgraded'
352
416
  }
353
417
 
354
418
  // Foreign content: preserve it verbatim and append the managed region.
419
+ const separator = existing.endsWith('\n') ? '\n' : '\n\n'
420
+ const payload = `${existing}${separator}${generated}`
355
421
  if (!dryRun) {
356
- const separator = existing.endsWith('\n') ? '\n' : '\n\n'
357
- writeFileSync(filePath, `${existing}${separator}${generated}`, 'utf8')
422
+ assertNoSymlinkAncestors(filePath)
423
+ writeFileSync(filePath, payload, 'utf8')
424
+ verifyRegularFile(filePath, payload)
358
425
  }
426
+ recordChange(
427
+ {
428
+ path: filePath,
429
+ action: ACTIONS.UPDATE,
430
+ bytes: Buffer.byteLength(payload, 'utf8'),
431
+ outputFingerprint: fingerprintOutput(payload),
432
+ },
433
+ recorder,
434
+ )
359
435
  return 'merged'
360
436
  }
361
437
 
@@ -423,11 +499,23 @@ export function generateAgentPrompt(agentName, prompt, instructions = readInstru
423
499
  * @param {string} targetDir - OpenCode agents directory
424
500
  * @param {boolean} dryRun - Do not write when true
425
501
  * @param {boolean} existingOnly - Refresh installed prompts without adding missing agents
502
+ * @param {(change: object) => void} [recorder] - optional change recorder for --dry-run preview
426
503
  * @returns {{ created: number, skipped: number }}
427
504
  */
428
- export function copyAgentPrompts(sourceDir, targetDir, dryRun = false, existingOnly = false) {
505
+ export function copyAgentPrompts(
506
+ sourceDir,
507
+ targetDir,
508
+ dryRun = false,
509
+ existingOnly = false,
510
+ recorder = null,
511
+ ) {
512
+ assertNoSymlinkAncestors(targetDir)
429
513
  const instructions = readInstructions()
430
- if (!dryRun && !existingOnly) mkdirSync(targetDir, { recursive: true })
514
+ if (!dryRun && !existingOnly) {
515
+ assertNoSymlinkAncestors(targetDir)
516
+ mkdirSync(targetDir, { recursive: true })
517
+ assertNoSymlinkAncestors(targetDir)
518
+ }
431
519
  let created = 0
432
520
  let skipped = 0
433
521
  for (const file of readdirSync(sourceDir)
@@ -439,13 +527,41 @@ export function copyAgentPrompts(sourceDir, targetDir, dryRun = false, existingO
439
527
  const agentName = file.replace(/\.md$/, '')
440
528
  const output = generateAgentPrompt(agentName, readFileSync(sourcePath, 'utf8'), instructions)
441
529
  const targetPath = join(targetDir, file)
530
+ assertPathWithin(targetDir, targetPath)
531
+ assertNoSymlinkAncestors(targetPath)
442
532
  if (existingOnly && !existsSync(targetPath)) continue
443
533
  const existing = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : null
444
534
  if (existing === output) {
445
535
  skipped++
536
+ if (isRecording(recorder)) {
537
+ recordChange(
538
+ {
539
+ path: targetPath,
540
+ action: ACTIONS.UNCHANGED,
541
+ bytes: Buffer.byteLength(output, 'utf8'),
542
+ outputFingerprint: fingerprintOutput(output),
543
+ },
544
+ recorder,
545
+ )
546
+ }
446
547
  } else {
447
- if (!dryRun) writeFileSync(targetPath, output, 'utf8')
548
+ if (!dryRun) {
549
+ assertNoSymlinkAncestors(targetPath)
550
+ writeFileSync(targetPath, output, 'utf8')
551
+ verifyRegularFile(targetPath, output)
552
+ }
448
553
  created++
554
+ if (isRecording(recorder)) {
555
+ recordChange(
556
+ {
557
+ path: targetPath,
558
+ action: existing === null ? ACTIONS.CREATE : ACTIONS.UPDATE,
559
+ bytes: Buffer.byteLength(output, 'utf8'),
560
+ outputFingerprint: fingerprintOutput(output),
561
+ },
562
+ recorder,
563
+ )
564
+ }
449
565
  }
450
566
  }
451
567
  return { created, skipped }
@@ -504,6 +620,8 @@ function main() {
504
620
 
505
621
  writeFileSync(AGENTS_GLOBAL_MD_PATH, global, 'utf8')
506
622
  writeFileSync(AGENTS_MD_PATH, project, 'utf8')
623
+ verifyRegularFile(AGENTS_GLOBAL_MD_PATH, global)
624
+ verifyRegularFile(AGENTS_MD_PATH, project)
507
625
  console.log(
508
626
  `✅ Generated AGENTS.md (${logicalLineCount(project)} lines) + AGENTS.global.md ` +
509
627
  `(${logicalLineCount(global)} lines, ${sharedCount} shared instruction sections)`,
@@ -3,8 +3,8 @@ import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { dirname, join, resolve } from 'node:path'
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url'
6
+ import { isSupportedNodeVersion, MIN_SUPPORTED_NODE_VERSION } from './node-engine-policy.mjs'
6
7
 
7
- export const REQUIRED_NODE_VERSION = 'v24.15.0'
8
8
  export const LINE_THRESHOLD_NUMERATOR = 4
9
9
  export const LINE_THRESHOLD_DENOMINATOR = 5
10
10
 
@@ -103,13 +103,13 @@ export function parseTargetLcov(report, repoRoot, targetPath) {
103
103
  const found = integerField(exactlyOneField(lines, 'LF'), 'LF', 1)
104
104
  const hit = integerField(exactlyOneField(lines, 'LH'), 'LH', 0)
105
105
  if (hit > found) throw new Error(`LH ${hit} exceeds LF ${found}`)
106
- if (found !== lineRecords.size) {
107
- throw new Error(`LF ${found} does not equal ${lineRecords.size} unique DA entries`)
106
+ if (lineRecords.size > found) {
107
+ throw new Error(`LF ${found} is less than ${lineRecords.size} unique DA entries`)
108
108
  }
109
109
 
110
110
  const coveredRecords = [...lineRecords.values()].filter((count) => count > 0).length
111
- if (hit !== coveredRecords) {
112
- throw new Error(`LH ${hit} does not equal ${coveredRecords} covered DA entries`)
111
+ if (hit < coveredRecords) {
112
+ throw new Error(`LH ${hit} is less than ${coveredRecords} covered DA entries`)
113
113
  }
114
114
 
115
115
  return {
@@ -142,9 +142,9 @@ function listTypeScriptTests(repoRoot) {
142
142
  }
143
143
 
144
144
  function runCoverage() {
145
- if (process.version !== REQUIRED_NODE_VERSION) {
145
+ if (!isSupportedNodeVersion(process.versions.node)) {
146
146
  throw new Error(
147
- `plugin-v2 coverage requires Node ${REQUIRED_NODE_VERSION}; got ${process.version}`,
147
+ `plugin-v2 coverage requires Node.js >= ${MIN_SUPPORTED_NODE_VERSION}; got ${process.version}`,
148
148
  )
149
149
  }
150
150
 
@@ -30,6 +30,9 @@ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
30
30
  import { fileURLToPath } from 'node:url'
31
31
 
32
32
  import { listV2UnsupportedFeatures } from '../src/pantheon/v2-unsupported.mjs'
33
+ import { classifyFileInventory } from './install/file-inventory.mjs'
34
+ import { readState } from './install/state.mjs'
35
+ import { isSupportedNodeVersion, MIN_SUPPORTED_NODE_VERSION } from './node-engine-policy.mjs'
33
36
 
34
37
  // ---------------------------------------------------------------------------
35
38
  // Paths
@@ -47,6 +50,9 @@ const counts = { pass: 0, warn: 0, error: 0, info: 0 }
47
50
 
48
51
  const results = []
49
52
  let exitCode = 0
53
+ let jsonMode = false
54
+ let jsonOriginalWrite = null
55
+ let jsonArgs = null
50
56
 
51
57
  // ---------------------------------------------------------------------------
52
58
  // CLI arg parsing
@@ -58,6 +64,7 @@ function parseArgs(argv) {
58
64
  platform: null,
59
65
  fix: false,
60
66
  verbose: false,
67
+ json: false,
61
68
  help: false,
62
69
  profile: process.env.PANTHEON_PROFILE ?? 'auto',
63
70
  }
@@ -73,6 +80,9 @@ function parseArgs(argv) {
73
80
  case '--verbose':
74
81
  args.verbose = true
75
82
  break
83
+ case '--json':
84
+ args.json = true
85
+ break
76
86
  case '--profile':
77
87
  args.profile = argv[++i] ?? 'auto'
78
88
  break
@@ -123,22 +133,22 @@ const ICON = {
123
133
  info: '\u2139\uFE0F', // ℹ️
124
134
  }
125
135
 
126
- function emit(category, message) {
136
+ function emit(category, message, metadata = {}) {
127
137
  counts[category]++
128
- results.push({ category, message })
138
+ results.push({ category, message, ...metadata })
129
139
  if (category === 'pass' && !process.argv.includes('--verbose')) return
130
140
  const prefix = ICON[category] ?? category
131
141
  console.log(` ${prefix} ${message}`)
132
142
  }
133
143
 
134
- function pass(msg) {
135
- emit('pass', msg)
144
+ function pass(msg, metadata = {}) {
145
+ emit('pass', msg, metadata)
136
146
  }
137
- function warn(msg) {
138
- emit('warn', msg)
147
+ function warn(msg, metadata = {}) {
148
+ emit('warn', msg, metadata)
139
149
  }
140
- function error(msg) {
141
- emit('error', msg)
150
+ function error(msg, metadata = {}) {
151
+ emit('error', msg, metadata)
142
152
  exitCode = 2
143
153
  }
144
154
  function info(msg) {
@@ -159,7 +169,8 @@ function showHelp() {
159
169
  node scripts/doctor.mjs auto-detect, cwd
160
170
  node scripts/doctor.mjs --target /path/to/project target project
161
171
  node scripts/doctor.mjs --profile global|lite|sandbox profile policy
162
- node scripts/doctor.mjs --fix attempt auto-fixes
172
+ node scripts/doctor.mjs --fix attempt auto-fixes
173
+ node scripts/doctor.mjs --json read-only JSON report
163
174
  node scripts/doctor.mjs --verbose detailed output
164
175
  node scripts/doctor.mjs --help show this help
165
176
 
@@ -1265,6 +1276,7 @@ export function checkInstallVersionDrift(args) {
1265
1276
  warn(
1266
1277
  `Package v${pkgVersion} is newer than the last-synced installation (v${installed}) — ` +
1267
1278
  'run `npx pantheon-opencode update` (or re-run init) to refresh config, venv and MCP entries',
1279
+ { code: 'install_version_drift', classification: 'advisory' },
1268
1280
  )
1269
1281
  return 'package-newer'
1270
1282
  }
@@ -1272,6 +1284,7 @@ export function checkInstallVersionDrift(args) {
1272
1284
  warn(
1273
1285
  `Installation marker (v${installed}) is newer than this package (v${pkgVersion}) — ` +
1274
1286
  'the doctor is running from a different install than the last sync',
1287
+ { code: 'install_version_drift', classification: 'advisory' },
1275
1288
  )
1276
1289
  return 'install-newer'
1277
1290
  }
@@ -1841,13 +1854,47 @@ export function classifyNodeSqliteProbe(probe) {
1841
1854
  }
1842
1855
  }
1843
1856
 
1857
+ /** Classify the Node package-support policy independently of feature probes. */
1858
+ export function classifyNodePackageRuntime(version) {
1859
+ if (isSupportedNodeVersion(version)) {
1860
+ return {
1861
+ status: 'supported',
1862
+ code: 'node_version_supported',
1863
+ message: `Node.js v${String(version).replace(/^v/, '')} meets Pantheon's package requirement (>= ${MIN_SUPPORTED_NODE_VERSION})`,
1864
+ }
1865
+ }
1866
+ return {
1867
+ status: 'unsupported',
1868
+ code: 'node_version_unsupported',
1869
+ message: `Pantheon requires Node.js >= ${MIN_SUPPORTED_NODE_VERSION}; current runtime is v${String(version).replace(/^v/, '')}`,
1870
+ }
1871
+ }
1872
+
1844
1873
  function checkNodeRuntime() {
1845
1874
  section('K. Node Runtime')
1875
+ const packageRuntime = classifyNodePackageRuntime(process.versions.node)
1876
+ if (packageRuntime.status === 'supported') {
1877
+ pass(packageRuntime.message, {
1878
+ code: packageRuntime.code,
1879
+ classification: 'runtime',
1880
+ })
1881
+ } else {
1882
+ error(packageRuntime.message, { code: packageRuntime.code, classification: 'runtime' })
1883
+ }
1884
+
1885
+ // This capability check is independent of the package support floor: it
1886
+ // describes pantheon_cost's optional backend, not Pantheon's Node support.
1846
1887
  const outcome = classifyNodeSqliteProbe(probeNodeSqlite())
1847
1888
  if (outcome.status === 'ok') {
1848
- pass(outcome.message)
1889
+ pass(outcome.message, {
1890
+ code: 'node_sqlite_supported',
1891
+ classification: 'capability',
1892
+ })
1849
1893
  } else {
1850
- warn(outcome.message)
1894
+ warn(outcome.message, {
1895
+ code: 'node_sqlite_unsupported',
1896
+ classification: 'capability',
1897
+ })
1851
1898
  info(
1852
1899
  `Upgrade to Node >= ${NODE_SQLITE_MIN} (or a runtime with node:sqlite) to enable pantheon_cost`,
1853
1900
  )
@@ -1898,22 +1945,156 @@ function printSummary(targetPath) {
1898
1945
  // Main
1899
1946
  // ---------------------------------------------------------------------------
1900
1947
 
1948
+ async function checkInstallerFileInventory(args) {
1949
+ const state = readState(args.target)
1950
+ if (!state) return
1951
+ const selectedComponents = [
1952
+ 'agents',
1953
+ 'skills',
1954
+ 'instructions',
1955
+ 'prompts',
1956
+ 'commands',
1957
+ 'plugins',
1958
+ 'runtime',
1959
+ ]
1960
+ let desiredFiles = new Map()
1961
+ let desiredUnavailable = true
1962
+ let selectedLayouts = {}
1963
+ try {
1964
+ const { installOpenCode, isGlobalConfigDir } = await import('./install/opencode.mjs')
1965
+ const preview = await installOpenCode(args.target, true, false, selectedComponents, {
1966
+ version: 'auto',
1967
+ quiet: true,
1968
+ quietPlan: true,
1969
+ planOnly: true,
1970
+ headless: true,
1971
+ })
1972
+ desiredFiles = new Map(
1973
+ (preview?.files ?? [])
1974
+ .filter((file) => typeof file.outputFingerprint === 'string')
1975
+ .map((file) => [file.path, { sha256: file.outputFingerprint }]),
1976
+ )
1977
+ const layout = isGlobalConfigDir(args.target)
1978
+ ? 'global'
1979
+ : preview?.openCodeVersion === 'v2'
1980
+ ? 'project-v2'
1981
+ : 'project-v1'
1982
+ selectedLayouts = Object.fromEntries(selectedComponents.map((component) => [component, layout]))
1983
+ desiredUnavailable = false
1984
+ } catch {
1985
+ // Doctor remains advisory if a source/layout preview cannot be computed.
1986
+ // The fallback labels equal recorded hashes explicitly, never as current.
1987
+ }
1988
+ let report
1989
+ try {
1990
+ report = classifyFileInventory(args.target, state, {
1991
+ desiredFiles,
1992
+ desiredUnavailable,
1993
+ selectedComponents,
1994
+ selectedLayouts,
1995
+ })
1996
+ } catch {
1997
+ warn('Installer file inventory could not be safely inspected.', {
1998
+ code: 'installer_inventory_unreadable',
1999
+ classification: 'ambiguous',
2000
+ })
2001
+ return
2002
+ }
2003
+ if (report.legacyAmbiguous) {
2004
+ warn('Legacy installed files have no ownership inventory; cleanup requires manual review.', {
2005
+ code: 'installer_inventory_legacy_unknown',
2006
+ classification: 'ambiguous',
2007
+ })
2008
+ }
2009
+ const recordedCurrent = report.entries.filter(
2010
+ (entry) => entry.classification === 'recorded-current',
2011
+ ).length
2012
+ if (recordedCurrent > 0) {
2013
+ emit(
2014
+ 'info',
2015
+ `${recordedCurrent} inventoried file(s) match their recorded install hashes; current package payload was not compared.`,
2016
+ { code: 'installer_file_recorded_current', classification: 'recorded-current' },
2017
+ )
2018
+ }
2019
+ for (const entry of report.entries) {
2020
+ if (entry.classification === 'modified') {
2021
+ warn(`Installer-owned file was modified and will be preserved: ${entry.path}`, {
2022
+ code: 'installer_file_modified',
2023
+ classification: 'modified',
2024
+ })
2025
+ } else if (entry.classification === 'missing') {
2026
+ warn(`Previously installer-owned file is missing: ${entry.path}`, {
2027
+ code: 'installer_file_missing',
2028
+ classification: 'missing',
2029
+ })
2030
+ } else if (entry.classification === 'update-drift') {
2031
+ warn(`Installer source has a newer version of an unchanged owned file: ${entry.path}`, {
2032
+ code: 'installer_file_update_available',
2033
+ classification: 'update-drift',
2034
+ })
2035
+ } else if (entry.classification === 'stale-owned') {
2036
+ warn(`Stale installer-owned file is eligible for reviewed cleanup: ${entry.path}`, {
2037
+ code: 'installer_file_stale_owned',
2038
+ classification: 'stale-owned',
2039
+ })
2040
+ } else if (entry.classification === 'ambiguous') {
2041
+ warn(`Installer ownership is ambiguous for ${entry.path}; file preserved.`, {
2042
+ code: 'installer_file_ambiguous',
2043
+ classification: 'ambiguous',
2044
+ })
2045
+ }
2046
+ }
2047
+ }
2048
+
1901
2049
  async function main() {
1902
2050
  const args = parseArgs(process.argv)
2051
+ jsonMode = args.json
2052
+ jsonArgs = args
2053
+ if (jsonMode) {
2054
+ jsonOriginalWrite = process.stdout.write
2055
+ process.stdout.write = () => true
2056
+ }
1903
2057
 
1904
2058
  if (args.help) {
2059
+ if (jsonMode) {
2060
+ process.stdout.write = jsonOriginalWrite
2061
+ jsonOriginalWrite = null
2062
+ }
1905
2063
  showHelp()
1906
- process.exit(0)
2064
+ process.exitCode = 0
2065
+ return
2066
+ }
2067
+
2068
+ if (args.json && args.fix) {
2069
+ writeDoctorJson(
2070
+ {
2071
+ schema_version: 1,
2072
+ command: 'doctor',
2073
+ status: 'error',
2074
+ target: resolve(args.target),
2075
+ profile: args.profile,
2076
+ summary: { ...counts, exit_code: 1 },
2077
+ findings: [{ category: 'error', code: 'json_fix_unsupported', classification: 'usage' }],
2078
+ },
2079
+ 1,
2080
+ )
2081
+ return
1907
2082
  }
1908
2083
 
1909
2084
  // Report the resolved validation profile up front (auto → global/sandbox)
1910
2085
  // so callers — including the `doctor` CLI wrapper — can see which policy
1911
2086
  // gate applied before any check output.
1912
- console.log(`Validation profile: ${args.profile}`)
2087
+ if (!jsonMode) console.log(`Validation profile: ${args.profile}`)
1913
2088
 
1914
2089
  if (!existsSync(args.target)) {
1915
- console.error(`❌ Target directory does not exist: ${args.target}`)
1916
- process.exit(2)
2090
+ if (jsonMode) {
2091
+ error(`Target directory does not exist: ${args.target}`)
2092
+ writeDoctorJson(createDoctorJsonReport(args), exitCode)
2093
+ } else {
2094
+ console.error(`❌ Target directory does not exist: ${args.target}`)
2095
+ process.exitCode = 2
2096
+ }
2097
+ return
1917
2098
  }
1918
2099
 
1919
2100
  // Verify it looks like a Pantheon project or an OpenCode install target.
@@ -1944,7 +2125,14 @@ async function main() {
1944
2125
  checkCodeModeManifest(args)
1945
2126
  checkInstallVersionDrift(args)
1946
2127
  checkPluginVersionDrift(args)
1947
- await checkMcpRuntimeSmoke(args)
2128
+ await checkInstallerFileInventory(args)
2129
+ if (jsonMode) {
2130
+ info('MCP runtime smoke skipped to keep --json read-only')
2131
+ results.at(-1).code = 'mcp_runtime_smoke_skipped'
2132
+ results.at(-1).classification = 'not_run'
2133
+ } else {
2134
+ await checkMcpRuntimeSmoke(args)
2135
+ }
1948
2136
  checkPermissionMismatches(args)
1949
2137
  checkSyncStatus(args)
1950
2138
  checkGitStatus(args)
@@ -1954,13 +2142,48 @@ async function main() {
1954
2142
  checkV2UnsupportedFeatures(args)
1955
2143
 
1956
2144
  printSummary(args.target)
2145
+ if (jsonMode) writeDoctorJson(createDoctorJsonReport(args), exitCode)
2146
+ else process.exitCode = exitCode
2147
+ }
1957
2148
 
1958
- process.exit(exitCode)
2149
+ function createDoctorJsonReport(args) {
2150
+ return {
2151
+ schema_version: 1,
2152
+ command: 'doctor',
2153
+ status: exitCode === 2 ? 'error' : counts.warn > 0 ? 'warning' : 'ok',
2154
+ target: resolve(args.target),
2155
+ profile: args.profile,
2156
+ summary: { ...counts, exit_code: exitCode },
2157
+ findings: results,
2158
+ }
2159
+ }
2160
+
2161
+ function writeDoctorJson(report, code) {
2162
+ const writer = jsonOriginalWrite ?? process.stdout.write
2163
+ process.stdout.write = writer
2164
+ jsonOriginalWrite = null
2165
+ writer.call(process.stdout, `${JSON.stringify(report)}\n`)
2166
+ process.exitCode = code
1959
2167
  }
1960
2168
 
1961
2169
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
1962
2170
  main().catch((err) => {
1963
- console.error(`❌ Doctor crashed: ${err.stack ?? err.message}`)
1964
- process.exit(2)
2171
+ if (jsonMode) {
2172
+ writeDoctorJson(
2173
+ {
2174
+ schema_version: 1,
2175
+ command: 'doctor',
2176
+ status: 'error',
2177
+ target: jsonArgs ? resolve(jsonArgs.target) : null,
2178
+ profile: jsonArgs?.profile ?? null,
2179
+ summary: { ...counts, exit_code: 2 },
2180
+ findings: [{ category: 'error', code: 'doctor_failure', classification: 'error' }],
2181
+ },
2182
+ 2,
2183
+ )
2184
+ } else {
2185
+ console.error(`❌ Doctor crashed: ${err.stack ?? err.message}`)
2186
+ process.exitCode = 2
2187
+ }
1965
2188
  })
1966
2189
  }