pantheon-opencode 1.6.0-beta.11 → 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.
- package/AGENTS.global.md +3 -3
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +22 -0
- package/README.md +69 -30
- package/README.pt-BR.md +5 -4
- package/bin/pantheon-init.mjs +279 -101
- package/package.json +4 -3
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/build-agents-md.mjs +77 -13
- package/scripts/check-plugin-v2-coverage.mjs +7 -7
- package/scripts/doctor.mjs +242 -19
- package/scripts/install/backup.mjs +191 -18
- package/scripts/install/cli-ui.mjs +73 -0
- package/scripts/install/file-inventory.mjs +555 -0
- package/scripts/install/migrate.mjs +45 -38
- package/scripts/install/model-picker.mjs +63 -22
- package/scripts/install/opencode-version.mjs +9 -1
- package/scripts/install/opencode.mjs +265 -79
- package/scripts/install/plan-artifact.mjs +517 -0
- package/scripts/install/plan.mjs +165 -3
- package/scripts/install/plugin.mjs +118 -14
- package/scripts/install/shared.mjs +186 -65
- package/scripts/install/state.mjs +63 -8
- package/scripts/install/venv.mjs +15 -6
- package/scripts/install/verify.mjs +128 -0
- package/scripts/install/vision-prompt.mjs +2 -0
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/manifest-inventory.mjs +49 -3
- package/scripts/node-engine-policy.mjs +74 -0
- package/scripts/package-evidence.mjs +6 -0
- package/scripts/postinstall.mjs +3 -4
- package/scripts/reconcile.mjs +325 -0
- package/scripts/uninstall.mjs +258 -34
- package/scripts/update.mjs +164 -0
- package/scripts/validate-node-engine-policy.mjs +13 -0
- package/scripts/version-check.mjs +34 -1
- package/scripts/versioning.mjs +9 -1
- package/skills-lock.json +3 -3
- package/src/agents/mnemosyne.md +7 -2
- package/src/instructions/memory-protocol.instructions.md +2 -2
- package/src/instructions/zeus-memory-operations.instructions.md +4 -3
- package/src/pantheon/v2-unsupported.mjs +2 -3
- package/src/plugins/tui/dist/tui-v2.js +2 -2
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/package-lock.json +3 -3
- package/src/plugins/tui/package.json +2 -2
- package/src/skills/artifact-management/SKILL.md +5 -1
- package/src/skills/memory-bank/SKILL.md +5 -1
- 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 {
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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)
|
|
382
|
+
if (!dryRun) {
|
|
383
|
+
assertNoSymlinkAncestors(filePath)
|
|
384
|
+
writeFileSync(filePath, next, 'utf8')
|
|
385
|
+
verifyRegularFile(filePath, next)
|
|
386
|
+
}
|
|
363
387
|
recordChange(
|
|
364
|
-
{
|
|
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)
|
|
401
|
+
if (!dryRun) {
|
|
402
|
+
assertNoSymlinkAncestors(filePath)
|
|
403
|
+
writeFileSync(filePath, generated, 'utf8')
|
|
404
|
+
verifyRegularFile(filePath, generated)
|
|
405
|
+
}
|
|
373
406
|
recordChange(
|
|
374
|
-
{
|
|
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
|
-
{
|
|
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)
|
|
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
|
-
{
|
|
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)
|
|
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 (
|
|
107
|
-
throw new Error(`LF ${found}
|
|
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
|
|
112
|
-
throw new Error(`LH ${hit}
|
|
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.
|
|
145
|
+
if (!isSupportedNodeVersion(process.versions.node)) {
|
|
146
146
|
throw new Error(
|
|
147
|
-
`plugin-v2 coverage requires Node ${
|
|
147
|
+
`plugin-v2 coverage requires Node.js >= ${MIN_SUPPORTED_NODE_VERSION}; got ${process.version}`,
|
|
148
148
|
)
|
|
149
149
|
}
|
|
150
150
|
|
package/scripts/doctor.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
1916
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1964
|
-
|
|
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
|
}
|