spexcode 0.4.0 → 0.4.3
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/README.md +37 -17
- package/package.json +9 -5
- package/spec-cli/README.md +23 -14
- package/spec-cli/hooks/dispatch.sh +7 -5
- package/spec-cli/hooks/harness.sh +19 -8
- package/spec-cli/src/anchors.ts +163 -30
- package/spec-cli/src/cli.ts +97 -42
- package/spec-cli/src/client.ts +34 -11
- package/spec-cli/src/doctor.ts +174 -36
- package/spec-cli/src/gateway-auth.ts +194 -0
- package/spec-cli/src/gateway-hub.ts +360 -0
- package/spec-cli/src/gateway.ts +31 -16
- package/spec-cli/src/git.ts +12 -5
- package/spec-cli/src/graph.ts +53 -36
- package/spec-cli/src/graphStream.ts +265 -34
- package/spec-cli/src/guide.ts +157 -76
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +266 -67
- package/spec-cli/src/help.ts +84 -41
- package/spec-cli/src/host.ts +610 -0
- package/spec-cli/src/identity-presets.d.ts +13 -0
- package/spec-cli/src/identity-presets.js +128 -0
- package/spec-cli/src/index.ts +163 -86
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +115 -169
- package/spec-cli/src/login-page.ts +12 -5
- package/spec-cli/src/materialize.ts +39 -13
- package/spec-cli/src/mentions.ts +16 -11
- package/spec-cli/src/opencode.ts +145 -0
- package/spec-cli/src/pi-harness.ts +124 -0
- package/spec-cli/src/project-identity.ts +73 -0
- package/spec-cli/src/pty-bridge.ts +321 -521
- package/spec-cli/src/pty-helper.mjs +86 -0
- package/spec-cli/src/reaper.ts +16 -8
- package/spec-cli/src/reviewSnapshot.ts +23 -0
- package/spec-cli/src/reviews.ts +347 -0
- package/spec-cli/src/session-timeline.ts +162 -0
- package/spec-cli/src/sessions.ts +210 -63
- package/spec-cli/src/shim-runtime.ts +169 -0
- package/spec-cli/src/slash-commands.ts +76 -0
- package/spec-cli/src/source-files.ts +79 -0
- package/spec-cli/src/specs.ts +47 -24
- package/spec-cli/src/supervise.ts +37 -15
- package/spec-cli/src/tree.ts +8 -8
- package/spec-cli/src/uninstall.ts +56 -23
- package/spec-cli/templates/hooks/prepare-commit-msg +32 -13
- package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +24 -18
- package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +30 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
- package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
- package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +62 -0
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +19 -0
- package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
- package/spec-cli/templates/spec/project/spec.md +6 -5
- package/spec-cli/templates/spexcode.json +4 -2
- package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
- package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
- package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
- package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
- package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
- package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
- package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
- package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
- package/spec-dashboard/dist/index.html +10 -10
- package/spec-dashboard/src/reviewFilters.js +279 -0
- package/spec-dashboard/src/reviewQuery.js +174 -0
- package/spec-dashboard/src/session.js +167 -0
- package/spec-eval/src/cli.ts +80 -45
- package/spec-eval/src/evaltab.ts +4 -3
- package/spec-eval/src/humanok.ts +1 -1
- package/spec-eval/src/matrix.ts +693 -0
- package/spec-eval/src/scenarios.ts +116 -4
- package/spec-eval/src/sessioneval.ts +726 -66
- package/spec-cli/src/migrate-table.ts +0 -398
- package/spec-cli/src/migrate.ts +0 -386
- package/spec-cli/templates/spec/project/.plugins/distill/spec.md +0 -74
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
- package/spec-cli/templates/spec/project/.plugins/tidy/spec.md +0 -25
- package/spec-dashboard/dist/assets/Dashboard-CTcH2eW9.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
- package/spec-dashboard/dist/assets/FoldToggle-CVFbBpyW.js +0 -1
- package/spec-dashboard/dist/assets/IssuesPage-kULjonqj.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B0ZJju8K.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-BRKJqU2U.js +0 -71
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
- package/spec-dashboard/dist/assets/SessionWindow-CDhEL7wO.js +0 -9
- package/spec-dashboard/dist/assets/Settings-BL6FV_8S.js +0 -1
- package/spec-dashboard/dist/assets/index-DmQsNYKK.css +0 -1
- package/spec-dashboard/dist/assets/index-DulGPk6A.js +0 -41
- /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
package/spec-cli/src/cli.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export {} // make this a module so top-level await is allowed
|
|
2
2
|
// static import is fine here: mentions.ts is dependency-free at module level, and stripRefSigil is needed
|
|
3
|
-
// by several verbs (spec owner,
|
|
3
|
+
// by several verbs (spec owner, graph, issue/eval node args) — a CLI reference arg tolerates an optional @/[[ ]]
|
|
4
4
|
// sigil ([[mentions]]).
|
|
5
5
|
import { stripRefSigil } from './mentions.js'
|
|
6
6
|
|
|
@@ -60,6 +60,19 @@ function positionals(from: number): string[] {
|
|
|
60
60
|
return out
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function rejectUnknownFlags(command: string, from: number, allowed: readonly string[]): void {
|
|
64
|
+
const known = new Set(allowed.map((name) => `--${name}`))
|
|
65
|
+
for (let i = from; i < process.argv.length; i++) {
|
|
66
|
+
const token = process.argv[i]
|
|
67
|
+
if (!token.startsWith('--')) continue
|
|
68
|
+
if (!known.has(token)) {
|
|
69
|
+
console.error(`${command}: unknown flag ${token}`)
|
|
70
|
+
process.exit(2)
|
|
71
|
+
}
|
|
72
|
+
if (VALUE_FLAGS.has(token)) i++
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
// @@@ signposts (one version only — delete in 0.4.0) - every spelling v0.3.0 removed maps to its new home.
|
|
64
77
|
// A signpost REPORTS and exits 2; it never executes (not an alias): a stale hook or a human's muscle memory
|
|
65
78
|
// gets a readable failure that names the migration, and nothing old keeps silently working.
|
|
@@ -73,7 +86,6 @@ const SIGNPOSTS: Record<string, string> = {
|
|
|
73
86
|
blob: 'spex evidence put|get',
|
|
74
87
|
issues: 'spex issue — ls (was: bare issues) · show · open · reply · close · promote; on|off|status → the `issues.enabled` key in spexcode.json; `issues nudge` → spex internal nudge',
|
|
75
88
|
forge: 'spex issue links [--pending] [--store <host>] (--host is now --store)',
|
|
76
|
-
dashboard: 'spex serve ui',
|
|
77
89
|
new: 'spex session new',
|
|
78
90
|
ls: 'spex session ls',
|
|
79
91
|
watch: 'spex session watch',
|
|
@@ -173,7 +185,7 @@ async function withWatchEdge<T>(selectors: string[], intervalMs: number, body: (
|
|
|
173
185
|
}
|
|
174
186
|
|
|
175
187
|
async function resolveSelectorOrExit(selector: string): Promise<string> {
|
|
176
|
-
if (!selector) { console.error('spex: missing session selector (id | id-prefix | node | branch)'); process.exit(2) }
|
|
188
|
+
if (!selector) { console.error('spex: missing session selector (id | id-prefix | node | branch | . for self)'); process.exit(2) }
|
|
177
189
|
const { resolveClientSession } = await import('./client.js')
|
|
178
190
|
const { sessionLabel } = await import('./sessions.js')
|
|
179
191
|
const r = await resolveClientSession(selector)
|
|
@@ -309,6 +321,19 @@ if (cmd === 'serve') {
|
|
|
309
321
|
console.error(`spex serve: unknown target '${target}' — spex serve [api] (the backend) | spex serve ui (the dashboard)`)
|
|
310
322
|
process.exit(2)
|
|
311
323
|
}
|
|
324
|
+
} else if (cmd === 'dashboard') {
|
|
325
|
+
// the HOST-level dashboard ([[host-gateway]]): ONE gateway for every project this user serves. The
|
|
326
|
+
// engine is [[gateway-hub]] (routing + [[gateway-auth]] authorization: admin scope implicit from
|
|
327
|
+
// loopback until an admin password is set; per-project gates as configured); the host layer mounts the
|
|
328
|
+
// instance-validated project registry, its SSE stream, the durable catalog, and the /projects
|
|
329
|
+
// operations (register · init · doctor · start a backend) as the hub's admin extension. No --api-port
|
|
330
|
+
// pairing: which backend a request reaches is named in its /p/:projectId path, resolved per request.
|
|
331
|
+
// `spex serve ui` remains the explicit one-backend pairing; this verb is the zero-config many-project face.
|
|
332
|
+
const { startHostDashboard } = await import('./host.js')
|
|
333
|
+
const port = Number(flag('port') ?? process.env.SPEXCODE_DASHBOARD_PORT ?? 5173)
|
|
334
|
+
const host = flag('host') ?? '127.0.0.1'
|
|
335
|
+
if (!Number.isInteger(port)) { console.error('spex dashboard: --port must be an integer'); process.exit(2) }
|
|
336
|
+
startHostDashboard({ port, host })
|
|
312
337
|
} else if (cmd === undefined || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
313
338
|
// `spex help <cmd>` drills into one command; bare help is the map. Both name the next layer down.
|
|
314
339
|
const { commandHelp, overviewHelp } = await import('./help.js')
|
|
@@ -404,7 +429,11 @@ if (cmd === 'serve') {
|
|
|
404
429
|
const owners = specOwners(p)
|
|
405
430
|
const related = specRelated(p)
|
|
406
431
|
const maxOwners = loadConfig(process.cwd()).maxOwners
|
|
407
|
-
|
|
432
|
+
// a selector-SCOPED governor (every claiming code: entry carries `#symbol` — [[code-anchor]]) still
|
|
433
|
+
// DISPLAYS, marked, but does not count toward the too-many-owners bound: it claims named units, not
|
|
434
|
+
// the whole file.
|
|
435
|
+
const whole = owners.filter((o) => !o.scoped)
|
|
436
|
+
const names = (xs: { id: string; scoped?: boolean }[]) => xs.map((o) => `'${o.id}'${o.scoped ? ' (scoped)' : ''}`).join(', ')
|
|
408
437
|
const relLine = related.length ? `\n also referenced by ${names(related)} (related: coverage only — no drift, no eval freshness)` : ''
|
|
409
438
|
if (owners.length === 0 && related.length === 0) {
|
|
410
439
|
console.log(`${rel} — no spec claims this yet (uncovered). If your change is substantive, give it a home before it drifts.`)
|
|
@@ -413,16 +442,16 @@ if (cmd === 'serve') {
|
|
|
413
442
|
// but a human asking gets the honest nuance: nothing tracks this file's drift.
|
|
414
443
|
if (has('actionable')) process.exit(0)
|
|
415
444
|
console.log(`${rel} — not governed (no code: claim), but referenced by ${names(related)} (related: coverage only). Nothing tracks its drift; if your change is substantive, consider giving it a governing home.`)
|
|
416
|
-
} else if (
|
|
445
|
+
} else if (whole.length <= maxOwners) {
|
|
417
446
|
// a sanely-owned file is NOT actionable: --actionable callers (the per-edit spec-of-file hook) stay
|
|
418
447
|
// silent here, so the annotation fires only on an OVER-owned or uncovered file — rare and worth acting on.
|
|
419
448
|
if (has('actionable')) process.exit(0)
|
|
420
|
-
const named = owners
|
|
449
|
+
const named = names(owners)
|
|
421
450
|
const lead = owners.length === 1 ? `${rel} is governed by ${named} — ${owners[0].desc}` : `${rel} is governed by ${named} (shared, fine).`
|
|
422
451
|
console.log(`${lead} Read/honor the spec; if your change shifts the intent, update the spec in the SAME commit.${relLine}`)
|
|
423
452
|
} else {
|
|
424
|
-
const ids = owners
|
|
425
|
-
console.log(`${rel} is governed by ${
|
|
453
|
+
const ids = names(owners)
|
|
454
|
+
console.log(`${rel} is governed whole-file by ${whole.length} specs (all claims: ${ids}) — more than one file should hold. This file does TOO MUCH: SPLIT it so each governor owns its own module (or merge the nodes if they're one concern, or give it a single foundation owner + relate the rest).${relLine}`)
|
|
426
455
|
}
|
|
427
456
|
} else if (sub === 'lint') {
|
|
428
457
|
const { specLint, DRIFT_GUIDANCE } = await import('./lint.js')
|
|
@@ -463,9 +492,9 @@ if (cmd === 'serve') {
|
|
|
463
492
|
}
|
|
464
493
|
} else if (cmd === 'init') {
|
|
465
494
|
// scaffold a repo to adopt SpexCode: copy the shipped DATA templates (seed spec tree + git hooks)
|
|
466
|
-
// into <targetDir> (default cwd). spex init [targetDir] [--preset <tier>]
|
|
495
|
+
// into <targetDir> (default cwd). spex init [targetDir] --harness <ids> [--preset <tier>]
|
|
467
496
|
const { specInit } = await import('./init.js')
|
|
468
|
-
await specInit(positionals(3)[0], flag('preset'))
|
|
497
|
+
await specInit(positionals(3)[0], flag('preset'), flag('harness'))
|
|
469
498
|
} else if (cmd === 'uninstall') {
|
|
470
499
|
// the surgical inverse of init: remove every SpexCode-generated artifact (harness shims/contract/trust, the
|
|
471
500
|
// .gitignore block, the global store, any plugin bundle) — NEVER the user's .spec/.plugins data or their own
|
|
@@ -493,42 +522,35 @@ if (cmd === 'serve') {
|
|
|
493
522
|
if (!r.ok) { console.error(`no evals for ${id} (status ${r.status})`); process.exit(1) }
|
|
494
523
|
if (has('json')) { console.log(JSON.stringify(r.model, null, 2)); await flushExit(0) }
|
|
495
524
|
const m = r.model
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
return { n, blind, rows }
|
|
506
|
-
})
|
|
507
|
-
const own = groups.reduce((a, g) => a + g.rows.filter((e) => e.inSession).length, 0)
|
|
508
|
-
console.log(`eval ${m.title} [${m.id}]`)
|
|
509
|
-
console.log(` branch : ${m.branch ?? '—'} · ${m.ahead} commit(s) ahead · ${m.dirtyNonRuntime} uncommitted`)
|
|
525
|
+
const byNode = new Map<string, any[]>()
|
|
526
|
+
for (const item of m.items) {
|
|
527
|
+
const rows = byNode.get(item.node) ?? []
|
|
528
|
+
rows.push(item)
|
|
529
|
+
byNode.set(item.node, rows)
|
|
530
|
+
}
|
|
531
|
+
const groups = [...byNode].map(([node, rows]) => ({ node, rows }))
|
|
532
|
+
const own = m.items.filter((item) => item.inSession).length
|
|
533
|
+
console.log(`eval session [${m.id}]`)
|
|
510
534
|
console.log(` gates : ${m.gates.map((g) => `${g.ok ? '✓' : '✗'} ${g.label} — ${g.detail}`).join(' · ')}`)
|
|
511
535
|
if (own) console.log(` ✦ : ${own} scenario(s) measured by THIS session (unmarked rows = inherited baseline)`)
|
|
512
|
-
if (!m.
|
|
513
|
-
for (const {
|
|
514
|
-
console.log(`\n${
|
|
515
|
-
for (const
|
|
536
|
+
if (!m.items.length) console.log('\n no affected scenarios to evaluate yet')
|
|
537
|
+
for (const { node, rows } of groups) {
|
|
538
|
+
console.log(`\n${node}`)
|
|
539
|
+
for (const item of rows.filter((row) => row.filterKind === 'blind')) console.log(` ∅ unmeasured ${item.scenario} — declared, never measured (blind spot)`)
|
|
516
540
|
let divided = false
|
|
517
|
-
for (const e of rows) {
|
|
518
|
-
if (!e.inSession && !divided && rows.some((x) => x.inSession)) { console.log(` ── inherited baseline (other sessions' latest evals) ──`); divided = true }
|
|
541
|
+
for (const e of rows.filter((row) => row.filterKind === 'result')) {
|
|
542
|
+
if (!e.inSession && !divided && rows.some((x) => x.filterKind === 'result' && x.inSession)) { console.log(` ── inherited baseline (other sessions' latest evals) ──`); divided = true }
|
|
519
543
|
const verdict = e.verdict?.status === 'pass' ? '✓ pass' : e.verdict?.status === 'fail' ? '✗ fail' : '· unscored'
|
|
520
|
-
const stale = e.fresh ? '' : ` (stale: ${e.staleAxes.join(',')})`
|
|
544
|
+
const stale = e.fresh ? '' : ` (stale: ${(e.staleAxes || []).join(',')})`
|
|
521
545
|
console.log(` ${e.inSession ? '✦' : ' '} ${verdict}${stale} ${e.scenario} — ${e.ts}${e.evaluator ? ` · ${e.evaluator}` : ''}`)
|
|
522
546
|
}
|
|
523
|
-
if (!n.hasEvalFile && !n.uncoveredFrontend) console.log(' (no eval.md — nothing declared to measure)')
|
|
524
|
-
else if (n.hasEvalFile && !n.scenarios.length) console.log(' (eval.md declares no scenarios)')
|
|
525
547
|
}
|
|
526
|
-
} else if (['add', 'ls', 'scenario', 'lint', 'ok', 'retract', 'clean'].includes(sub)) {
|
|
548
|
+
} else if (['add', 'ls', 'scenario', 'matrix', 'lint', 'ok', 'retract', 'clean'].includes(sub)) {
|
|
527
549
|
// node-scoped verbs — thin route; the logic lives in spec-eval.
|
|
528
550
|
const { runEval } = await import('../../spec-eval/src/cli.js')
|
|
529
551
|
await flushExit(await runEval(process.argv.slice(3)))
|
|
530
552
|
} else {
|
|
531
|
-
console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls | lint | ok | retract | clean (spex help eval)`)
|
|
553
|
+
console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls | matrix | lint | ok | retract | clean (spex help eval)`)
|
|
532
554
|
if (!sub.startsWith('--')) console.error(` (the old \`spex eval <SEL>\` session read is now \`spex eval ls --session <SEL>\` [--export])`) // dead-words-ok: signpost — one-version tombstone teaching the renamed spelling (0.4.0 removes it)
|
|
533
555
|
process.exit(2)
|
|
534
556
|
}
|
|
@@ -579,7 +601,14 @@ if (cmd === 'serve') {
|
|
|
579
601
|
// trust, for cwd's project. Anchored on git-native events only ([[commit-surgery]]): this verb, init,
|
|
580
602
|
// session-worktree creation, and the planted pre-commit/post-checkout/post-merge hooks.
|
|
581
603
|
const { materialize } = await import('./materialize.js')
|
|
582
|
-
|
|
604
|
+
try {
|
|
605
|
+
console.log(`materialized — content-hash ${materialize().contentHash}`)
|
|
606
|
+
} catch (e) {
|
|
607
|
+
// a policy error (e.g. a missing/illegal `harnesses` set) is a user-facing verdict, not a crash — one
|
|
608
|
+
// line + the repair it already carries, never a stack trace.
|
|
609
|
+
console.error(`spex materialize: ${(e as Error).message}`)
|
|
610
|
+
process.exit(1)
|
|
611
|
+
}
|
|
583
612
|
} else if (cmd === 'doctor') {
|
|
584
613
|
// @@@ doctor - the diagnosis surface ([[doctor]], né `self` — renamed: "self" read as the tool itself /
|
|
585
614
|
// the global install, while the report is about THIS agent's wiring): does the materialized workflow
|
|
@@ -595,12 +624,16 @@ if (cmd === 'serve') {
|
|
|
595
624
|
} else if (SESSION_SIGNPOSTS[sub]) {
|
|
596
625
|
signpost(`spex session ${sub}`, SESSION_SIGNPOSTS[sub])
|
|
597
626
|
} else if (sub === 'new') {
|
|
598
|
-
// spex session new "<prompt>"
|
|
627
|
+
// spex session new "<prompt>" (prompt = first positional or --prompt, or --prompt-file
|
|
599
628
|
// <path>|- so a long multi-paragraph prompt never fights shell quoting — [[prompt-file]]).
|
|
600
629
|
// createSession POSTs to the running backend so the launch runs in the backend's process (auth env + cap);
|
|
601
630
|
// it falls back to an in-process launch only when no backend answers.
|
|
631
|
+
if (has('node')) {
|
|
632
|
+
console.error('spex session new: --node was removed — put a [[<id>]] mention in the prompt — the first mention binds')
|
|
633
|
+
process.exit(2)
|
|
634
|
+
}
|
|
635
|
+
rejectUnknownFlags('spex session new', 4, ['prompt', 'prompt-file', 'launcher', 'api', 'port'])
|
|
602
636
|
const { createSession } = await import('./sessions.js')
|
|
603
|
-
if (has('harness')) { console.error('spex session new: --harness was removed; use --launcher <name> (for example --launcher codex)'); process.exit(2) }
|
|
604
637
|
const promptFile = flag('prompt-file')
|
|
605
638
|
const inline = flag('prompt') ?? positionals(4)[0]
|
|
606
639
|
let prompt = inline ?? ''
|
|
@@ -612,8 +645,7 @@ if (cmd === 'serve') {
|
|
|
612
645
|
catch (e) { console.error(`spex session new: --prompt-file ${promptFile}: ${e instanceof Error ? e.message : e}`); process.exit(2) }
|
|
613
646
|
if (!prompt.trim()) { console.error(`spex session new: --prompt-file ${promptFile === '-' ? 'stdin' : promptFile} is empty — refusing a promptless launch`); process.exit(2) }
|
|
614
647
|
}
|
|
615
|
-
const
|
|
616
|
-
const created = await createSession(nodeArg ? stripRefSigil(nodeArg) : null, prompt, flag('launcher') ?? undefined)
|
|
648
|
+
const created = await createSession(prompt, flag('launcher') ?? undefined)
|
|
617
649
|
console.log(JSON.stringify(created, null, 2))
|
|
618
650
|
await launchMonitorReminder(created.id)
|
|
619
651
|
} else if (sub === 'ls') {
|
|
@@ -724,7 +756,7 @@ if (cmd === 'serve') {
|
|
|
724
756
|
// overrides for a genuinely wedged process. A following prompt is what actually re-drives it.
|
|
725
757
|
const full = await resolveSelectorOrExit(id)
|
|
726
758
|
const r = await c.clientResume(full, process.argv.includes('--force'))
|
|
727
|
-
if (r.ok) console.log(`${full} -> resumed`)
|
|
759
|
+
if (r.ok) console.log(r.info ? `${full} -> ${r.info}` : `${full} -> resumed`)
|
|
728
760
|
else { console.error(`spex session resume: ${r.error || `no such session ${full}`}`); process.exit(2) }
|
|
729
761
|
} else if (sub === 'done') {
|
|
730
762
|
// sugar for awaiting; --propose merge|nothing|close, optional --note
|
|
@@ -851,6 +883,18 @@ if (cmd === 'serve') {
|
|
|
851
883
|
// line; GET /api/settings exposes the same resolution (`.layout`).
|
|
852
884
|
const { mainBranch } = await import('./layout.js')
|
|
853
885
|
console.log(mainBranch())
|
|
886
|
+
} else if (sub === 'spec-governors') {
|
|
887
|
+
// Stable machine projection for spec-aware hooks: one real code: governor per row, with the live spec
|
|
888
|
+
// path the block reason can point at. Empty stdout means ungoverned (including related-only).
|
|
889
|
+
const file = process.argv[4]
|
|
890
|
+
if (!file) { console.error('usage: spex internal spec-governors <path>'); process.exit(2) }
|
|
891
|
+
const { specOwners, loadSpecsLite } = await import('./specs.js')
|
|
892
|
+
const paths = new Map(loadSpecsLite().map((node) => [node.id, node.path]))
|
|
893
|
+
for (const owner of specOwners(file)) {
|
|
894
|
+
const path = paths.get(owner.id)
|
|
895
|
+
if (!path) throw new Error(`governor '${owner.id}' has no live spec path`)
|
|
896
|
+
console.log(`${owner.id}\t${path}`)
|
|
897
|
+
}
|
|
854
898
|
} else if (sub === 'codex-launch') {
|
|
855
899
|
// BACKEND-owned codex thread. On the shared per-project app-server: thread/start { cwd = this worktree }
|
|
856
900
|
// (codex loads that worktree's config/hooks/AGENTS.md), store the new id on the governed record (keyed by
|
|
@@ -864,7 +908,7 @@ if (cmd === 'serve') {
|
|
|
864
908
|
// On the bypass-trust path (the codex install supports the flag → materialize skipped writeCodexTrust's hash),
|
|
865
909
|
// the thread the BACKEND owns must carry `bypass_hook_trust` in thread/start's config so the app-server fires
|
|
866
910
|
// the worktree's local hooks — mirror materialize's capability decision so the two stay in lockstep.
|
|
867
|
-
const bypassHookTrust = codexSupportsBypassHookTrust(codexBinary(process.env.SPEXCODE_CODEX_CMD || 'codex
|
|
911
|
+
const bypassHookTrust = codexSupportsBypassHookTrust(codexBinary(process.env.SPEXCODE_CODEX_CMD || 'codex'))
|
|
868
912
|
const r = await codexStartThread(sock, cwd, bypassHookTrust)
|
|
869
913
|
if (!r.ok) { console.error(r.error); process.exit(1) }
|
|
870
914
|
if (prompt) {
|
|
@@ -883,6 +927,17 @@ if (cmd === 'serve') {
|
|
|
883
927
|
const sid = process.env.SPEXCODE_SESSION_ID
|
|
884
928
|
if (sid) markHarnessSessionId(sid, r.threadId)
|
|
885
929
|
console.log(r.threadId)
|
|
930
|
+
} else if (sub === 'opencode-capture') {
|
|
931
|
+
// opencode MINTS its own session id (no launch flag pins it), so the generated plugin's FIRST event calls
|
|
932
|
+
// this to store that id as harness_session_id on the governed record (SPEXCODE_SESSION_ID from the launch
|
|
933
|
+
// env, inherited by the opencode process → plugin). That is what lets reopen() resume the SAME
|
|
934
|
+
// conversation (`--session <id>`). A missing record/env is a clean no-op — a plugin loaded outside a
|
|
935
|
+
// governed launch has nothing to mark.
|
|
936
|
+
const { markHarnessSessionId } = await import('./sessions.js')
|
|
937
|
+
const ocid = process.argv[4]
|
|
938
|
+
if (!ocid) { console.error('usage: spex internal opencode-capture <opencode-session-id>'); process.exit(2) }
|
|
939
|
+
const sid = process.env.SPEXCODE_SESSION_ID
|
|
940
|
+
console.log(sid && markHarnessSessionId(sid, ocid) ? `captured ${ocid}` : 'noop (no governed session record)')
|
|
886
941
|
} else if (sub === 'commit-surgery') {
|
|
887
942
|
// the pre-commit footprint anchor ([[commit-surgery]]): unconditional materialize + staged-index repair
|
|
888
943
|
// (strip our sentinel block from staged blobs, unstage HEAD-untracked generated artifacts). Called only
|
package/spec-cli/src/client.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { apiBase, assertProjectMatch, resolveSession, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
|
|
2
|
-
import type { SessionEvals } from '../../spec-eval/src/sessioneval.js'
|
|
3
2
|
|
|
4
3
|
export class BackendError extends Error {
|
|
5
4
|
constructor(message: string, readonly status?: number) {
|
|
@@ -74,15 +73,38 @@ export async function clientEvalExport(id: string): Promise<ExportResult> {
|
|
|
74
73
|
return { ok: false, status: r.status }
|
|
75
74
|
}
|
|
76
75
|
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
76
|
+
// The CLI's explicit aggregate walks the same 25-row pages as the dashboard. No server response contains
|
|
77
|
+
// the full session model; aggregation exists only for this one-shot terminal rendering.
|
|
78
|
+
type SessionEvalPage = {
|
|
79
|
+
items: any[]
|
|
80
|
+
page: number
|
|
81
|
+
pageCount: number
|
|
82
|
+
total: number
|
|
83
|
+
gates: any[]
|
|
84
|
+
unknown: number
|
|
85
|
+
revision: string
|
|
86
|
+
summary?: any
|
|
87
|
+
evalRevision?: any
|
|
88
|
+
}
|
|
89
|
+
export type EvalsResult = { ok: true; model: SessionEvalPage & { id: string } } | { ok: false; status: number }
|
|
82
90
|
export async function clientEvals(id: string): Promise<EvalsResult> {
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
91
|
+
const q = encodeURIComponent(`is:eval scope:${id}`)
|
|
92
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
93
|
+
const items: any[] = []
|
|
94
|
+
let first: SessionEvalPage | null = null
|
|
95
|
+
let changed = false
|
|
96
|
+
for (let page = 1;; page++) {
|
|
97
|
+
const r = await apiFetch(`/api/evals?q=${q}&page=${page}`)
|
|
98
|
+
if (!r.ok) return { ok: false, status: r.status }
|
|
99
|
+
const current = await r.json() as SessionEvalPage
|
|
100
|
+
first ??= current
|
|
101
|
+
if (current.revision !== first.revision) { changed = true; break }
|
|
102
|
+
items.push(...current.items)
|
|
103
|
+
if (page >= current.pageCount) break
|
|
104
|
+
}
|
|
105
|
+
if (!changed) return { ok: true, model: { ...first!, id, items } }
|
|
106
|
+
}
|
|
107
|
+
throw new BackendError(`session eval pages changed while reading ${id}; retry the command`)
|
|
86
108
|
}
|
|
87
109
|
|
|
88
110
|
// POST /api/sessions/:id/merge — the cockpit's merge DISPATCH (200 {dispatched:true} / 409 {reason}).
|
|
@@ -94,8 +116,9 @@ export async function clientMerge(id: string): Promise<{ dispatched: boolean; re
|
|
|
94
116
|
|
|
95
117
|
// POST /api/sessions/:id/resume — bring the agent back (relaunch ONLY if confirmed offline); demotes
|
|
96
118
|
// working→idle, keeps any declaration. The RESUME GUARD REFUSES (409 {refused:true}) on a live/unproven agent;
|
|
97
|
-
// `force` overrides for a wedged-but-alive process. {ok:false} otherwise = no such session (404).
|
|
98
|
-
|
|
119
|
+
// `force` overrides for a wedged-but-alive process. {ok:false} otherwise = no such session (404). `info`
|
|
120
|
+
// carries a non-error advisory.
|
|
121
|
+
export async function clientResume(id: string, force = false): Promise<{ ok: boolean; error?: string; refused?: boolean; info?: string }> {
|
|
99
122
|
await guarded('session resume')
|
|
100
123
|
const r = await apiFetch(`/api/sessions/${seg(id)}/resume`, post({ force }))
|
|
101
124
|
return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` }))
|
package/spec-cli/src/doctor.ts
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
|
-
// @@@ spex doctor - the DIAGNOSIS surface ([[doctor]]
|
|
2
|
-
//
|
|
3
|
-
// in the launch, the workflow reaches
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// LAYER, looping the same HARNESSES adapter materialize delivers through (so claude AND codex are covered
|
|
7
|
-
// with no hardcoded paths). It catches the SILENT failure: a shim whose handler is missing, a PATH that
|
|
8
|
-
// can't resolve `spex`, a contract that never landed. Read-only today: the bare report, `contract` (print
|
|
9
|
-
// the surface:system text any agent reads), `conflicts`. install/uninstall are STAGED (noteStaged).
|
|
1
|
+
// @@@ spex doctor - the opt-in, read-only project DIAGNOSIS surface ([[doctor]]). Bare doctor composes
|
|
2
|
+
// spec-health findings with the delivery audit: when a user launches their OWN agent with no SpexCode
|
|
3
|
+
// process in the launch, the workflow reaches it only through the files materialize() writes. The audit
|
|
4
|
+
// loops the same HARNESSES adapter materialize uses and catches missing delivery or duplicate discovery.
|
|
5
|
+
// `--contract` and `--conflicts` remain focused representations of that same diagnosis.
|
|
10
6
|
import { existsSync, readFileSync, readdirSync, accessSync, constants } from 'node:fs'
|
|
11
7
|
import { join, dirname, basename } from 'node:path'
|
|
12
8
|
import { fileURLToPath } from 'node:url'
|
|
13
9
|
import { execFileSync } from 'node:child_process'
|
|
14
10
|
import { homedir } from 'node:os'
|
|
15
|
-
import { loadSystemConfig, loadSkillConfig } from './specs.js'
|
|
16
|
-
import { runtimeRoot, treeSlotDir, envSessionId, readAliasedRawRecord, mainCheckout } from './layout.js'
|
|
11
|
+
import { loadSystemConfig, loadSkillConfig, loadSpecs } from './specs.js'
|
|
12
|
+
import { runtimeRoot, treeSlotDir, envSessionId, readAliasedRawRecord, mainCheckout, readJsonConfig } from './layout.js'
|
|
13
|
+
import { loadConfig } from './lint.js'
|
|
14
|
+
import { trackedSourceFiles } from './source-files.js'
|
|
17
15
|
|
|
18
16
|
// this file lives at <pkgRoot>/src/self.ts, so `..` is the package root — the same derivation init.ts/
|
|
19
17
|
// materialize.ts use (never a hardcoded repo path), so the git-hook template lookup survives a relocated install.
|
|
@@ -31,6 +29,156 @@ function commonHooksDir(dir: string): string | null {
|
|
|
31
29
|
}
|
|
32
30
|
const read = (f: string): string => { try { return readFileSync(f, 'utf8') } catch { return '' } }
|
|
33
31
|
|
|
32
|
+
type AltitudeConfig = {
|
|
33
|
+
lineBudget: number
|
|
34
|
+
charBudget: number
|
|
35
|
+
sizeable: number
|
|
36
|
+
dense: number
|
|
37
|
+
steps: number
|
|
38
|
+
identifierExtensions: string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type BreadthConfig = {
|
|
42
|
+
maxChildren: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type HealthFinding = {
|
|
46
|
+
check: string
|
|
47
|
+
spec: string
|
|
48
|
+
summary: string
|
|
49
|
+
evidence: string[]
|
|
50
|
+
repair: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DEFAULT_ALTITUDE: AltitudeConfig = {
|
|
54
|
+
lineBudget: 50,
|
|
55
|
+
charBudget: 4200,
|
|
56
|
+
sizeable: 35,
|
|
57
|
+
dense: 1.3,
|
|
58
|
+
steps: 3,
|
|
59
|
+
identifierExtensions: [],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const DEFAULT_BREADTH: BreadthConfig = {
|
|
63
|
+
maxChildren: 8,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function loadAltitudeConfig(root: string): AltitudeConfig {
|
|
67
|
+
const configured = readJsonConfig(join(root, 'spexcode.json'))?.doctor?.altitude ?? {}
|
|
68
|
+
const merged = { ...DEFAULT_ALTITUDE, ...configured }
|
|
69
|
+
return {
|
|
70
|
+
...merged,
|
|
71
|
+
identifierExtensions: (merged.identifierExtensions ?? []).map((ext: string) => ext.replace(/^\.+/, '')),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function loadBreadthConfig(root: string): BreadthConfig {
|
|
76
|
+
const configured = readJsonConfig(join(root, 'spexcode.json'))?.doctor?.breadth ?? {}
|
|
77
|
+
return { ...DEFAULT_BREADTH, ...configured }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Filename rows are lint coverage's exact tracked candidates. Compatibility extensions lower to wildcard
|
|
81
|
+
// rows before the one identifier matcher is compiled.
|
|
82
|
+
function identifierFilenameCandidates(sourceFiles: string[], compatibilityExtensions: string[]): string[] {
|
|
83
|
+
return [...new Set([
|
|
84
|
+
...sourceFiles.map((path) => basename(path)),
|
|
85
|
+
...compatibilityExtensions.map((ext) => `*.${ext}`),
|
|
86
|
+
])]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function identRe(filenameCandidates: string[]): RegExp {
|
|
90
|
+
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
91
|
+
const filenames = filenameCandidates
|
|
92
|
+
.map((candidate) => candidate.startsWith('*.')
|
|
93
|
+
? `[\\w-]+\\.${escape(candidate.slice(2))}`
|
|
94
|
+
: escape(candidate))
|
|
95
|
+
.sort((a, b) => b.length - a.length)
|
|
96
|
+
const signals = [
|
|
97
|
+
'[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*',
|
|
98
|
+
'\\b[a-z]+_[a-z0-9_]+\\b',
|
|
99
|
+
'\\b\\w+\\(',
|
|
100
|
+
'`[^`]+`',
|
|
101
|
+
'\\/[\\w./-]+\\.\\w+',
|
|
102
|
+
]
|
|
103
|
+
if (filenames.length) signals.push(`(?<![\\w./-])(?:${filenames.join('|')})(?![\\w.-])`)
|
|
104
|
+
return new RegExp(signals.join('|'), 'g')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const STEP_LINE = /^\s*(\d+[.)]\s|[-*]\s*(first|then|next|finally)\b)|(^|[,;]\s*)(first|then|next|finally),/i
|
|
108
|
+
|
|
109
|
+
function altitudeEvidence(body: string, cfg: AltitudeConfig, ident: RegExp): string[] {
|
|
110
|
+
const lines = body.split('\n')
|
|
111
|
+
const nonBlank = lines.filter((line) => line.trim()).length
|
|
112
|
+
let inFence = false
|
|
113
|
+
let signals = 0
|
|
114
|
+
let steps = 0
|
|
115
|
+
for (const line of lines) {
|
|
116
|
+
if (/^\s*```/.test(line)) { inFence = !inFence; continue }
|
|
117
|
+
if (inFence || !line.trim()) continue
|
|
118
|
+
signals += line.match(ident)?.length ?? 0
|
|
119
|
+
if (STEP_LINE.test(line)) steps++
|
|
120
|
+
}
|
|
121
|
+
const density = signals / Math.max(1, nonBlank)
|
|
122
|
+
const evidence: string[] = []
|
|
123
|
+
if (nonBlank > cfg.lineBudget || body.length > cfg.charBudget)
|
|
124
|
+
evidence.push(`${nonBlank} non-blank lines / ${body.length} chars over budget (${cfg.lineBudget}/${cfg.charBudget})`)
|
|
125
|
+
if (nonBlank > cfg.sizeable && density > cfg.dense)
|
|
126
|
+
evidence.push(`code-identifier density ${density.toFixed(2)}/line over ${cfg.dense}`)
|
|
127
|
+
if (nonBlank > cfg.sizeable && steps >= cfg.steps)
|
|
128
|
+
evidence.push(`${steps} step-by-step how-to lines`)
|
|
129
|
+
return evidence
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function specHealthDiagnosis(root: string): Promise<HealthFinding[]> {
|
|
133
|
+
const lint = loadConfig(root)
|
|
134
|
+
const altitude = loadAltitudeConfig(root)
|
|
135
|
+
const breadth = loadBreadthConfig(root)
|
|
136
|
+
const governed = trackedSourceFiles(root, lint.governedRoots, lint)
|
|
137
|
+
const ident = identRe(identifierFilenameCandidates(governed, altitude.identifierExtensions))
|
|
138
|
+
const specs = await loadSpecs(root)
|
|
139
|
+
const childCount = new Map<string, number>()
|
|
140
|
+
for (const spec of specs) if (spec.parent) childCount.set(spec.parent, (childCount.get(spec.parent) ?? 0) + 1)
|
|
141
|
+
const findings: HealthFinding[] = []
|
|
142
|
+
for (const spec of specs) {
|
|
143
|
+
const evidence = altitudeEvidence(spec.body, altitude, ident)
|
|
144
|
+
if (evidence.length) findings.push({
|
|
145
|
+
check: 'altitude',
|
|
146
|
+
spec: spec.id,
|
|
147
|
+
summary: 'body reads like mechanics rather than a contract',
|
|
148
|
+
evidence,
|
|
149
|
+
repair: `rewrite '${spec.id}' around observable intent and invariants; the tidy workflow can perform the semantic review`,
|
|
150
|
+
})
|
|
151
|
+
const children = childCount.get(spec.id) ?? 0
|
|
152
|
+
if (children >= breadth.maxChildren) findings.push({
|
|
153
|
+
check: 'breadth',
|
|
154
|
+
spec: spec.id,
|
|
155
|
+
summary: 'tree fan-out may be missing a natural grouping layer',
|
|
156
|
+
evidence: [`${children} direct child nodes (>= ${breadth.maxChildren})`],
|
|
157
|
+
repair: `review '${spec.id}' with the regroup workflow; group only along a real seam and leave genuine peers flat`,
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
return findings
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function healthReport(findings: HealthFinding[], adopted: boolean): string[] {
|
|
164
|
+
const lines = ['Spec health diagnosis (opt-in advisory; never part of spex spec lint)']
|
|
165
|
+
if (!adopted) {
|
|
166
|
+
lines.push(' status : unavailable — adopt the repository with `spex init`')
|
|
167
|
+
return lines
|
|
168
|
+
}
|
|
169
|
+
const checks = ['altitude', 'breadth', ...new Set(findings.map((finding) => finding.check).filter((check) => check !== 'altitude' && check !== 'breadth'))]
|
|
170
|
+
for (const check of checks) {
|
|
171
|
+
const rows = findings.filter((finding) => finding.check === check)
|
|
172
|
+
lines.push(` ${check.padEnd(16)}: ${rows.length ? `${rows.length} finding(s)` : 'healthy'}`)
|
|
173
|
+
for (const finding of rows) {
|
|
174
|
+
lines.push(` ${finding.spec.padEnd(16)}: ${finding.summary}`)
|
|
175
|
+
for (const evidence of finding.evidence) lines.push(` evidence : ${evidence}`)
|
|
176
|
+
lines.push(` repair : ${finding.repair}`)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return lines
|
|
180
|
+
}
|
|
181
|
+
|
|
34
182
|
// @@@ contractText - the layer-2 payload: the composed `surface:system` bodies, the SAME join materialize()
|
|
35
183
|
// folds into each harness's contract file — so a BYOA agent reads byte-identical guidance.
|
|
36
184
|
function contractText(): { names: string[]; body: string } {
|
|
@@ -170,9 +318,9 @@ async function doubleDeliveryReport(base: string): Promise<{ lines: string[]; co
|
|
|
170
318
|
if (conflict) {
|
|
171
319
|
L.push('Repair — SpexCode is reaching this agent through MORE THAN ONE discovery channel. Keep exactly one:')
|
|
172
320
|
L.push(' • remove the independently-installed plugin bundle (delete its dir, or `claude plugin uninstall spexcode`); or')
|
|
173
|
-
L.push(' • if you WANT the plugin, stop the native delivery: set spexcode.json
|
|
174
|
-
L.push('
|
|
175
|
-
L.push(' • remove
|
|
321
|
+
L.push(' • if you WANT the plugin, stop the native delivery: set spexcode.json to')
|
|
322
|
+
L.push(' `"harnesses": [{"plugin":".claude"}]`, then run `spex materialize` to prune the loose shim/contract/skills; or')
|
|
323
|
+
L.push(' • remove SpexCode\'s generated delivery with `spex uninstall`.')
|
|
176
324
|
} else {
|
|
177
325
|
L.push('No double-delivery: each harness is reached by at most one spexcode-stamped channel.')
|
|
178
326
|
}
|
|
@@ -233,8 +381,14 @@ async function doctor(): Promise<number> {
|
|
|
233
381
|
})
|
|
234
382
|
line('issues workflow', `${issuesEnabled() ? 'ON' : 'OFF'} (spexcode.json issues.enabled)`)
|
|
235
383
|
if (legacy.length) line(' LEGACY key', `\`proposals\` found in ${legacy.map((f) => join(cfgHome, f)).join(', ')} — no longer read; rename it to "issues": { "enabled": … }`)
|
|
384
|
+
const projectConfig = readJsonConfig(join(base, 'spexcode.json'))
|
|
385
|
+
if (Object.prototype.hasOwnProperty.call(projectConfig.lint ?? {}, 'maxChildren'))
|
|
386
|
+
line('RETIRED key', '`lint.maxChildren` is no longer read — move the value to `doctor.breadth.maxChildren`')
|
|
236
387
|
}
|
|
237
388
|
|
|
389
|
+
const health = adopted ? await specHealthDiagnosis(base) : []
|
|
390
|
+
L.push('\n' + healthReport(health, adopted).join('\n'))
|
|
391
|
+
|
|
238
392
|
// --- preconditions: nothing downstream fires without these ---
|
|
239
393
|
L.push('\nPreconditions (without these nothing downstream fires)')
|
|
240
394
|
for (const bin of ['spex', 'claude', 'codex']) {
|
|
@@ -347,37 +501,23 @@ async function conflicts(): Promise<number> {
|
|
|
347
501
|
return conflict ? 1 : 0
|
|
348
502
|
}
|
|
349
503
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
// detect a missing managed session and degrade. So the diagnosis ships first; the installer lands behind it.
|
|
353
|
-
function noteStaged(verb: string): number {
|
|
354
|
-
console.error(`spex doctor ${verb} is not available yet — it is staged behind the hook-degradation prerequisite
|
|
355
|
-
(the live hooks must detect a missing managed session and degrade before they can be safely wired into your
|
|
356
|
-
own agent's config). Meanwhile: \`spex doctor\` reports your coverage, and \`spex doctor --contract\` prints
|
|
357
|
-
the workflow text you can hand any agent.`)
|
|
504
|
+
function migrationRemoved(): number {
|
|
505
|
+
console.error('spex: `spex doctor --migrate` was removed in v0.4.0 — migrate this tree with a 0.3.x SpexCode release, then reinstall the current release. Nothing was changed.')
|
|
358
506
|
return 2
|
|
359
507
|
}
|
|
360
508
|
|
|
361
509
|
function usage(): number {
|
|
362
|
-
console.error(`spex doctor — diagnose how the SpexCode workflow reaches your agent
|
|
363
|
-
(bare)
|
|
510
|
+
console.error(`spex doctor — diagnose spec health and how the SpexCode workflow reaches your agent
|
|
511
|
+
(bare) spec-health findings + delivery report: preconditions · git-hook floor · contract · hooks(+handlers) · backend · footprint
|
|
364
512
|
--contract print the surface:system contract text (hand it to any agent)
|
|
365
|
-
--conflicts detect double-delivery — the same agent reached via loose native delivery AND a plugin bundle (exits non-zero on conflict)
|
|
366
|
-
--migrate one-shot 0.2.x → 0.3.0 vocabulary migration for an adopter repo (renames + hook-asset
|
|
367
|
-
upgrade + body rewrites; refuses on a dirty tree / undrained sessions / an already-migrated
|
|
368
|
-
tree; stages everything, commits nothing; removed in 0.4.0)
|
|
369
|
-
install [staged] wire the materialized contract + hooks into your agent (--agent claude, --minimal)
|
|
370
|
-
uninstall [staged] reverse exactly what install wrote`)
|
|
513
|
+
--conflicts detect double-delivery — the same agent reached via loose native delivery AND a plugin bundle (exits non-zero on conflict)`)
|
|
371
514
|
return 0
|
|
372
515
|
}
|
|
373
516
|
|
|
374
517
|
export async function runDoctor(args: string[]): Promise<number> {
|
|
375
518
|
// contract/conflicts are FLAGS, not subcommands ([[cli-surface]] §4: another representation of the same
|
|
376
519
|
// diagnosis read, not a distinct action). The old positional spellings signpost — report, never run.
|
|
377
|
-
|
|
378
|
-
// ships with 0.3.0, deleted in 0.4.0) — dispatched first so its refusal/summary is never diluted
|
|
379
|
-
// by the diagnosis report.
|
|
380
|
-
if (args.includes('--migrate')) return await (await import('./migrate.js')).runMigrate()
|
|
520
|
+
if (args.includes('--migrate')) return migrationRemoved()
|
|
381
521
|
if (args.includes('--contract')) return contract()
|
|
382
522
|
if (args.includes('--conflicts')) return await conflicts()
|
|
383
523
|
switch (args[0]) {
|
|
@@ -385,8 +525,6 @@ export async function runDoctor(args: string[]): Promise<number> {
|
|
|
385
525
|
case 'contract': case 'conflicts':
|
|
386
526
|
console.error(`spex: \`spex doctor ${args[0]}\` was removed in v0.3.0 — use: spex doctor --${args[0]}`)
|
|
387
527
|
return 2
|
|
388
|
-
case 'install': return noteStaged('install')
|
|
389
|
-
case 'uninstall': return noteStaged('uninstall')
|
|
390
528
|
case 'help': case '--help': case '-h': return usage()
|
|
391
529
|
default: console.error(`spex doctor: unknown subcommand "${args[0]}"`); usage(); return 2
|
|
392
530
|
}
|