spexcode 0.4.1 → 0.5.0
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 +110 -18
- package/spec-cli/src/claude-headless.ts +271 -0
- package/spec-cli/src/cli.ts +112 -38
- package/spec-cli/src/client.ts +42 -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 +27 -19
- package/spec-cli/src/git.ts +12 -5
- package/spec-cli/src/graph.ts +54 -43
- package/spec-cli/src/graphStream.ts +265 -34
- package/spec-cli/src/guide.ts +115 -56
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +295 -39
- package/spec-cli/src/help.ts +68 -28
- 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 +158 -87
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +6 -5
- package/spec-cli/src/lint.ts +25 -121
- 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/message-stream.ts +147 -0
- package/spec-cli/src/opencode-headless.ts +95 -0
- package/spec-cli/src/opencode.ts +145 -0
- package/spec-cli/src/pi-harness.ts +124 -0
- package/spec-cli/src/pi-headless.ts +195 -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 +14 -0
- package/spec-cli/src/sessions.ts +211 -75
- 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 +12 -7
- 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 -18
- package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
- package/spec-cli/templates/spec/project/.plugins/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 +22 -8
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
- 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 +38 -50
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
- 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 +10 -2
- package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +2 -0
- package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +1 -0
- package/spec-dashboard/dist/assets/PageScroll-B_dKCuXx.js +1 -0
- package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +13 -0
- package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +1 -0
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
- package/spec-dashboard/dist/assets/index-BKaTHjmU.js +41 -0
- package/spec-dashboard/dist/assets/index-DcnCaBAC.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 +49 -28
- package/spec-eval/src/humanok.ts +1 -1
- package/spec-eval/src/matrix.ts +693 -0
- package/spec-eval/src/sessioneval.ts +726 -66
- package/spec-cli/src/migrate-table.ts +0 -408
- package/spec-cli/src/migrate.ts +0 -386
- package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
- package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
- package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
- package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
- package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
- package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
- package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
- package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +0 -6
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
|
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// @@@ gateway auth - the ONE authorization mechanism of the multi-project gateway ([[gateway-hub]]).
|
|
2
|
+
// Everything about passwords and visitors lives HERE, at the gateway: the project backends stay loopback
|
|
3
|
+
// internal services that never see a credential, a cookie, or a visitor identity. Two signed scopes only:
|
|
4
|
+
// an ADMIN session grants /projects (management) plus every /p/:projectId route; a PROJECT session grants
|
|
5
|
+
// exactly its own /p/:projectId route. A project with no configured password is open; with no admin
|
|
6
|
+
// password, loopback may manage implicitly while non-loopback /projects stays locked.
|
|
7
|
+
//
|
|
8
|
+
// Secrets discipline: password VERIFIERS (scrypt, salted) live only in the per-user private store
|
|
9
|
+
// (~/.spexcode/gateway/auth.json, 0600) — never in a repo, never in a backend record, never plaintext.
|
|
10
|
+
// Session tokens are HMAC-signed claims under a random per-user secret, so they survive a gateway restart
|
|
11
|
+
// with no server-side session table, verified in constant time. Each verifier carries a random `gen` that
|
|
12
|
+
// rotates on every set/clear and is embedded in the tokens it authenticates — changing or clearing a
|
|
13
|
+
// password instantly invalidates every session it minted.
|
|
14
|
+
import { createHmac, createHash, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
|
|
15
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { dirname, join } from 'node:path'
|
|
17
|
+
import { spexcodeHome } from './layout.js'
|
|
18
|
+
|
|
19
|
+
export type Verifier = { algo: 'scrypt'; N: number; r: number; p: number; salt: string; hash: string; gen: string }
|
|
20
|
+
export type AuthStore = { v: 1; secret: string; admin?: Verifier; projects: Record<string, Verifier> }
|
|
21
|
+
export type Scope = { s: 'admin' } | { s: 'project'; p: string }
|
|
22
|
+
export type Claims = { v: 1; s: 'admin' | 'project'; p?: string; g: string; t: number }
|
|
23
|
+
export type Decision =
|
|
24
|
+
| { ok: true; via: 'admin' | 'project' | 'open' | 'loopback' }
|
|
25
|
+
| { ok: false; reason: 'admin-login' | 'locked' | 'project-login' }
|
|
26
|
+
|
|
27
|
+
const TOKEN_TTL_MS = 30 * 24 * 3600 * 1000 // matches the single-project gateway's 30-day cookie
|
|
28
|
+
const SCRYPT = { N: 16384, r: 8, p: 1 } as const
|
|
29
|
+
|
|
30
|
+
// ---- the private per-user store --------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
export function authStorePath(): string {
|
|
33
|
+
return join(spexcodeHome(), 'gateway', 'auth.json')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// first load creates the store (and its random signing secret) so sessions survive restarts; the file is
|
|
37
|
+
// 0600 in a 0700 dir — this is a per-USER secret store, no other principal has business reading it.
|
|
38
|
+
export function loadAuthStore(): AuthStore {
|
|
39
|
+
try {
|
|
40
|
+
const raw = JSON.parse(readFileSync(authStorePath(), 'utf8'))
|
|
41
|
+
if (raw?.v === 1 && typeof raw.secret === 'string' && raw.secret) return { v: 1, secret: raw.secret, admin: raw.admin, projects: raw.projects ?? {} }
|
|
42
|
+
} catch { /* absent or unreadable → fresh below; malformed secrets must not be half-trusted */ }
|
|
43
|
+
const fresh: AuthStore = { v: 1, secret: randomBytes(32).toString('base64url'), projects: {} }
|
|
44
|
+
saveAuthStore(fresh)
|
|
45
|
+
return fresh
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function saveAuthStore(store: AuthStore): void {
|
|
49
|
+
const file = authStorePath()
|
|
50
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
|
|
51
|
+
const tmp = `${file}.tmp`
|
|
52
|
+
writeFileSync(tmp, JSON.stringify(store, null, 2) + '\n', { mode: 0o600 })
|
|
53
|
+
renameSync(tmp, file)
|
|
54
|
+
chmodSync(file, 0o600) // rename preserves the tmp mode, but never let a pre-existing looser file win
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- password verifiers (scrypt, salted, constant-time) --------------------------------------------
|
|
58
|
+
|
|
59
|
+
export function makeVerifier(password: string): Verifier {
|
|
60
|
+
const salt = randomBytes(16)
|
|
61
|
+
const hash = scryptSync(password, salt, 32, SCRYPT)
|
|
62
|
+
return { algo: 'scrypt', ...SCRYPT, salt: salt.toString('base64url'), hash: hash.toString('base64url'), gen: randomBytes(8).toString('base64url') }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function verifyPassword(v: Verifier | undefined, password: string): boolean {
|
|
66
|
+
if (!v || v.algo !== 'scrypt' || typeof password !== 'string') return false
|
|
67
|
+
const got = scryptSync(password, Buffer.from(v.salt, 'base64url'), 32, { N: v.N, r: v.r, p: v.p })
|
|
68
|
+
return constEq(got, Buffer.from(v.hash, 'base64url'))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function constEq(a: Buffer | string, b: Buffer | string): boolean {
|
|
72
|
+
const ab = Buffer.isBuffer(a) ? a : Buffer.from(a)
|
|
73
|
+
const bb = Buffer.isBuffer(b) ? b : Buffer.from(b)
|
|
74
|
+
return ab.length === bb.length && timingSafeEqual(ab, bb)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- verifier management (the gateway admin APIs write through these) ------------------------------
|
|
78
|
+
|
|
79
|
+
export function setAdminPassword(password: string): AuthStore {
|
|
80
|
+
const store = loadAuthStore()
|
|
81
|
+
store.admin = makeVerifier(password)
|
|
82
|
+
saveAuthStore(store)
|
|
83
|
+
return store
|
|
84
|
+
}
|
|
85
|
+
export function clearAdminPassword(): AuthStore {
|
|
86
|
+
const store = loadAuthStore()
|
|
87
|
+
delete store.admin
|
|
88
|
+
saveAuthStore(store)
|
|
89
|
+
return store
|
|
90
|
+
}
|
|
91
|
+
export function setProjectPassword(projectId: string, password: string): AuthStore {
|
|
92
|
+
const store = loadAuthStore()
|
|
93
|
+
store.projects[projectId] = makeVerifier(password)
|
|
94
|
+
saveAuthStore(store)
|
|
95
|
+
return store
|
|
96
|
+
}
|
|
97
|
+
export function clearProjectPassword(projectId: string): AuthStore {
|
|
98
|
+
const store = loadAuthStore()
|
|
99
|
+
delete store.projects[projectId]
|
|
100
|
+
saveAuthStore(store)
|
|
101
|
+
return store
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- signed session tokens --------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function sign(secret: string, payload: string): string {
|
|
107
|
+
return createHmac('sha256', Buffer.from(secret, 'base64url')).update(payload).digest('base64url')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// a token exists only for a scope that has a verifier — its `g` claim is that verifier's current gen.
|
|
111
|
+
export function mintToken(store: AuthStore, scope: Scope, nowMs = Date.now()): string {
|
|
112
|
+
const v = scope.s === 'admin' ? store.admin : store.projects[scope.p]
|
|
113
|
+
if (!v) throw new Error(`gateway-auth: cannot mint a ${scope.s} token with no verifier configured`)
|
|
114
|
+
const claims: Claims = { v: 1, s: scope.s, ...(scope.s === 'project' ? { p: scope.p } : {}), g: v.gen, t: nowMs }
|
|
115
|
+
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url')
|
|
116
|
+
return `${payload}.${sign(store.secret, payload)}`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// full validity lives here: signature (constant-time), shape, expiry, and the gen check against the
|
|
120
|
+
// CURRENT verifier — a cleared or re-set password leaves no valid tokens behind.
|
|
121
|
+
export function verifyToken(store: AuthStore, token: string, nowMs = Date.now()): Claims | null {
|
|
122
|
+
const dot = token.indexOf('.')
|
|
123
|
+
if (dot <= 0) return null
|
|
124
|
+
const payload = token.slice(0, dot), sig = token.slice(dot + 1)
|
|
125
|
+
if (!constEq(sig, sign(store.secret, payload))) return null
|
|
126
|
+
let claims: Claims
|
|
127
|
+
try { claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) } catch { return null }
|
|
128
|
+
if (claims?.v !== 1 || typeof claims.t !== 'number' || typeof claims.g !== 'string') return null
|
|
129
|
+
if (nowMs > claims.t + TOKEN_TTL_MS || claims.t > nowMs + 60_000) return null
|
|
130
|
+
if (claims.s === 'admin') return store.admin && constEq(claims.g, store.admin.gen) ? claims : null
|
|
131
|
+
if (claims.s === 'project' && typeof claims.p === 'string') {
|
|
132
|
+
const v = store.projects[claims.p]
|
|
133
|
+
return v && constEq(claims.g, v.gen) ? claims : null
|
|
134
|
+
}
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- cookies ----------------------------------------------------------------------------------------
|
|
139
|
+
// Cookie names are keyed by the gateway's public port (cookies are host-scoped; the port is the
|
|
140
|
+
// discriminator — same rationale as the single-project gateway) and, for project scopes, by a hash of the
|
|
141
|
+
// projectId (ids are path-derived and may hold non-token chars). The NAME is only a mailbox: authorization
|
|
142
|
+
// always re-validates the token's own projectId claim against the requested route — never the cookie's
|
|
143
|
+
// name, and never its Path attribute, both of which are client-controlled.
|
|
144
|
+
|
|
145
|
+
export function adminCookieName(port: number): string { return `spex_admin_${port}` }
|
|
146
|
+
export function projectCookieName(port: number, projectId: string): string {
|
|
147
|
+
return `spex_proj_${port}_${createHash('sha256').update(projectId).digest('hex').slice(0, 12)}`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function cookieOf(header: string | undefined, name: string): string | null {
|
|
151
|
+
for (const part of (header ?? '').split(';')) {
|
|
152
|
+
const eq = part.indexOf('=')
|
|
153
|
+
if (eq > 0 && part.slice(0, eq).trim() === name) return decodeURIComponent(part.slice(eq + 1).trim())
|
|
154
|
+
}
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---- loopback ---------------------------------------------------------------------------------------
|
|
159
|
+
// The implicit-admin decision reads the SOCKET's remote address only. Never a header (X-Forwarded-For,
|
|
160
|
+
// X-Real-IP): headers are attacker-controlled, and this gateway is designed to face the internet directly,
|
|
161
|
+
// not to sit behind a trusted proxy.
|
|
162
|
+
export function isLoopback(addr: string | null | undefined): boolean {
|
|
163
|
+
if (!addr) return false
|
|
164
|
+
const a = addr.startsWith('::ffff:') ? addr.slice(7) : addr
|
|
165
|
+
return a === '::1' || /^127(\.\d{1,3}){3}$/.test(a)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---- the decision -----------------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
export type Route = { kind: 'admin' } | { kind: 'project'; projectId: string }
|
|
171
|
+
|
|
172
|
+
export function authorize(store: AuthStore, route: Route, cookieHeader: string | undefined, remoteAddr: string | null | undefined, port: number): Decision {
|
|
173
|
+
const adminTok = cookieOf(cookieHeader, adminCookieName(port))
|
|
174
|
+
const adminClaims = adminTok ? verifyToken(store, adminTok) : null
|
|
175
|
+
const adminOk = adminClaims?.s === 'admin'
|
|
176
|
+
|
|
177
|
+
if (route.kind === 'admin') {
|
|
178
|
+
if (adminOk) return { ok: true, via: 'admin' }
|
|
179
|
+
// no admin verifier configured: loopback manages implicitly (the bootstrap path — set the first
|
|
180
|
+
// password from the machine itself); anyone else finds /projects locked, not open.
|
|
181
|
+
if (!store.admin) return isLoopback(remoteAddr) ? { ok: true, via: 'loopback' } : { ok: false, reason: 'locked' }
|
|
182
|
+
return { ok: false, reason: 'admin-login' }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// project route — admin reaches every project; then the project's own gate decides.
|
|
186
|
+
if (adminOk) return { ok: true, via: 'admin' }
|
|
187
|
+
if (!store.projects[route.projectId]) return { ok: true, via: 'open' }
|
|
188
|
+
const tok = cookieOf(cookieHeader, projectCookieName(port, route.projectId))
|
|
189
|
+
const claims = tok ? verifyToken(store, tok) : null
|
|
190
|
+
// the projectId CLAIM must match the route — presenting project A's token under project B's cookie
|
|
191
|
+
// name (or any Path trick) authorizes nothing.
|
|
192
|
+
if (claims?.s === 'project' && claims.p === route.projectId) return { ok: true, via: 'project' }
|
|
193
|
+
return { ok: false, reason: 'project-login' }
|
|
194
|
+
}
|