pantheon-opencode 1.6.0-beta.11 → 1.6.0-beta.13

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 (53) hide show
  1. package/AGENTS.global.md +3 -3
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +39 -0
  4. package/README.md +85 -31
  5. package/README.pt-BR.md +7 -4
  6. package/bin/pantheon-init.mjs +278 -102
  7. package/package.json +5 -3
  8. package/plugin.json +1 -1
  9. package/pyproject.toml +1 -1
  10. package/scripts/build-agents-md.mjs +77 -13
  11. package/scripts/check-plugin-v2-coverage.mjs +7 -7
  12. package/scripts/doctor.mjs +242 -19
  13. package/scripts/install/backup.mjs +191 -18
  14. package/scripts/install/cli-ui.mjs +73 -0
  15. package/scripts/install/file-inventory.mjs +555 -0
  16. package/scripts/install/interactive.mjs +69 -391
  17. package/scripts/install/migrate.mjs +45 -38
  18. package/scripts/install/model-picker.mjs +63 -22
  19. package/scripts/install/opencode-version.mjs +9 -1
  20. package/scripts/install/opencode.mjs +279 -79
  21. package/scripts/install/plan-artifact.mjs +517 -0
  22. package/scripts/install/plan.mjs +165 -3
  23. package/scripts/install/plugin.mjs +118 -14
  24. package/scripts/install/prompt-result.mjs +2 -0
  25. package/scripts/install/shared.mjs +188 -65
  26. package/scripts/install/state.mjs +63 -8
  27. package/scripts/install/venv.mjs +15 -6
  28. package/scripts/install/verify.mjs +128 -0
  29. package/scripts/install/vision-prompt.mjs +2 -0
  30. package/scripts/install-mcp.mjs +1 -1
  31. package/scripts/manifest-inventory.mjs +49 -3
  32. package/scripts/node-engine-policy.mjs +85 -0
  33. package/scripts/package-evidence.mjs +6 -0
  34. package/scripts/postinstall.mjs +26 -26
  35. package/scripts/reconcile.mjs +325 -0
  36. package/scripts/sync-tui.mjs +5 -1
  37. package/scripts/uninstall.mjs +258 -34
  38. package/scripts/update.mjs +164 -0
  39. package/scripts/validate-node-engine-policy.mjs +13 -0
  40. package/scripts/version-check.mjs +34 -1
  41. package/scripts/versioning.mjs +9 -1
  42. package/skills-lock.json +3 -3
  43. package/src/agents/mnemosyne.md +7 -2
  44. package/src/instructions/memory-protocol.instructions.md +2 -2
  45. package/src/instructions/zeus-memory-operations.instructions.md +4 -3
  46. package/src/pantheon/v2-unsupported.mjs +2 -3
  47. package/src/plugins/tui/dist/tui-v2.js +2 -2
  48. package/src/plugins/tui/dist/tui.js +1 -1
  49. package/src/plugins/tui/package-lock.json +3 -3
  50. package/src/plugins/tui/package.json +2 -2
  51. package/src/skills/artifact-management/SKILL.md +5 -1
  52. package/src/skills/memory-bank/SKILL.md +5 -1
  53. package/src/skills/worktrees/SKILL.md +30 -11
@@ -35,8 +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
-
39
- import { ACTIONS, isRecording, recordChange } from './install/plan.mjs'
38
+ import { ACTIONS, fingerprintOutput, isRecording, recordChange } from './install/plan.mjs'
39
+ import { assertNoSymlinkAncestors, assertPathWithin, verifyRegularFile } from './install/verify.mjs'
40
40
 
41
41
  const __dirname = dirname(fileURLToPath(import.meta.url))
42
42
  export const ROOT = join(__dirname, '..')
@@ -326,21 +326,36 @@ export function generateProjectAgentsMd(git = readGitInfo()) {
326
326
  * @returns {'created' | 'skipped' | 'updated' | 'upgraded' | 'merged'}
327
327
  */
328
328
  export function writeAgentsMdManaged(filePath, generated, dryRun = false, recorder = null) {
329
+ assertPathWithin(dirname(filePath), filePath)
330
+ assertNoSymlinkAncestors(filePath)
329
331
  const existing = existsSync(filePath) ? readFileSync(filePath, 'utf8') : null
330
332
  if (existing === null) {
331
333
  if (!dryRun) {
334
+ assertNoSymlinkAncestors(dirname(filePath))
332
335
  mkdirSync(dirname(filePath), { recursive: true })
336
+ assertNoSymlinkAncestors(filePath)
333
337
  writeFileSync(filePath, generated, 'utf8')
338
+ verifyRegularFile(filePath, generated)
334
339
  }
335
340
  recordChange(
336
- { path: filePath, action: ACTIONS.CREATE, bytes: Buffer.byteLength(generated, 'utf8') },
341
+ {
342
+ path: filePath,
343
+ action: ACTIONS.CREATE,
344
+ bytes: Buffer.byteLength(generated, 'utf8'),
345
+ outputFingerprint: fingerprintOutput(generated),
346
+ },
337
347
  recorder,
338
348
  )
339
349
  return 'created'
340
350
  }
341
351
  if (existing === generated) {
342
352
  recordChange(
343
- { path: filePath, action: ACTIONS.UNCHANGED, bytes: Buffer.byteLength(existing, 'utf8') },
353
+ {
354
+ path: filePath,
355
+ action: ACTIONS.UNCHANGED,
356
+ bytes: Buffer.byteLength(existing, 'utf8'),
357
+ outputFingerprint: fingerprintOutput(existing),
358
+ },
344
359
  recorder,
345
360
  )
346
361
  return 'skipped'
@@ -354,14 +369,28 @@ export function writeAgentsMdManaged(filePath, generated, dryRun = false, record
354
369
  const next = `${before}${generated}${after}`
355
370
  if (next === existing) {
356
371
  recordChange(
357
- { path: filePath, action: ACTIONS.UNCHANGED, bytes: Buffer.byteLength(existing, 'utf8') },
372
+ {
373
+ path: filePath,
374
+ action: ACTIONS.UNCHANGED,
375
+ bytes: Buffer.byteLength(existing, 'utf8'),
376
+ outputFingerprint: fingerprintOutput(existing),
377
+ },
358
378
  recorder,
359
379
  )
360
380
  return 'skipped'
361
381
  }
362
- if (!dryRun) writeFileSync(filePath, next, 'utf8')
382
+ if (!dryRun) {
383
+ assertNoSymlinkAncestors(filePath)
384
+ writeFileSync(filePath, next, 'utf8')
385
+ verifyRegularFile(filePath, next)
386
+ }
363
387
  recordChange(
364
- { path: filePath, action: ACTIONS.UPDATE, bytes: Buffer.byteLength(next, 'utf8') },
388
+ {
389
+ path: filePath,
390
+ action: ACTIONS.UPDATE,
391
+ bytes: Buffer.byteLength(next, 'utf8'),
392
+ outputFingerprint: fingerprintOutput(next),
393
+ },
365
394
  recorder,
366
395
  )
367
396
  return before.trim() === '' && after.trim() === '' ? 'updated' : 'merged'
@@ -369,9 +398,18 @@ export function writeAgentsMdManaged(filePath, generated, dryRun = false, record
369
398
 
370
399
  if (existing.startsWith(GENERATED_BY)) {
371
400
  // Legacy format: the header declares the whole file machine-generated.
372
- if (!dryRun) writeFileSync(filePath, generated, 'utf8')
401
+ if (!dryRun) {
402
+ assertNoSymlinkAncestors(filePath)
403
+ writeFileSync(filePath, generated, 'utf8')
404
+ verifyRegularFile(filePath, generated)
405
+ }
373
406
  recordChange(
374
- { path: filePath, action: ACTIONS.UPDATE, bytes: Buffer.byteLength(generated, 'utf8') },
407
+ {
408
+ path: filePath,
409
+ action: ACTIONS.UPDATE,
410
+ bytes: Buffer.byteLength(generated, 'utf8'),
411
+ outputFingerprint: fingerprintOutput(generated),
412
+ },
375
413
  recorder,
376
414
  )
377
415
  return 'upgraded'
@@ -381,10 +419,17 @@ export function writeAgentsMdManaged(filePath, generated, dryRun = false, record
381
419
  const separator = existing.endsWith('\n') ? '\n' : '\n\n'
382
420
  const payload = `${existing}${separator}${generated}`
383
421
  if (!dryRun) {
422
+ assertNoSymlinkAncestors(filePath)
384
423
  writeFileSync(filePath, payload, 'utf8')
424
+ verifyRegularFile(filePath, payload)
385
425
  }
386
426
  recordChange(
387
- { path: filePath, action: ACTIONS.UPDATE, bytes: Buffer.byteLength(payload, 'utf8') },
427
+ {
428
+ path: filePath,
429
+ action: ACTIONS.UPDATE,
430
+ bytes: Buffer.byteLength(payload, 'utf8'),
431
+ outputFingerprint: fingerprintOutput(payload),
432
+ },
388
433
  recorder,
389
434
  )
390
435
  return 'merged'
@@ -464,8 +509,13 @@ export function copyAgentPrompts(
464
509
  existingOnly = false,
465
510
  recorder = null,
466
511
  ) {
512
+ assertNoSymlinkAncestors(targetDir)
467
513
  const instructions = readInstructions()
468
- if (!dryRun && !existingOnly) mkdirSync(targetDir, { recursive: true })
514
+ if (!dryRun && !existingOnly) {
515
+ assertNoSymlinkAncestors(targetDir)
516
+ mkdirSync(targetDir, { recursive: true })
517
+ assertNoSymlinkAncestors(targetDir)
518
+ }
469
519
  let created = 0
470
520
  let skipped = 0
471
521
  for (const file of readdirSync(sourceDir)
@@ -477,18 +527,29 @@ export function copyAgentPrompts(
477
527
  const agentName = file.replace(/\.md$/, '')
478
528
  const output = generateAgentPrompt(agentName, readFileSync(sourcePath, 'utf8'), instructions)
479
529
  const targetPath = join(targetDir, file)
530
+ assertPathWithin(targetDir, targetPath)
531
+ assertNoSymlinkAncestors(targetPath)
480
532
  if (existingOnly && !existsSync(targetPath)) continue
481
533
  const existing = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : null
482
534
  if (existing === output) {
483
535
  skipped++
484
536
  if (isRecording(recorder)) {
485
537
  recordChange(
486
- { path: targetPath, action: ACTIONS.UNCHANGED, bytes: Buffer.byteLength(output, 'utf8') },
538
+ {
539
+ path: targetPath,
540
+ action: ACTIONS.UNCHANGED,
541
+ bytes: Buffer.byteLength(output, 'utf8'),
542
+ outputFingerprint: fingerprintOutput(output),
543
+ },
487
544
  recorder,
488
545
  )
489
546
  }
490
547
  } else {
491
- if (!dryRun) writeFileSync(targetPath, output, 'utf8')
548
+ if (!dryRun) {
549
+ assertNoSymlinkAncestors(targetPath)
550
+ writeFileSync(targetPath, output, 'utf8')
551
+ verifyRegularFile(targetPath, output)
552
+ }
492
553
  created++
493
554
  if (isRecording(recorder)) {
494
555
  recordChange(
@@ -496,6 +557,7 @@ export function copyAgentPrompts(
496
557
  path: targetPath,
497
558
  action: existing === null ? ACTIONS.CREATE : ACTIONS.UPDATE,
498
559
  bytes: Buffer.byteLength(output, 'utf8'),
560
+ outputFingerprint: fingerprintOutput(output),
499
561
  },
500
562
  recorder,
501
563
  )
@@ -558,6 +620,8 @@ function main() {
558
620
 
559
621
  writeFileSync(AGENTS_GLOBAL_MD_PATH, global, 'utf8')
560
622
  writeFileSync(AGENTS_MD_PATH, project, 'utf8')
623
+ verifyRegularFile(AGENTS_GLOBAL_MD_PATH, global)
624
+ verifyRegularFile(AGENTS_MD_PATH, project)
561
625
  console.log(
562
626
  `✅ Generated AGENTS.md (${logicalLineCount(project)} lines) + AGENTS.global.md ` +
563
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
  }