@skitterbyte/skitterspec 18.0.0 → 20.0.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/MIGRATION.md +296 -10
- package/README.md +53 -4
- package/assets/claude-md-section.md +38 -2
- package/assets/commands/spec-connect.md +2 -2
- package/assets/commands/spec-live.md +2 -2
- package/assets/core/env.config.json.example +7 -3
- package/assets/core/env.config.md +90 -30
- package/assets/hooks/review-gate.js +141 -0
- package/assets/review/page.html +1787 -0
- package/assets/rules/spec-planning.md +250 -10
- package/assets/rules/spec-reports.md +321 -0
- package/assets/skills/spec/SKILL.md +32 -4
- package/assets/skills/spec-bug/SKILL.md +113 -8
- package/assets/skills/spec-cancel/SKILL.md +84 -19
- package/assets/skills/spec-complete/SKILL.md +80 -23
- package/assets/skills/spec-diff/SKILL.md +678 -0
- package/assets/skills/spec-hotfix/SKILL.md +113 -10
- package/assets/skills/spec-init/SKILL.md +56 -7
- package/assets/skills/spec-next/SKILL.md +408 -7
- package/assets/skills/spec-review/SKILL.md +26 -3
- package/assets/skills/spec-reviewed/SKILL.md +258 -0
- package/assets/skills/spec-start/SKILL.md +283 -106
- package/assets/skills/spec-to-main/SKILL.md +28 -6
- package/package.json +11 -7
- package/src/cli.js +1808 -89
- package/src/env/building.js +143 -0
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +58 -9
- package/src/env/hooks.js +117 -0
- package/src/env/provision.js +54 -15
- package/src/env/proxy.js +34 -1
- package/src/env/render.js +3 -12
- package/src/env/resolve.js +295 -9
- package/src/env/review.js +1536 -0
- package/src/env/serve.js +573 -0
- package/src/env/teardown.js +13 -6
- package/src/init.js +150 -1
- package/LICENSE +0 -21
package/src/cli.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const fs = require('fs')
|
|
4
4
|
const path = require('path')
|
|
5
5
|
const { execFileSync } = require('child_process')
|
|
6
|
-
const { init, resync, reset, isExistingSetup } = require('./init.js')
|
|
6
|
+
const { init, resync, reset, checkSync, isExistingSetup } = require('./init.js')
|
|
7
7
|
const {
|
|
8
8
|
detectReleaseTooling,
|
|
9
9
|
removeReleaseTooling,
|
|
@@ -20,13 +20,18 @@ const {
|
|
|
20
20
|
const {
|
|
21
21
|
resolveSpec,
|
|
22
22
|
resolveBaseBranch,
|
|
23
|
+
liveWorktreePaths,
|
|
24
|
+
collectSpecFolders,
|
|
25
|
+
allSpecs,
|
|
23
26
|
resolvePrimaryCheckout,
|
|
24
27
|
assertPrimaryOnMain,
|
|
25
28
|
currentBranch,
|
|
26
29
|
repoInfo,
|
|
27
30
|
expandTokens,
|
|
28
31
|
splitPrefix,
|
|
32
|
+
BUCKETS,
|
|
29
33
|
} = require('./env/resolve.js')
|
|
34
|
+
const building = require('./env/building.js')
|
|
30
35
|
const {
|
|
31
36
|
readReceipt,
|
|
32
37
|
writeReceipt,
|
|
@@ -38,14 +43,57 @@ const {
|
|
|
38
43
|
planAbort,
|
|
39
44
|
} = require('./env/live.js')
|
|
40
45
|
const { ensureWorktreeDirTrusted } = require('./env/trust.js')
|
|
46
|
+
const {
|
|
47
|
+
rawGitReader,
|
|
48
|
+
collectReview,
|
|
49
|
+
renderReviewPage,
|
|
50
|
+
renderReviewBlock,
|
|
51
|
+
reviewOutPath,
|
|
52
|
+
reviewFileUrl,
|
|
53
|
+
reviewUrlPath,
|
|
54
|
+
reviewPublishPath,
|
|
55
|
+
readReviewUrl,
|
|
56
|
+
publishedPageNotice,
|
|
57
|
+
reviewServerNotice,
|
|
58
|
+
renderReviewFragment,
|
|
59
|
+
resolveReader,
|
|
60
|
+
writeReviewPage,
|
|
61
|
+
reviewNotesPath,
|
|
62
|
+
readNotes,
|
|
63
|
+
writeNotes,
|
|
64
|
+
validateNotesBlob,
|
|
65
|
+
judgeVerdict,
|
|
66
|
+
appendDecision,
|
|
67
|
+
annotateLastDecision,
|
|
68
|
+
readPending,
|
|
69
|
+
writePending,
|
|
70
|
+
claimPending,
|
|
71
|
+
passesSince,
|
|
72
|
+
describePending,
|
|
73
|
+
pendingAge,
|
|
74
|
+
reviewPendingPath,
|
|
75
|
+
validateResolutions,
|
|
76
|
+
mergeNotes,
|
|
77
|
+
applyResolutions,
|
|
78
|
+
COMMITTING,
|
|
79
|
+
reviewGatePath,
|
|
80
|
+
readGate,
|
|
81
|
+
writeGate,
|
|
82
|
+
armGate,
|
|
83
|
+
disarmGate,
|
|
84
|
+
gateState,
|
|
85
|
+
} = require('./env/review.js')
|
|
41
86
|
const { planUp, planCheckoutUp } = require('./env/provision.js')
|
|
87
|
+
const { classifyDirtyTree } = require('./env/classify.js')
|
|
88
|
+
const { isGitCommit } = require('./env/commitcmd.js')
|
|
42
89
|
const { planDown, planDownCheckout } = require('./env/teardown.js')
|
|
43
90
|
const { planPrune, liveSlugsForSpecs, reconcileRegistry } = require('./env/prune.js')
|
|
44
91
|
const { planIntegrate, planIntegrateCheckout } = require('./env/integrate.js')
|
|
45
92
|
const { planHotfixLand } = require('./env/hotfix.js')
|
|
46
93
|
const { planDev } = require('./env/dev.js')
|
|
47
|
-
const { startProcess, stopProcess, waitHealthy } = require('./env/supervise.js')
|
|
48
|
-
const { renderRoutes, portsInUse, waitListening } = require('./env/proxy.js')
|
|
94
|
+
const { startProcess, stopProcess, waitHealthy, readPid, isAlive } = require('./env/supervise.js')
|
|
95
|
+
const { renderRoutes, portsInUse, portsInUseOn, waitListening } = require('./env/proxy.js')
|
|
96
|
+
const { mintToken, servableSpecs, engineVersionFor, staleServer } = require('./env/serve.js')
|
|
49
97
|
|
|
50
98
|
const pkg = require('../package.json')
|
|
51
99
|
|
|
@@ -81,7 +129,8 @@ Usage:
|
|
|
81
129
|
— non-interactively it just adds anything missing.
|
|
82
130
|
skitterspec update [dir] Resync managed files to the latest, keeping your
|
|
83
131
|
edits (--force to overwrite). Leaves specs/ + live
|
|
84
|
-
.core config alone.
|
|
132
|
+
.core config alone. --check reports what it would
|
|
133
|
+
change and writes nothing.
|
|
85
134
|
skitterspec spec-env <cmd> Per-spec isolation engine (opt-in; needs
|
|
86
135
|
specs/.core/env.config.json). Subcommands:
|
|
87
136
|
up <spec> print the plan to provision a worktree +
|
|
@@ -94,6 +143,10 @@ Usage:
|
|
|
94
143
|
integrate <spec> plan rebase + fast-forward onto the base branch
|
|
95
144
|
hotfix land <spec> tag + cherry-pick a hotfix (--also <tag>)
|
|
96
145
|
status list provisioned specs + port blocks
|
|
146
|
+
review <spec> write an HTML page of the spec's diff
|
|
147
|
+
(--branch for the whole spec; --out, --json)
|
|
148
|
+
(--notes <json> merges a review pass back;
|
|
149
|
+
--resolve <json> records what was done)
|
|
97
150
|
resolve <spec> print resolved slug/type/branch/paths
|
|
98
151
|
skitterspec gating <cmd> Release-gating check (opt-in; needs
|
|
99
152
|
specs/.core/gating.config.json). Subcommands:
|
|
@@ -138,6 +191,7 @@ function parse(argv) {
|
|
|
138
191
|
resync: false,
|
|
139
192
|
reset: false,
|
|
140
193
|
diff: false,
|
|
194
|
+
check: false,
|
|
141
195
|
}
|
|
142
196
|
const positional = []
|
|
143
197
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -155,6 +209,7 @@ function parse(argv) {
|
|
|
155
209
|
else if (a === '--resync') opts.resync = true
|
|
156
210
|
else if (a === '--reset') opts.reset = true
|
|
157
211
|
else if (a === '--diff') opts.diff = true
|
|
212
|
+
else if (a === '--check') opts.check = true
|
|
158
213
|
else if (a === '--dir') opts.dir = argv[++i]
|
|
159
214
|
else if (a.startsWith('--')) throw new Error(`unknown option: ${a}`)
|
|
160
215
|
else positional.push(a)
|
|
@@ -215,7 +270,7 @@ async function cleanupReleaseTooling(dir, opts) {
|
|
|
215
270
|
* the harmless direction for a read-only report.
|
|
216
271
|
*/
|
|
217
272
|
function specEnvStatus(dir, config) {
|
|
218
|
-
const worktreePaths = liveWorktreePaths(dir)
|
|
273
|
+
const worktreePaths = liveWorktreePaths(gitReader(dir))
|
|
219
274
|
const provisioned = allSpecs(dir, config, worktreePaths)
|
|
220
275
|
.map((s) => ({ folder: s.folder, wt: path.resolve(s.worktreePath) }))
|
|
221
276
|
// The primary checkout is itself in `git worktree list`; a spec is
|
|
@@ -242,8 +297,8 @@ function specEnvStatus(dir, config) {
|
|
|
242
297
|
}
|
|
243
298
|
|
|
244
299
|
// Plan a provision: allocate the slot, persist the registry, and print the plan
|
|
245
|
-
// the /spec-env skill executes (git worktree add, docker compose up, .env
|
|
246
|
-
//
|
|
300
|
+
// the /spec-env skill executes (git worktree add, docker compose up, .env).
|
|
301
|
+
// This creates no worktree and starts no stack — the caller runs the
|
|
247
302
|
// printed commands. Keep the output's verb honest about that.
|
|
248
303
|
|
|
249
304
|
// git quotes a path containing unusual bytes and C-escapes it. Unquote what we
|
|
@@ -316,13 +371,24 @@ function specIsUntracked(dir, git, spec) {
|
|
|
316
371
|
*/
|
|
317
372
|
function specOnForkPoint(dir, git, spec) {
|
|
318
373
|
if (spec.baseRef) return { onFork: null, foundOn: null }
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
374
|
+
// Ask about EVERY bucket, by folder name — never about `spec.path`.
|
|
375
|
+
//
|
|
376
|
+
// A spec's folder is its identity; which bucket holds it is a property of the
|
|
377
|
+
// ref you are asking about, and the two legitimately disagree: `/spec-start`
|
|
378
|
+
// moves a spec to `in-progress` on its own branch while the base still shows
|
|
379
|
+
// `backlog`. Worse, resolution PREFERS the worktree, so `spec.path` routinely
|
|
380
|
+
// points outside this repo entirely (`../<repo>-wt/<slug>/specs/...`) — a path
|
|
381
|
+
// no `cat-file` or `log` can ever match, which turned every in-flight spec
|
|
382
|
+
// into "not committed" and silently emptied the `it is on <branch>` hint too.
|
|
383
|
+
const rels = BUCKETS.map((bucket) => `specs/${bucket}/${spec.folder}`)
|
|
384
|
+
for (const rel of rels) {
|
|
385
|
+
if (git(['cat-file', '-e', `HEAD:${rel}/00-overview.md`]) !== null) {
|
|
386
|
+
return { onFork: true, foundOn: null }
|
|
387
|
+
}
|
|
322
388
|
}
|
|
323
389
|
// Best-effort: name the branch that does have it, so the refusal is actionable.
|
|
324
390
|
let foundOn = null
|
|
325
|
-
const sha = git(['log', '--all', '--format=%H', '-1', '--',
|
|
391
|
+
const sha = git(['log', '--all', '--format=%H', '-1', '--', ...rels])
|
|
326
392
|
if (sha) {
|
|
327
393
|
const branches = git(['branch', '--contains', sha, '--format=%(refname:short)'])
|
|
328
394
|
if (branches) foundOn = branches.split('\n').map((b) => b.trim()).filter(Boolean)[0] || null
|
|
@@ -336,11 +402,28 @@ function specOnForkPoint(dir, git, spec) {
|
|
|
336
402
|
// listed before the commands that stage them.
|
|
337
403
|
function specCommitLines(plan, folder) {
|
|
338
404
|
if (!plan.specCommit) return []
|
|
339
|
-
|
|
405
|
+
// "all of it" is a claim about the whole tree, and it stops being true the
|
|
406
|
+
// moment somebody else's work is sitting there too.
|
|
407
|
+
const head = (plan.untouched || []).length
|
|
408
|
+
? `uncommitted, and this much of it is ${folder}'s — it will be committed first:`
|
|
409
|
+
: `uncommitted, and all of it is ${folder}'s — it will be committed first:`
|
|
410
|
+
const out = ['', ` ${head}`]
|
|
340
411
|
for (const p of plan.specCommit.paths) out.push(` ${p}`)
|
|
341
412
|
return out
|
|
342
413
|
}
|
|
343
414
|
|
|
415
|
+
// What the run is deliberately leaving alone. Printed rather than swallowed,
|
|
416
|
+
// because provisioning beside somebody else's uncommitted work is a fact the
|
|
417
|
+
// operator should be told — and never printed as a warning, because it is not
|
|
418
|
+
// one: a worktree carries nothing, and the spec commit above names its own paths.
|
|
419
|
+
function untouchedLines(plan) {
|
|
420
|
+
const untouched = plan.untouched || []
|
|
421
|
+
if (!untouched.length) return []
|
|
422
|
+
const out = ['', ` not this spec's — left untouched (${untouched.length}):`]
|
|
423
|
+
for (const p of untouched) out.push(` ${p}`)
|
|
424
|
+
return out
|
|
425
|
+
}
|
|
426
|
+
|
|
344
427
|
// `spec-env up` in checkout mode. Gathers the git facts, hands them to the pure
|
|
345
428
|
// planner, and prints the plan or the refusal.
|
|
346
429
|
|
|
@@ -460,8 +543,8 @@ function specEnvUp(dir, config, specArg) {
|
|
|
460
543
|
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
461
544
|
|
|
462
545
|
// Checkout mode: the branch is built in the primary checkout, so none of the
|
|
463
|
-
// worktree machinery below applies — no slot, no trust entry
|
|
464
|
-
//
|
|
546
|
+
// worktree machinery below applies — no slot, no trust entry and no bootstrap.
|
|
547
|
+
// Handled first precisely so none of that runs by accident.
|
|
465
548
|
if (config.mode === 'checkout') {
|
|
466
549
|
specEnvUpCheckout(dir, config, spec)
|
|
467
550
|
return
|
|
@@ -480,6 +563,27 @@ function specEnvUp(dir, config, specArg) {
|
|
|
480
563
|
return
|
|
481
564
|
}
|
|
482
565
|
|
|
566
|
+
// READ THE TREE BEFORE WRITING ANYTHING INTO IT.
|
|
567
|
+
//
|
|
568
|
+
// The report below divides the uncommitted tree into this spec's paths and
|
|
569
|
+
// everyone else's, and "everyone else's" means *the operator's* — work that
|
|
570
|
+
// was here before this command ran. Anything this command writes must
|
|
571
|
+
// therefore be invisible to that read, or `up` reports its own output as
|
|
572
|
+
// somebody's unfinished business.
|
|
573
|
+
//
|
|
574
|
+
// It did exactly that: the trust write below creates
|
|
575
|
+
// .claude/settings.local.json, and a clean checkout then reported
|
|
576
|
+
// "not this spec's — left untouched (1)" naming that very file. It went
|
|
577
|
+
// unnoticed for as long as it did because git reads ~/.config/git/ignore as
|
|
578
|
+
// its global excludes with no core.excludesFile setting needed, and the
|
|
579
|
+
// author's happened to list that path — so git never mentioned the file on
|
|
580
|
+
// the one machine the suite ever ran on. The first CI runner disagreed.
|
|
581
|
+
const upGit = gitReader(dir)
|
|
582
|
+
const upStatus = upGit(['status', '--porcelain'])
|
|
583
|
+
const upDirtyPaths = dirtyPaths(upGit)
|
|
584
|
+
const upOnFork = specOnForkPoint(dir, upGit, spec)
|
|
585
|
+
const upSpecUntracked = specIsUntracked(dir, upGit, spec)
|
|
586
|
+
|
|
483
587
|
// Trust the shared worktree root so edits into the freshly-provisioned worktree
|
|
484
588
|
// don't prompt. One absolute entry (the root) covers every spec; self-heals on
|
|
485
589
|
// every provision for teammates who only cloned and ran /spec-start.
|
|
@@ -503,18 +607,15 @@ function specEnvUp(dir, config, specArg) {
|
|
|
503
607
|
attached = fs.existsSync(spec.worktreePath)
|
|
504
608
|
}
|
|
505
609
|
|
|
506
|
-
// The tree gate: the same facts the checkout planner gets
|
|
507
|
-
//
|
|
508
|
-
const upGit = gitReader(dir)
|
|
509
|
-
const upStatus = upGit(['status', '--porcelain'])
|
|
510
|
-
const upOnFork = specOnForkPoint(dir, upGit, spec)
|
|
610
|
+
// The tree gate: the same facts the checkout planner gets, all of them read
|
|
611
|
+
// above — before this command wrote anything of its own into the tree.
|
|
511
612
|
const plan = planUp(spec, { slot, attached }, config, {
|
|
512
613
|
clean: upStatus !== null && upStatus.length === 0,
|
|
513
|
-
dirtyPaths:
|
|
614
|
+
dirtyPaths: upDirtyPaths,
|
|
514
615
|
specOnFork: upOnFork.onFork,
|
|
515
616
|
specFoundOn: upOnFork.foundOn,
|
|
516
617
|
forkRef: spec.baseRef || currentBranch(upGit) || 'HEAD',
|
|
517
|
-
specUntracked:
|
|
618
|
+
specUntracked: upSpecUntracked,
|
|
518
619
|
})
|
|
519
620
|
|
|
520
621
|
if (plan.blocked) {
|
|
@@ -559,10 +660,10 @@ function specEnvUp(dir, config, specArg) {
|
|
|
559
660
|
)
|
|
560
661
|
}
|
|
561
662
|
out.push(...specCommitLines(plan, spec.folder))
|
|
663
|
+
out.push(...untouchedLines(plan))
|
|
562
664
|
out.push('')
|
|
563
665
|
out.push(' to provision, run:')
|
|
564
666
|
for (const cmd of plan.commands) out.push(` ${cmd}`)
|
|
565
|
-
if (plan.openCommand) out.push(` ${plan.openCommand}`)
|
|
566
667
|
// Seed files first (setup may depend on them), then the setup commands —
|
|
567
668
|
// both run in the worktree, under one heading.
|
|
568
669
|
const worktreeSteps = [...plan.seedCommands, ...plan.setupCommands]
|
|
@@ -683,6 +784,31 @@ function compactTimestamp() {
|
|
|
683
784
|
// touch the trusted worktree root in .claude/settings.local.json — that entry is
|
|
684
785
|
// the shared parent of every spec's worktree and harmless when empty; removing it
|
|
685
786
|
// would just re-prompt on the next /spec-start (see spec: isolation-trusts-worktree-dir).
|
|
787
|
+
/**
|
|
788
|
+
* Gather the facts `reviewServerNotice` needs, and ask it what to say.
|
|
789
|
+
*
|
|
790
|
+
* `othersServed` counts the specs the server would still have work for once
|
|
791
|
+
* this one is gone. Evaluated BEFORE the teardown commands run — the worktree
|
|
792
|
+
* is still on disk at this point — so the spec being torn down is excluded by
|
|
793
|
+
* name rather than by waiting for it to disappear.
|
|
794
|
+
*/
|
|
795
|
+
function reviewServerNoticeFor(dir, config, folder) {
|
|
796
|
+
const sdir = stateDirLabel(config)
|
|
797
|
+
const proc = serveProcFor(config, path.resolve(dir, `${sdir}/review-serve.json`), { root: dir })
|
|
798
|
+
const pid = readPid(path.resolve(dir, proc.pidFile))
|
|
799
|
+
let othersServed = 0
|
|
800
|
+
try {
|
|
801
|
+
othersServed = servableSpecs(dir, config, gitReader(dir)).filter(
|
|
802
|
+
(sp) => sp.folder !== folder,
|
|
803
|
+
).length
|
|
804
|
+
} catch {
|
|
805
|
+
// Cannot tell how many are left — so say nothing rather than tell someone
|
|
806
|
+
// to stop a server other specs may still need (negative-checks rule 4).
|
|
807
|
+
return []
|
|
808
|
+
}
|
|
809
|
+
return reviewServerNotice({ running: Boolean(pid && isAlive(pid)), othersServed })
|
|
810
|
+
}
|
|
811
|
+
|
|
686
812
|
function specEnvDown(dir, config, specArg, flags) {
|
|
687
813
|
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
688
814
|
|
|
@@ -717,6 +843,10 @@ function specEnvDown(dir, config, specArg, flags) {
|
|
|
717
843
|
' mode: checkout (no worktree, no slot, no volumes)',
|
|
718
844
|
` branch: ${dplan.branch}`, '', ' run these:']
|
|
719
845
|
for (const cmd of dplan.commands) dout.push(` ${cmd}`)
|
|
846
|
+
// Checkout mode has no worktree, but a published page outlives it just the
|
|
847
|
+
// same — the two modes must not disagree about what is left behind.
|
|
848
|
+
dout.push(...publishedPageNotice(readReviewUrl(reviewOutPath(dir, spec.folder))))
|
|
849
|
+
dout.push(...reviewServerNoticeFor(dir, config, spec.folder))
|
|
720
850
|
process.stdout.write(dout.join('\n') + '\n')
|
|
721
851
|
return
|
|
722
852
|
}
|
|
@@ -765,6 +895,10 @@ function specEnvDown(dir, config, specArg, flags) {
|
|
|
765
895
|
out.push(' remote branch — confirm with the user first:')
|
|
766
896
|
for (const cmd of plan.remoteCommands) out.push(` ${cmd}`)
|
|
767
897
|
}
|
|
898
|
+
// The one survivor. Read through `readReviewUrl` rather than by building the
|
|
899
|
+
// path, and reported after the commands because it is not one of them.
|
|
900
|
+
out.push(...publishedPageNotice(readReviewUrl(reviewOutPath(dir, spec.folder))))
|
|
901
|
+
out.push(...reviewServerNoticeFor(dir, config, spec.folder))
|
|
768
902
|
process.stdout.write(out.join('\n') + '\n')
|
|
769
903
|
}
|
|
770
904
|
|
|
@@ -816,17 +950,6 @@ function volumeCreatedAt(names) {
|
|
|
816
950
|
}
|
|
817
951
|
|
|
818
952
|
// Absolute paths of every checkout git knows about (primary + all worktrees).
|
|
819
|
-
function liveWorktreePaths(dir) {
|
|
820
|
-
const out = gitReader(dir)(['worktree', 'list', '--porcelain'])
|
|
821
|
-
const paths = new Set()
|
|
822
|
-
if (out == null) return paths
|
|
823
|
-
for (const line of out.split('\n')) {
|
|
824
|
-
if (line.startsWith('worktree ')) {
|
|
825
|
-
paths.add(path.resolve(line.slice('worktree '.length).trim()))
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
return paths
|
|
829
|
-
}
|
|
830
953
|
|
|
831
954
|
/**
|
|
832
955
|
* The spec to act on when the caller named none.
|
|
@@ -862,8 +985,20 @@ function liveWorktreePaths(dir) {
|
|
|
862
985
|
* git keeps listing it as prunable. That over-reports rather than under-reports,
|
|
863
986
|
* so the failure is an ambiguity error, never a wrong spec.
|
|
864
987
|
*/
|
|
865
|
-
|
|
866
|
-
|
|
988
|
+
/**
|
|
989
|
+
* The same resolution as `soleProvisionedSpec`, returned as DATA rather than
|
|
990
|
+
* thrown: `{ folder }` when exactly one answer, `{ candidates }` when several,
|
|
991
|
+
* `{}` when none has a worktree.
|
|
992
|
+
*
|
|
993
|
+
* It exists because `liveGrammar` has to tell "several worktrees" from "no
|
|
994
|
+
* worktrees" and act differently on each, and the only other way to do that is
|
|
995
|
+
* to pattern-match the wording of an Error — which makes a message nobody
|
|
996
|
+
* thought was load-bearing into API. One implementation, two presentations:
|
|
997
|
+
* `soleProvisionedSpec` below is a thin wrapper that turns the two empty-handed
|
|
998
|
+
* cases into the exact errors every other subcommand already relies on.
|
|
999
|
+
*/
|
|
1000
|
+
function provisionedSpecChoice(dir, config, cwd = process.cwd()) {
|
|
1001
|
+
const worktreePaths = liveWorktreePaths(gitReader(dir))
|
|
867
1002
|
const provisioned = allSpecs(dir, config, worktreePaths)
|
|
868
1003
|
.map((s) => ({ folder: s.folder, wt: path.resolve(s.worktreePath) }))
|
|
869
1004
|
// The primary checkout is itself in `git worktree list`; a spec is
|
|
@@ -882,20 +1017,26 @@ function soleProvisionedSpec(dir, config, cwd = process.cwd()) {
|
|
|
882
1017
|
const inside = provisioned
|
|
883
1018
|
.filter((s) => here === s.wt || here.startsWith(s.wt + path.sep))
|
|
884
1019
|
.sort((a, b) => b.wt.length - a.wt.length)[0]
|
|
885
|
-
if (inside) return inside.folder
|
|
1020
|
+
if (inside) return { folder: inside.folder }
|
|
886
1021
|
|
|
887
1022
|
// 2. Otherwise only an unambiguous set answers.
|
|
888
|
-
if (provisioned.length === 1) return provisioned[0].folder
|
|
889
|
-
|
|
1023
|
+
if (provisioned.length === 1) return { folder: provisioned[0].folder }
|
|
1024
|
+
return { candidates: provisioned.map((s) => s.folder) }
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function soleProvisionedSpec(dir, config, cwd = process.cwd()) {
|
|
1028
|
+
const { folder, candidates } = provisionedSpecChoice(dir, config, cwd)
|
|
1029
|
+
if (folder) return folder
|
|
1030
|
+
if (!candidates.length) {
|
|
890
1031
|
throw new Error(
|
|
891
1032
|
'no spec given, and no spec has a worktree — name one explicitly, or run ' +
|
|
892
1033
|
'/spec-start to provision it.',
|
|
893
1034
|
)
|
|
894
1035
|
}
|
|
895
1036
|
throw new Error(
|
|
896
|
-
`no spec given, and ${
|
|
1037
|
+
`no spec given, and ${candidates.length} specs have worktrees — name the one ` +
|
|
897
1038
|
`you mean, or run this from inside one:\n` +
|
|
898
|
-
|
|
1039
|
+
candidates.map((f, i) => ` ${i + 1}. ${f}`).join('\n'),
|
|
899
1040
|
)
|
|
900
1041
|
}
|
|
901
1042
|
|
|
@@ -922,47 +1063,30 @@ function resolveSpecWithWorktree(dir, config, specArg) {
|
|
|
922
1063
|
expandTokens(config.worktree.root, wtTokens),
|
|
923
1064
|
expandTokens(config.worktree.folderPattern, wtTokens),
|
|
924
1065
|
)
|
|
925
|
-
const searchDirs = [...new Set([worktreeGuess, ...liveWorktreePaths(dir)])].filter(
|
|
1066
|
+
const searchDirs = [...new Set([worktreeGuess, ...liveWorktreePaths(gitReader(dir))])].filter(
|
|
926
1067
|
(p) => p !== dir,
|
|
927
1068
|
)
|
|
928
|
-
|
|
1069
|
+
// This spec's OWN worktree is preferred over the primary checkout, not merely a
|
|
1070
|
+
// fallback to it. `/spec-start` moves a spec to `in-progress` on the spec's own
|
|
1071
|
+
// branch, so the primary checkout goes on reporting `backlog` for the whole
|
|
1072
|
+
// life of the spec — and the same stale file supplies `Stack:` and
|
|
1073
|
+
// `Base version:`, so escalating a spec to docker by editing its header in the
|
|
1074
|
+
// worktree was invisible to `spec-env up`.
|
|
1075
|
+
//
|
|
1076
|
+
// WHAT WOULD FOOL THIS: only this spec's own worktree is promoted, never the
|
|
1077
|
+
// other entries in `searchDirs`. Those are other specs' checkouts, and letting
|
|
1078
|
+
// one of them answer for this spec would swap one wrong branch's view for
|
|
1079
|
+
// another's. A worktree left behind by a declined teardown can still answer
|
|
1080
|
+
// with a stale bucket — that is a leftover to prune, not a lookup to distrust.
|
|
1081
|
+
const preferDirs = worktreeGuess === dir ? [] : [worktreeGuess]
|
|
1082
|
+
return resolveSpec(specArg, dir, config, { searchDirs, preferDirs })
|
|
929
1083
|
}
|
|
930
1084
|
|
|
931
1085
|
// Every spec folder name found under specs/* across the given checkout roots.
|
|
932
|
-
// An in-progress spec lives on its *worktree branch*, not the primary checkout,
|
|
933
|
-
// so we must scan the worktrees too — otherwise a live spec's DB looks orphaned.
|
|
934
|
-
function collectSpecFolders(roots) {
|
|
935
|
-
const folders = new Set()
|
|
936
|
-
for (const root of roots) {
|
|
937
|
-
for (const bucket of ['backlog', 'in-progress', 'complete', 'cancelled']) {
|
|
938
|
-
let entries
|
|
939
|
-
try {
|
|
940
|
-
entries = fs.readdirSync(path.join(root, 'specs', bucket), { withFileTypes: true })
|
|
941
|
-
} catch {
|
|
942
|
-
continue
|
|
943
|
-
}
|
|
944
|
-
for (const entry of entries) if (entry.isDirectory()) folders.add(entry.name)
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
return folders
|
|
948
|
-
}
|
|
949
1086
|
|
|
950
1087
|
// Resolve every spec folder (found in the primary checkout OR any worktree) to
|
|
951
1088
|
// { folder, slug, worktreePath }. `searchDirs` lets resolveSpec locate a spec
|
|
952
1089
|
// that was authored on its branch and never committed to the primary checkout.
|
|
953
|
-
function allSpecs(dir, config, worktreePaths) {
|
|
954
|
-
const searchDirs = [...worktreePaths]
|
|
955
|
-
const specs = []
|
|
956
|
-
for (const folder of collectSpecFolders([dir, ...searchDirs])) {
|
|
957
|
-
try {
|
|
958
|
-
const spec = resolveSpec(folder, dir, config, { searchDirs })
|
|
959
|
-
specs.push({ folder: spec.folder, slug: spec.slug, worktreePath: spec.worktreePath })
|
|
960
|
-
} catch {
|
|
961
|
-
// Unresolvable folder (not a real spec) — skip.
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
return specs
|
|
965
|
-
}
|
|
966
1090
|
|
|
967
1091
|
// Prune: reconcile namespace volumes against specs that still have a worktree and
|
|
968
1092
|
// print the `docker volume rm` commands for the orphans. Liveness keys off the
|
|
@@ -970,7 +1094,37 @@ function allSpecs(dir, config, worktreePaths) {
|
|
|
970
1094
|
// this correctly reaps those and frees their stale slots. Destructive removal is
|
|
971
1095
|
// executed by the caller (skill) after confirmation — the CLI only plans + writes
|
|
972
1096
|
// the registry, mirroring `spec-env down`.
|
|
1097
|
+
/**
|
|
1098
|
+
* Delete a review-server pidfile whose process is gone.
|
|
1099
|
+
*
|
|
1100
|
+
* A LIVE pid is left strictly alone: `isAlive` is the positive signal, and the
|
|
1101
|
+
* file merely existing proves nothing — a crashed server leaves one behind,
|
|
1102
|
+
* which is the whole case this reaps. Being wrong in the other direction would
|
|
1103
|
+
* mean deleting the record of a server that is still listening, so the unknown
|
|
1104
|
+
* case (unreadable file, failed unlink) does nothing at all.
|
|
1105
|
+
*/
|
|
1106
|
+
function reapStaleServePid(dir, config) {
|
|
1107
|
+
const proc = serveProcFor(config, path.resolve(dir, `${stateDirLabel(config)}/review-serve.json`), { root: dir })
|
|
1108
|
+
const file = path.resolve(dir, proc.pidFile)
|
|
1109
|
+
const pid = readPid(file)
|
|
1110
|
+
if (!pid || isAlive(pid)) return null
|
|
1111
|
+
try {
|
|
1112
|
+
fs.rmSync(file, { force: true })
|
|
1113
|
+
} catch {
|
|
1114
|
+
return null
|
|
1115
|
+
}
|
|
1116
|
+
return pid
|
|
1117
|
+
}
|
|
1118
|
+
|
|
973
1119
|
function specEnvPrune(dir, config, flags) {
|
|
1120
|
+
// Before the docker section, deliberately: a stale pidfile is not docker's
|
|
1121
|
+
// business, and every branch below can return early.
|
|
1122
|
+
const reapedPid = reapStaleServePid(dir, config)
|
|
1123
|
+
if (reapedPid) {
|
|
1124
|
+
process.stdout.write(
|
|
1125
|
+
`spec-env prune: reaped a stale review-server pidfile (pid ${reapedPid} is gone).\n`,
|
|
1126
|
+
)
|
|
1127
|
+
}
|
|
974
1128
|
const { repoSlug } = repoInfo(dir)
|
|
975
1129
|
|
|
976
1130
|
const vols = listRepoVolumes(repoSlug)
|
|
@@ -982,7 +1136,7 @@ function specEnvPrune(dir, config, flags) {
|
|
|
982
1136
|
return
|
|
983
1137
|
}
|
|
984
1138
|
|
|
985
|
-
const worktrees = liveWorktreePaths(dir)
|
|
1139
|
+
const worktrees = liveWorktreePaths(gitReader(dir))
|
|
986
1140
|
const specs = allSpecs(dir, config, worktrees)
|
|
987
1141
|
const liveSlugs = liveSlugsForSpecs(specs, worktrees)
|
|
988
1142
|
|
|
@@ -1273,9 +1427,93 @@ function specEnvHotfix(dir, config, positional, flags) {
|
|
|
1273
1427
|
process.stdout.write(out.join('\n') + '\n')
|
|
1274
1428
|
}
|
|
1275
1429
|
|
|
1430
|
+
// What the primary checkout has GAINED right now, by content. `dir` is already
|
|
1431
|
+
// anchored on the primary checkout by the dispatcher, so this reads the tree the
|
|
1432
|
+
// build must not be writing into. See env/building.js for why this is two
|
|
1433
|
+
// content-based queries rather than one `status --porcelain`.
|
|
1434
|
+
function primaryPaths(dir) {
|
|
1435
|
+
const git = gitReader(dir)
|
|
1436
|
+
return building.mergePaths(
|
|
1437
|
+
git(['diff', '--name-only', 'HEAD']),
|
|
1438
|
+
git(['ls-files', '--others', '--exclude-standard']),
|
|
1439
|
+
)
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// `--record-primary`: stamp the baseline a later --assert-primary-clean reads.
|
|
1443
|
+
function recordPrimary(dir, config, r) {
|
|
1444
|
+
const file = building.baselinePath(dir, config)
|
|
1445
|
+
const baseline = building.buildBaseline({
|
|
1446
|
+
spec: r.folder,
|
|
1447
|
+
worktreePath: r.worktreePath,
|
|
1448
|
+
primary: dir,
|
|
1449
|
+
paths: primaryPaths(dir),
|
|
1450
|
+
})
|
|
1451
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
1452
|
+
fs.writeFileSync(file, JSON.stringify(baseline, null, 2) + '\n')
|
|
1453
|
+
const n = baseline.paths.length
|
|
1454
|
+
process.stdout.write(
|
|
1455
|
+
`spec-env resolve: baseline recorded for ${r.folder}\n` +
|
|
1456
|
+
` primary: ${dir}\n` +
|
|
1457
|
+
` worktree: ${r.worktreePath}\n` +
|
|
1458
|
+
` dirty now: ${n === 0 ? 'nothing' : `${n} path(s) — these will not be reported later`}\n`,
|
|
1459
|
+
)
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// `--assert-primary-clean`: did the build write into the primary checkout?
|
|
1463
|
+
//
|
|
1464
|
+
// THREE OUTCOMES, NOT TWO. It accuses only on `leaked`; `unknown` reports what
|
|
1465
|
+
// blinded it and exits 0, because a baseline that is missing or belongs to
|
|
1466
|
+
// another spec is an absence, and an absence is not evidence
|
|
1467
|
+
// (`.claude/rules/negative-checks.md` rules 1 and 4). The blind spots this
|
|
1468
|
+
// cannot see are named on `compare()` in env/building.js.
|
|
1469
|
+
function assertPrimaryClean(dir, config, r) {
|
|
1470
|
+
const file = building.baselinePath(dir, config)
|
|
1471
|
+
let baseline = null
|
|
1472
|
+
try {
|
|
1473
|
+
baseline = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
1474
|
+
} catch {
|
|
1475
|
+
baseline = null
|
|
1476
|
+
}
|
|
1477
|
+
const result = building.compare(baseline, primaryPaths(dir), {
|
|
1478
|
+
spec: r.folder,
|
|
1479
|
+
worktreePath: r.worktreePath,
|
|
1480
|
+
primary: dir,
|
|
1481
|
+
})
|
|
1482
|
+
|
|
1483
|
+
if (result.verdict === 'leaked') {
|
|
1484
|
+
// States the OBSERVATION, not the attribution. All this knows is that the
|
|
1485
|
+
// primary checkout gained these paths since the baseline — it cannot know
|
|
1486
|
+
// who wrote them, and in practice another session writing a backlog spec
|
|
1487
|
+
// into the primary looks identical to a leaked build write. Both readings
|
|
1488
|
+
// get a next step, so being wrong about which one costs a re-record rather
|
|
1489
|
+
// than someone deleting work that was never a leak.
|
|
1490
|
+
throw new Error(
|
|
1491
|
+
`${result.paths.length} path(s) appeared in the PRIMARY checkout since the baseline:\n` +
|
|
1492
|
+
result.paths.map((p) => ` ${p}`).join('\n') +
|
|
1493
|
+
`\n primary: ${dir}` +
|
|
1494
|
+
`\n worktree: ${r.worktreePath}` +
|
|
1495
|
+
'\n if this build wrote them, move them into the worktree before committing.' +
|
|
1496
|
+
'\n if something else did, re-run --record-primary and carry on.',
|
|
1497
|
+
)
|
|
1498
|
+
}
|
|
1499
|
+
if (result.verdict === 'unknown') {
|
|
1500
|
+
process.stdout.write(
|
|
1501
|
+
`spec-env resolve: cannot tell — ${result.reason}.\n` +
|
|
1502
|
+
' no leak is being claimed; run --record-primary before the build to enable this check.\n',
|
|
1503
|
+
)
|
|
1504
|
+
return
|
|
1505
|
+
}
|
|
1506
|
+
process.stdout.write(
|
|
1507
|
+
`spec-env resolve: primary checkout clean for ${r.folder}\n` +
|
|
1508
|
+
` nothing was written into ${dir}\n`,
|
|
1509
|
+
)
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1276
1512
|
// Print the resolved identity/coordinates for a single spec.
|
|
1277
|
-
function specEnvResolve(dir, config, specArg) {
|
|
1513
|
+
function specEnvResolve(dir, config, specArg, flags = {}) {
|
|
1278
1514
|
const r = resolveSpecWithWorktree(dir, config, specArg)
|
|
1515
|
+
if (flags.recordPrimary) return recordPrimary(dir, config, r)
|
|
1516
|
+
if (flags.assertPrimaryClean) return assertPrimaryClean(dir, config, r)
|
|
1279
1517
|
process.stdout.write(
|
|
1280
1518
|
`spec: ${r.folder} (${r.bucket})\n` +
|
|
1281
1519
|
`type/slug: ${r.type} / ${r.slug}\n` +
|
|
@@ -1285,6 +1523,896 @@ function specEnvResolve(dir, config, specArg) {
|
|
|
1285
1523
|
)
|
|
1286
1524
|
}
|
|
1287
1525
|
|
|
1526
|
+
/**
|
|
1527
|
+
* `skitterspec spec-env stage [<spec>] [--json]`
|
|
1528
|
+
*
|
|
1529
|
+
* Which uncommitted paths belong to this spec, and which belong to someone else?
|
|
1530
|
+
*
|
|
1531
|
+
* `classifyDirtyTree` has answered that since the `/spec-start` gate was written,
|
|
1532
|
+
* but only `spec-env up` could reach it — so every skill that commits a spec
|
|
1533
|
+
* hand-wrote `git add specs/` instead, staging a DIRECTORY. With more than one
|
|
1534
|
+
* session writing into `specs/` at once that sweeps a colleague's in-progress
|
|
1535
|
+
* spec into this spec's commit, under this spec's ticket trailer. This verb is
|
|
1536
|
+
* how a skill asks instead of guessing.
|
|
1537
|
+
*
|
|
1538
|
+
* IT ACCUSES NOBODY. `foreign` is not a complaint and not a refusal — it is the
|
|
1539
|
+
* list of paths to leave alone. Nothing here exits non-zero and nothing here
|
|
1540
|
+
* writes.
|
|
1541
|
+
*
|
|
1542
|
+
* `owned` IS THE SPEC'S DOCUMENTS, NEVER ITS CODE. A phase's own implementation
|
|
1543
|
+
* lands in `foreign` — correctly, and this is the point: the commits this verb
|
|
1544
|
+
* exists to bound are the lifecycle ones (`chore(spec): complete <name>`), which
|
|
1545
|
+
* carry a status flip and a folder move and nothing else. A caller that staged
|
|
1546
|
+
* `owned` expecting a phase's work would commit the spec file alone and think it
|
|
1547
|
+
* had committed the feature.
|
|
1548
|
+
*
|
|
1549
|
+
* WHICH TREE IT READS: the one the caller is standing in, resolved from the
|
|
1550
|
+
* invocation cwd rather than from `dir` (which every subcommand re-anchors on
|
|
1551
|
+
* the primary checkout so worktree paths and the registry resolve identically).
|
|
1552
|
+
* That distinction is the whole point here: `/spec-complete` and `/spec-cancel`
|
|
1553
|
+
* run INSIDE the spec's worktree and must be told about that tree, while
|
|
1554
|
+
* `/spec-start` runs in the primary checkout and must be told about that one.
|
|
1555
|
+
* Re-anchoring would silently answer about the wrong checkout, so the tree read
|
|
1556
|
+
* is printed rather than assumed.
|
|
1557
|
+
*/
|
|
1558
|
+
function specEnvStage(dir, config, specArg, flags = {}, invokedFrom = dir) {
|
|
1559
|
+
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
1560
|
+
|
|
1561
|
+
// The git root CONTAINING the caller, not the primary checkout. `git ls-files`
|
|
1562
|
+
// is scoped to its cwd, so reading from a subdirectory would list only that
|
|
1563
|
+
// subdirectory's untracked files and report the rest of the spec as absent.
|
|
1564
|
+
const git = gitReader(invokedFrom)
|
|
1565
|
+
const tree = git(['rev-parse', '--show-toplevel']) || invokedFrom
|
|
1566
|
+
const paths = dirtyPaths(gitReader(tree))
|
|
1567
|
+
|
|
1568
|
+
// Three states, not two (`.claude/rules/negative-checks.md` rule 4). A null
|
|
1569
|
+
// here is "nobody could look", never "clean" — so it must not become an empty
|
|
1570
|
+
// owned set, which a caller would stage happily and commit as nothing.
|
|
1571
|
+
if (paths === null) {
|
|
1572
|
+
if (flags.json) {
|
|
1573
|
+
process.stdout.write(
|
|
1574
|
+
JSON.stringify({
|
|
1575
|
+
spec: spec.folder,
|
|
1576
|
+
tree,
|
|
1577
|
+
owned: null,
|
|
1578
|
+
foreign: null,
|
|
1579
|
+
error: 'git could not be read',
|
|
1580
|
+
}) + '\n',
|
|
1581
|
+
)
|
|
1582
|
+
return
|
|
1583
|
+
}
|
|
1584
|
+
process.stdout.write(
|
|
1585
|
+
`spec-env stage: ${spec.folder} — git could not be read at ${tree}, so nothing was classified.\n` +
|
|
1586
|
+
' This is not "clean": stage nothing on the strength of it.\n',
|
|
1587
|
+
)
|
|
1588
|
+
return
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
const { owned, foreign } = classifyDirtyTree(spec, paths, config)
|
|
1592
|
+
|
|
1593
|
+
if (flags.json) {
|
|
1594
|
+
process.stdout.write(JSON.stringify({ spec: spec.folder, tree, owned, foreign }) + '\n')
|
|
1595
|
+
return
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
const out = [
|
|
1599
|
+
`spec-env stage: ${spec.folder} — ${owned.length} owned, ${foreign.length} foreign`,
|
|
1600
|
+
` tree: ${tree}`,
|
|
1601
|
+
]
|
|
1602
|
+
// An empty list is omitted rather than printed under its heading: a heading
|
|
1603
|
+
// with nothing beneath it reads as a finding.
|
|
1604
|
+
if (owned.length) {
|
|
1605
|
+
out.push('', ` owned (${spec.folder}'s — safe to commit):`)
|
|
1606
|
+
for (const p of owned) out.push(` ${p}`)
|
|
1607
|
+
}
|
|
1608
|
+
if (foreign.length) {
|
|
1609
|
+
out.push('', ' foreign (not this spec\'s — leave them alone):')
|
|
1610
|
+
for (const p of foreign) out.push(` ${p}`)
|
|
1611
|
+
}
|
|
1612
|
+
if (!owned.length && !foreign.length) {
|
|
1613
|
+
out.push('', ' nothing uncommitted.')
|
|
1614
|
+
}
|
|
1615
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/**
|
|
1619
|
+
* Write a self-contained HTML review of a spec's diff.
|
|
1620
|
+
*
|
|
1621
|
+
* Read entirely through `git -C <worktreePath>` — the caller's shell never moves,
|
|
1622
|
+
* which is the whole point: the work being reviewed lives in a worktree, and the
|
|
1623
|
+
* terminal is somewhere else (often a phone). Two shapes: the uncommitted working
|
|
1624
|
+
* tree (the default — "what did this phase just do") and `--branch` (everything
|
|
1625
|
+
* since the base branch — "what does this whole spec do").
|
|
1626
|
+
*/
|
|
1627
|
+
// How a judged verdict reads on the `notes:` line. The refused case leads with
|
|
1628
|
+
// the word `refused` rather than burying it after the reason, because the one
|
|
1629
|
+
// thing the reader must take away is that the approval did not happen.
|
|
1630
|
+
function verdictSaid(v) {
|
|
1631
|
+
if (!v.honoured) return `commit refused — ${v.reason}`
|
|
1632
|
+
// A committing verdict names what it hands off to, because that is the next
|
|
1633
|
+
// thing that will happen to the repo and the reader should see it coming.
|
|
1634
|
+
if (v.effective === 'commit') return `committing with ${v.commitWith}`
|
|
1635
|
+
if (v.effective === 'commit-continue') return `committing with ${v.commitWith}, then the next phase`
|
|
1636
|
+
if (v.effective === 'changes') return 'changes requested'
|
|
1637
|
+
return 'discuss first'
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/**
|
|
1641
|
+
* Resolve the spec and its page path for a gate verb, or say why not.
|
|
1642
|
+
*
|
|
1643
|
+
* Shared by `arm`, `gate` and `skip` so all three answer about the same
|
|
1644
|
+
* sidecar the render writes beside the page — `--out` moves them together, and
|
|
1645
|
+
* a gate keyed to a different path than its page is a gate nobody can clear.
|
|
1646
|
+
*
|
|
1647
|
+
* It never throws. Resolution failure is a cannot-tell for these verbs, not an
|
|
1648
|
+
* error: `gate --check` is called by a commit hook, and a hook that fails on a
|
|
1649
|
+
* repo it could not resolve would block every commit in it.
|
|
1650
|
+
*/
|
|
1651
|
+
function gateTarget(dir, config, specArg) {
|
|
1652
|
+
try {
|
|
1653
|
+
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
1654
|
+
return { spec, out: reviewOutPath(dir, spec.folder), reason: null }
|
|
1655
|
+
} catch (err) {
|
|
1656
|
+
return { spec: null, out: null, reason: err.message }
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// `review arm` — a phase ended, and its diff is now owed a verdict.
|
|
1661
|
+
function specEnvReviewArm(dir, config, specArg, flags) {
|
|
1662
|
+
const target = gateTarget(dir, config, specArg)
|
|
1663
|
+
if (!target.spec) {
|
|
1664
|
+
// Arming is a best-effort half of a phase ending; the phase is still built.
|
|
1665
|
+
process.stdout.write(`spec-env review arm: cannot tell which spec — ${target.reason}\n`)
|
|
1666
|
+
return
|
|
1667
|
+
}
|
|
1668
|
+
const read = readGate(target.out, target.spec.folder)
|
|
1669
|
+
if (read.corrupt) {
|
|
1670
|
+
// Same rule as every other sidecar: never write over a file we could not
|
|
1671
|
+
// read. Here that also means never claiming to have armed something.
|
|
1672
|
+
process.stdout.write(
|
|
1673
|
+
`spec-env review arm: ${reviewGatePath(target.out)} is not readable JSON — ` +
|
|
1674
|
+
'move it aside rather than losing the history it holds.\n',
|
|
1675
|
+
)
|
|
1676
|
+
return
|
|
1677
|
+
}
|
|
1678
|
+
const phase = flags.phase === undefined ? null : flags.phase
|
|
1679
|
+
const before = read.gate
|
|
1680
|
+
const gate = armGate(before, { at: new Date().toISOString(), phase })
|
|
1681
|
+
writeGate(target.out, gate)
|
|
1682
|
+
const again = before.armed && gate.armedAt === before.armedAt
|
|
1683
|
+
if (flags.json) {
|
|
1684
|
+
process.stdout.write(JSON.stringify({ spec: target.spec.folder, armed: true, armedAt: gate.armedAt, phase: gate.phase, alreadyArmed: again }, null, 2) + '\n')
|
|
1685
|
+
return
|
|
1686
|
+
}
|
|
1687
|
+
process.stdout.write(
|
|
1688
|
+
`spec-env review arm: ${target.spec.folder} is awaiting a verdict` +
|
|
1689
|
+
`${gate.phase ? ` (phase ${gate.phase})` : ''}` +
|
|
1690
|
+
`${again ? ' — already was, since ' + String(gate.armedAt).slice(0, 19) : ''}\n`,
|
|
1691
|
+
)
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/**
|
|
1695
|
+
* `review gate` — is anything owed?
|
|
1696
|
+
*
|
|
1697
|
+
* `--check` is the one call a commit hook makes, and it exits non-zero ONLY on
|
|
1698
|
+
* `armed`: a positive signal, read from a present and parseable sidecar. Every
|
|
1699
|
+
* other state — cleared, unreadable, versioned past this engine, switched off
|
|
1700
|
+
* — exits 0 and says which, because a check that accuses on an absence accuses
|
|
1701
|
+
* healthy repos (`.claude/rules/negative-checks.md`).
|
|
1702
|
+
*/
|
|
1703
|
+
function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
|
|
1704
|
+
// `--for-command` is the hook's half: it asks about a command line rather
|
|
1705
|
+
// than about the repo, and a command that is not a commit is simply not this
|
|
1706
|
+
// check's business. Answered FIRST and in silence, because the overwhelming
|
|
1707
|
+
// majority of tool calls land here and every one of them must cost nothing
|
|
1708
|
+
// and say nothing.
|
|
1709
|
+
if (flags.forCommand !== undefined && !isGitCommit(flags.forCommand)) return
|
|
1710
|
+
|
|
1711
|
+
const target = gateTarget(dir, config, specArg)
|
|
1712
|
+
let judged = target.spec
|
|
1713
|
+
? gateState({ ...readGate(target.out, target.spec.folder), required: config.review.required })
|
|
1714
|
+
: { state: 'unknown', reason: target.reason, gate: null }
|
|
1715
|
+
|
|
1716
|
+
// ASKED ABOUT A COMMAND, the question is narrower than "is anything owed in
|
|
1717
|
+
// this repo": it is "does the commit happening HERE owe a verdict". The bare
|
|
1718
|
+
// resolution answers with the sole provisioned spec wherever you stand, which
|
|
1719
|
+
// is right for a person typing the verb and wrong for this — it denied a
|
|
1720
|
+
// commit on the base branch because some other spec was mid-review, which is
|
|
1721
|
+
// exactly how a backlog spec authored from the primary checkout (the thing
|
|
1722
|
+
// `commit-trailers.md` asks for) would be blocked by unrelated work.
|
|
1723
|
+
//
|
|
1724
|
+
// So it wants a POSITIVE signal (`.claude/rules/negative-checks.md` rule 1):
|
|
1725
|
+
// this commit is running inside that spec's own worktree. Anything else —
|
|
1726
|
+
// the primary checkout, another spec's tree, a path that cannot be resolved —
|
|
1727
|
+
// is a cannot-tell, and cannot-tell allows.
|
|
1728
|
+
if (flags.forCommand !== undefined && judged.state === 'armed') {
|
|
1729
|
+
const inside = (child, parent) => {
|
|
1730
|
+
try {
|
|
1731
|
+
const rel = path.relative(fs.realpathSync(parent), fs.realpathSync(child))
|
|
1732
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
|
|
1733
|
+
} catch {
|
|
1734
|
+
return false
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
if (!inside(invokedFrom, target.spec.worktreePath)) {
|
|
1738
|
+
judged = {
|
|
1739
|
+
state: 'unknown',
|
|
1740
|
+
reason: `this command is not running inside ${target.spec.folder}'s worktree`,
|
|
1741
|
+
gate: judged.gate,
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
if (flags.json) {
|
|
1747
|
+
process.stdout.write(
|
|
1748
|
+
JSON.stringify(
|
|
1749
|
+
{
|
|
1750
|
+
spec: target.spec ? target.spec.folder : null,
|
|
1751
|
+
state: judged.state,
|
|
1752
|
+
reason: judged.reason,
|
|
1753
|
+
armedAt: judged.gate ? judged.gate.armedAt : null,
|
|
1754
|
+
phase: judged.gate ? judged.gate.phase : null,
|
|
1755
|
+
required: config.review.required,
|
|
1756
|
+
log: judged.gate && Array.isArray(judged.gate.log) ? judged.gate.log : [],
|
|
1757
|
+
},
|
|
1758
|
+
null,
|
|
1759
|
+
2,
|
|
1760
|
+
) + '\n',
|
|
1761
|
+
)
|
|
1762
|
+
} else if (judged.state === 'armed') {
|
|
1763
|
+
const g = judged.gate
|
|
1764
|
+
process.stdout.write(
|
|
1765
|
+
`spec-env review gate: ${target.spec.folder} is awaiting a verdict` +
|
|
1766
|
+
`${g.phase ? ` (phase ${g.phase})` : ''}` +
|
|
1767
|
+
`${g.armedAt ? ` since ${String(g.armedAt).slice(0, 19)}` : ''}\n` +
|
|
1768
|
+
' read the page and send a verdict, or record why you are moving on:\n' +
|
|
1769
|
+
' skitterspec spec-env review skip "<reason>"\n',
|
|
1770
|
+
)
|
|
1771
|
+
} else if (judged.state === 'clear') {
|
|
1772
|
+
process.stdout.write(`spec-env review gate: ${target.spec.folder} owes nothing — ${judged.reason}\n`)
|
|
1773
|
+
} else {
|
|
1774
|
+
process.stdout.write(
|
|
1775
|
+
`spec-env review gate: cannot tell — ${judged.reason}.\n` +
|
|
1776
|
+
' nothing is being claimed, and nothing is blocked.\n',
|
|
1777
|
+
)
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// The exit status is the whole interface for a hook, so it is set from the
|
|
1781
|
+
// one state that is evidence and never from the two that are not.
|
|
1782
|
+
if (flags.check && judged.state === 'armed') process.exitCode = 1
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
// `review skip` — move on without a verdict, on the record.
|
|
1786
|
+
function specEnvReviewSkip(dir, config, reason, flags) {
|
|
1787
|
+
const said = String(reason || '').trim()
|
|
1788
|
+
if (!said) {
|
|
1789
|
+
// The reason IS the feature. A skip with no reason is the silence this
|
|
1790
|
+
// whole gate exists to replace, so it is refused rather than defaulted.
|
|
1791
|
+
process.stdout.write(
|
|
1792
|
+
'spec-env review skip: needs a reason — skitterspec spec-env review skip "<why>"\n',
|
|
1793
|
+
)
|
|
1794
|
+
process.exitCode = 1
|
|
1795
|
+
return
|
|
1796
|
+
}
|
|
1797
|
+
const target = gateTarget(dir, config, null)
|
|
1798
|
+
if (!target.spec) {
|
|
1799
|
+
process.stdout.write(`spec-env review skip: cannot tell which spec — ${target.reason}\n`)
|
|
1800
|
+
process.exitCode = 1
|
|
1801
|
+
return
|
|
1802
|
+
}
|
|
1803
|
+
const read = readGate(target.out, target.spec.folder)
|
|
1804
|
+
if (read.corrupt) {
|
|
1805
|
+
process.stdout.write(
|
|
1806
|
+
`spec-env review skip: ${reviewGatePath(target.out)} is not readable JSON — ` +
|
|
1807
|
+
'move it aside rather than losing the history it holds.\n',
|
|
1808
|
+
)
|
|
1809
|
+
process.exitCode = 1
|
|
1810
|
+
return
|
|
1811
|
+
}
|
|
1812
|
+
const result = disarmGate(read.gate, { at: new Date().toISOString(), by: 'skip', reason: said })
|
|
1813
|
+
if (!result.logged) {
|
|
1814
|
+
// Nothing was owed, so nothing is recorded: a log entry here would claim a
|
|
1815
|
+
// decision was taken about an obligation that did not exist.
|
|
1816
|
+
process.stdout.write(`spec-env review skip: ${target.spec.folder} owes nothing — nothing to skip\n`)
|
|
1817
|
+
return
|
|
1818
|
+
}
|
|
1819
|
+
writeGate(target.out, result.gate)
|
|
1820
|
+
if (flags.json) {
|
|
1821
|
+
process.stdout.write(JSON.stringify({ spec: target.spec.folder, skipped: true, reason: said }, null, 2) + '\n')
|
|
1822
|
+
return
|
|
1823
|
+
}
|
|
1824
|
+
process.stdout.write(
|
|
1825
|
+
`spec-env review skip: ${target.spec.folder} moved on without a verdict\n reason: ${said}\n`,
|
|
1826
|
+
)
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
async function specEnvReview(dir, config, specArg, flags) {
|
|
1830
|
+
// An unknown name throws here rather than falling back to the branch: a review
|
|
1831
|
+
// of the wrong spec looks exactly like a review of the right one.
|
|
1832
|
+
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
1833
|
+
|
|
1834
|
+
// The worktree is what we read; without it there is nothing to say. This is an
|
|
1835
|
+
// absence that means something — `git worktree list` is the same source that
|
|
1836
|
+
// resolved the path — so it is safe to act on.
|
|
1837
|
+
if (!fs.existsSync(spec.worktreePath)) {
|
|
1838
|
+
process.stdout.write(
|
|
1839
|
+
`spec-env review: ${spec.folder} has no worktree at ${spec.worktreePath} — ` +
|
|
1840
|
+
'run /spec-start to provision it.\n',
|
|
1841
|
+
)
|
|
1842
|
+
return
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
const git = rawGitReader(spec.worktreePath)
|
|
1846
|
+
const trimmed = gitReader(spec.worktreePath)
|
|
1847
|
+
|
|
1848
|
+
let mode = 'working'
|
|
1849
|
+
let ref = 'HEAD'
|
|
1850
|
+
let base = null
|
|
1851
|
+
|
|
1852
|
+
// WHICH BASE THE BRANCH VIEW MEASURES FROM. A hotfix forks its worktree from a
|
|
1853
|
+
// release tag rather than the base branch, so the range that answers "what
|
|
1854
|
+
// does this spec change" starts at that tag — `spec.baseRef`, read from the
|
|
1855
|
+
// `> **Base version:**` header, and null for every other spec type.
|
|
1856
|
+
//
|
|
1857
|
+
// Measuring a hotfix from the base branch is wrong in two ways at once: the
|
|
1858
|
+
// header says `since main`, which is not where the work started, and when the
|
|
1859
|
+
// tag is not an ancestor of the base branch (a release line that never merged
|
|
1860
|
+
// back) the range widens to include commits the hotfix never touched.
|
|
1861
|
+
//
|
|
1862
|
+
// Lazy, so the common working-tree path pays nothing for it.
|
|
1863
|
+
const reviewBase = () => spec.baseRef || resolveBaseBranch(config, trimmed)
|
|
1864
|
+
|
|
1865
|
+
if (flags.branch) {
|
|
1866
|
+
base = reviewBase()
|
|
1867
|
+
const mergeBase = trimmed(['merge-base', base, 'HEAD'])
|
|
1868
|
+
// Cannot tell → do nothing. A missing merge-base means the branch and the
|
|
1869
|
+
// base share no history (a fresh repo, an unfetched base); diffing against
|
|
1870
|
+
// the base tip anyway would report every file in the project as changed.
|
|
1871
|
+
if (!mergeBase) {
|
|
1872
|
+
process.stdout.write(
|
|
1873
|
+
`spec-env review: no merge-base between ${base} and ${spec.branch} — ` +
|
|
1874
|
+
'cannot compute the branch range (fetch the base branch?).\n',
|
|
1875
|
+
)
|
|
1876
|
+
return
|
|
1877
|
+
}
|
|
1878
|
+
ref = mergeBase
|
|
1879
|
+
mode = 'branch'
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// The sidecar is keyed to the page's path, so resolve that first — `--out`
|
|
1883
|
+
// moves both together.
|
|
1884
|
+
const out = reviewOutPath(dir, spec.folder, flags.out)
|
|
1885
|
+
const stored = readNotes(out, spec.folder)
|
|
1886
|
+
let notes = stored.notes
|
|
1887
|
+
let merged = null
|
|
1888
|
+
let sentVerdict = null
|
|
1889
|
+
let claimed = null
|
|
1890
|
+
|
|
1891
|
+
// `--claim-since <iso>` resolves to a code and then joins the ordinary claim
|
|
1892
|
+
// path below. THE ENGINE PICKS SO THE AGENT DOES NOT: an agent left to find
|
|
1893
|
+
// "the pass that just arrived" reads the store and chooses, and the rule that
|
|
1894
|
+
// it must not choose becomes a request. Here the window is the only input, and
|
|
1895
|
+
// an answer of anything but exactly one pass acts on nothing.
|
|
1896
|
+
let claimCode = flags.claim
|
|
1897
|
+
if (!claimCode && flags.claimSince) {
|
|
1898
|
+
const heldRead = readPending(out, spec.folder)
|
|
1899
|
+
if (heldRead.corrupt) {
|
|
1900
|
+
process.stdout.write(
|
|
1901
|
+
`spec-env review: ${reviewPendingPath(out)} is not readable JSON — ` +
|
|
1902
|
+
'move it aside rather than losing the passes it holds.\n',
|
|
1903
|
+
)
|
|
1904
|
+
return
|
|
1905
|
+
}
|
|
1906
|
+
const window = passesSince(heldRead.pending, flags.claimSince)
|
|
1907
|
+
if (!window.usable) {
|
|
1908
|
+
process.stdout.write(
|
|
1909
|
+
`spec-env review: --claim-since ${flags.claimSince} is not a timestamp — nothing claimed\n`,
|
|
1910
|
+
)
|
|
1911
|
+
return
|
|
1912
|
+
}
|
|
1913
|
+
if (window.codes.length === 0) {
|
|
1914
|
+
process.stdout.write(
|
|
1915
|
+
`spec-env review: no pass has arrived since ${flags.claimSince} — nothing claimed\n`,
|
|
1916
|
+
)
|
|
1917
|
+
return
|
|
1918
|
+
}
|
|
1919
|
+
if (window.codes.length > 1) {
|
|
1920
|
+
// Names the count, never the codes — the same silence a wrong `--claim`
|
|
1921
|
+
// keeps, for the same reason. The operator has them; the page prints them.
|
|
1922
|
+
process.stdout.write(
|
|
1923
|
+
`spec-env review: ${window.codes.length} passes arrived in that window — ` +
|
|
1924
|
+
'claim one by its code rather than guessing between them.\n',
|
|
1925
|
+
)
|
|
1926
|
+
return
|
|
1927
|
+
}
|
|
1928
|
+
claimCode = window.codes[0]
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// A CLAIM IS A DELIVERY MECHANISM, not a second kind of review. It lifts a
|
|
1932
|
+
// pass out of the holding area and hands it to exactly the same merge a
|
|
1933
|
+
// pasted blob goes through, so nothing downstream can tell — or behave
|
|
1934
|
+
// differently — by how the pass arrived.
|
|
1935
|
+
if (claimCode) {
|
|
1936
|
+
const heldRead = readPending(out, spec.folder)
|
|
1937
|
+
if (heldRead.corrupt) {
|
|
1938
|
+
// Same rule as the notes sidecar: a file we cannot parse is not "nothing
|
|
1939
|
+
// pending", and claiming against it must refuse rather than find nothing.
|
|
1940
|
+
process.stdout.write(
|
|
1941
|
+
`spec-env review: ${reviewPendingPath(out)} is not readable JSON — ` +
|
|
1942
|
+
'move it aside rather than losing the passes it holds.\n',
|
|
1943
|
+
)
|
|
1944
|
+
return
|
|
1945
|
+
}
|
|
1946
|
+
const result = claimPending(heldRead.pending, String(claimCode).trim())
|
|
1947
|
+
if (!result.pass) {
|
|
1948
|
+
// NO FALLBACK, EVER. Not "the only one", not "the most recent" — either
|
|
1949
|
+
// would let a pass nobody read out reach the review, which is the whole
|
|
1950
|
+
// thing the code exists to prevent. And it names nothing: listing the
|
|
1951
|
+
// pending codes would hand a guesser the answer.
|
|
1952
|
+
process.stdout.write(
|
|
1953
|
+
`spec-env review: no pending pass with that code` +
|
|
1954
|
+
`${result.count ? ` (${result.count} waiting)` : ''}\n`,
|
|
1955
|
+
)
|
|
1956
|
+
return
|
|
1957
|
+
}
|
|
1958
|
+
// Validated on the way in as well as on the way out. The blob has been
|
|
1959
|
+
// through a socket and sat on disk, so it is untrusted input twice over.
|
|
1960
|
+
let parsed
|
|
1961
|
+
try {
|
|
1962
|
+
parsed = validateNotesBlob(result.pass.blob, spec.folder)
|
|
1963
|
+
} catch (err) {
|
|
1964
|
+
process.stdout.write(`spec-env review: ${err.message}\n`)
|
|
1965
|
+
return
|
|
1966
|
+
}
|
|
1967
|
+
notes = mergeNotes(notes, parsed, new Date().toISOString())
|
|
1968
|
+
writeNotes(out, notes)
|
|
1969
|
+
// Spent by the claim, so the same code cannot be claimed twice.
|
|
1970
|
+
writePending(out, result.pending)
|
|
1971
|
+
sentVerdict = parsed.verdict
|
|
1972
|
+
claimed = {
|
|
1973
|
+
code: result.pass.code,
|
|
1974
|
+
at: result.pass.at || null,
|
|
1975
|
+
remaining: result.count,
|
|
1976
|
+
accepted: parsed.accepted.length,
|
|
1977
|
+
unaccepted: parsed.unaccepted.length,
|
|
1978
|
+
comments: parsed.comments.length,
|
|
1979
|
+
}
|
|
1980
|
+
merged = { accepted: claimed.accepted, unaccepted: claimed.unaccepted, comments: claimed.comments }
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// The other half of a confirmation: a pass the operator says is not theirs.
|
|
1984
|
+
// Left in the store it is reported on every render until they stop reading the
|
|
1985
|
+
// line — which is how the real one gets waved away too.
|
|
1986
|
+
let dropped = null
|
|
1987
|
+
if (flags.drop) {
|
|
1988
|
+
const heldRead = readPending(out, spec.folder)
|
|
1989
|
+
if (heldRead.corrupt) {
|
|
1990
|
+
process.stdout.write(
|
|
1991
|
+
`spec-env review: ${reviewPendingPath(out)} is not readable JSON — ` +
|
|
1992
|
+
'move it aside rather than losing the passes it holds.\n',
|
|
1993
|
+
)
|
|
1994
|
+
return
|
|
1995
|
+
}
|
|
1996
|
+
// Same match and the SAME SILENCE as a claim. A drop that listed the codes
|
|
1997
|
+
// it could not find would hand a guesser exactly what the claim withholds.
|
|
1998
|
+
const result = claimPending(heldRead.pending, String(flags.drop).trim())
|
|
1999
|
+
if (!result.pass) {
|
|
2000
|
+
process.stdout.write(
|
|
2001
|
+
`spec-env review: no pending pass with that code` +
|
|
2002
|
+
`${result.count ? ` (${result.count} waiting)` : ''}\n`,
|
|
2003
|
+
)
|
|
2004
|
+
return
|
|
2005
|
+
}
|
|
2006
|
+
writePending(out, result.pending)
|
|
2007
|
+
// Nothing is merged, and the notes sidecar is not touched.
|
|
2008
|
+
dropped = { code: result.pass.code, remaining: result.count }
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
if (flags.notes) {
|
|
2012
|
+
// Refuse rather than write over notes we could not read: an unreadable
|
|
2013
|
+
// sidecar is a whole review pass, and overwriting it is unrecoverable.
|
|
2014
|
+
if (stored.corrupt) {
|
|
2015
|
+
process.stdout.write(
|
|
2016
|
+
`spec-env review: ${reviewNotesPath(out)} is not readable JSON — ` +
|
|
2017
|
+
'move it aside and re-paste, rather than losing what it holds.\n',
|
|
2018
|
+
)
|
|
2019
|
+
return
|
|
2020
|
+
}
|
|
2021
|
+
let blob
|
|
2022
|
+
try {
|
|
2023
|
+
blob = JSON.parse(fs.readFileSync(path.resolve(flags.notes), 'utf8'))
|
|
2024
|
+
} catch (err) {
|
|
2025
|
+
process.stdout.write(`spec-env review: notes blob: not valid JSON (${err.message})\n`)
|
|
2026
|
+
return
|
|
2027
|
+
}
|
|
2028
|
+
let parsed
|
|
2029
|
+
try {
|
|
2030
|
+
parsed = validateNotesBlob(blob, spec.folder)
|
|
2031
|
+
} catch (err) {
|
|
2032
|
+
// Nothing has been written at this point, and nothing will be.
|
|
2033
|
+
process.stdout.write(`spec-env review: ${err.message}\n`)
|
|
2034
|
+
return
|
|
2035
|
+
}
|
|
2036
|
+
// MERGED BEFORE THE VERDICT IS JUDGED, and written either way. The notes
|
|
2037
|
+
// are good work even when the verdict that came with them is one we cannot
|
|
2038
|
+
// honour; dropping them would punish the mistake twice, and the reader
|
|
2039
|
+
// would have to re-read the diff to write them again.
|
|
2040
|
+
notes = mergeNotes(notes, parsed, new Date().toISOString())
|
|
2041
|
+
writeNotes(out, notes)
|
|
2042
|
+
sentVerdict = parsed.verdict
|
|
2043
|
+
merged = {
|
|
2044
|
+
accepted: parsed.accepted.length,
|
|
2045
|
+
unaccepted: parsed.unaccepted.length,
|
|
2046
|
+
comments: parsed.comments.length,
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
let resolvedNow = null
|
|
2051
|
+
if (flags.resolve) {
|
|
2052
|
+
if (stored.corrupt) {
|
|
2053
|
+
process.stdout.write(
|
|
2054
|
+
`spec-env review: ${reviewNotesPath(out)} is not readable JSON — ` +
|
|
2055
|
+
'move it aside and re-paste, rather than losing what it holds.\n',
|
|
2056
|
+
)
|
|
2057
|
+
return
|
|
2058
|
+
}
|
|
2059
|
+
// Nothing recorded means every id would be unknown. Say that once, rather
|
|
2060
|
+
// than listing every id back as a mistake, and write no sidecar for it.
|
|
2061
|
+
if (!stored.present && !flags.notes) {
|
|
2062
|
+
process.stdout.write(
|
|
2063
|
+
`spec-env review: no notes recorded for ${spec.folder} — nothing to resolve.\n`,
|
|
2064
|
+
)
|
|
2065
|
+
return
|
|
2066
|
+
}
|
|
2067
|
+
let list
|
|
2068
|
+
try {
|
|
2069
|
+
list = validateResolutions(JSON.parse(fs.readFileSync(path.resolve(flags.resolve), 'utf8')))
|
|
2070
|
+
} catch (err) {
|
|
2071
|
+
process.stdout.write(`spec-env review: ${err.message}\n`)
|
|
2072
|
+
return
|
|
2073
|
+
}
|
|
2074
|
+
const result = applyResolutions(notes, list, new Date().toISOString())
|
|
2075
|
+
notes = result.notes
|
|
2076
|
+
writeNotes(out, notes)
|
|
2077
|
+
resolvedNow = { applied: result.applied, unknown: result.unknown }
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
// AFTER the merge and AFTER any resolutions, so the count the approval is
|
|
2081
|
+
// judged against is the one that is true now. A note raised and answered in
|
|
2082
|
+
// the same invocation is not an open note.
|
|
2083
|
+
//
|
|
2084
|
+
// Said nothing about when no verdict was sent: a blob without one behaves as
|
|
2085
|
+
// it always did, and gains no key in either output.
|
|
2086
|
+
let verdictReport = null
|
|
2087
|
+
if (sentVerdict) {
|
|
2088
|
+
const judged = judgeVerdict(sentVerdict, notes)
|
|
2089
|
+
if (judged.honoured) {
|
|
2090
|
+
// The log is appended only for a verdict that was ACTED ON. A refused
|
|
2091
|
+
// approval did not happen, and recording it as history would leave a
|
|
2092
|
+
// trail of decisions the repo never took.
|
|
2093
|
+
notes = appendDecision(notes, { verdict: judged.effective, at: new Date().toISOString() })
|
|
2094
|
+
writeNotes(out, notes)
|
|
2095
|
+
}
|
|
2096
|
+
verdictReport = {
|
|
2097
|
+
sent: judged.sent,
|
|
2098
|
+
effective: judged.effective,
|
|
2099
|
+
honoured: judged.honoured,
|
|
2100
|
+
reason: judged.reason,
|
|
2101
|
+
// `openCount` rather than the bare word: a dotted property of that name
|
|
2102
|
+
// is what the removed opener used, and `assets-spec-start-one-path`
|
|
2103
|
+
// guards the engine against it coming back under any spelling.
|
|
2104
|
+
openCount: judged.openCount,
|
|
2105
|
+
openFiles: judged.openFiles,
|
|
2106
|
+
// Named here so the skill that routes on the verdict does not have to
|
|
2107
|
+
// read the config itself — one answer, from the engine that owns it.
|
|
2108
|
+
commitWith: config.review.commitWith,
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
// A COMMITTING verdict is what the gate was waiting for, so it clears it —
|
|
2112
|
+
// and only it. `changes` leaves the gate armed deliberately: the work
|
|
2113
|
+
// happens, the page re-renders, and the next verdict is the exit. `discuss`
|
|
2114
|
+
// likewise, including a REFUSED commit, which did not happen and must not
|
|
2115
|
+
// clear an obligation on the strength of having been asked for.
|
|
2116
|
+
if (judged.honoured && COMMITTING.includes(judged.effective)) {
|
|
2117
|
+
const gateRead = readGate(out, spec.folder)
|
|
2118
|
+
if (!gateRead.corrupt) {
|
|
2119
|
+
const result = disarmGate(gateRead.gate, {
|
|
2120
|
+
at: new Date().toISOString(),
|
|
2121
|
+
by: 'verdict',
|
|
2122
|
+
reason: judged.effective,
|
|
2123
|
+
})
|
|
2124
|
+
if (result.logged) {
|
|
2125
|
+
writeGate(out, result.gate)
|
|
2126
|
+
verdictReport.gateCleared = true
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
// A corrupt gate is left exactly as it is. It already reads as
|
|
2130
|
+
// cannot-tell everywhere, so it refuses nothing — there is no obligation
|
|
2131
|
+
// to clear, and writing over it would lose the log it holds.
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
// What the last decision PRODUCED — written after the thing it asked for has
|
|
2136
|
+
// happened, which is why it is a separate invocation rather than part of the
|
|
2137
|
+
// verdict above. Nothing reads it back; it is history for the page to show.
|
|
2138
|
+
let outcomeSaid = null
|
|
2139
|
+
if (flags.outcome) {
|
|
2140
|
+
const result = annotateLastDecision(notes, flags.outcome)
|
|
2141
|
+
if (result.annotated) {
|
|
2142
|
+
notes = result.notes
|
|
2143
|
+
writeNotes(out, notes)
|
|
2144
|
+
outcomeSaid = flags.outcome
|
|
2145
|
+
} else {
|
|
2146
|
+
// Says so rather than inventing a decision to hang it on. An outcome with
|
|
2147
|
+
// no decision behind it is a record of something nobody chose.
|
|
2148
|
+
process.stdout.write('spec-env review: no decision to record an outcome against — ignored\n')
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// Information, never a prompt. Nothing counts these to decide anything and
|
|
2153
|
+
// nothing refuses over them — the same rule the marks have lived under since
|
|
2154
|
+
// `feat-review-round-trip`.
|
|
2155
|
+
//
|
|
2156
|
+
// DESCRIBED, not counted. The code is here so a skill can name it without
|
|
2157
|
+
// opening the store: an agent that still has to read `.pending.json` to find
|
|
2158
|
+
// a code will read it, and the rule against claiming unasked becomes a
|
|
2159
|
+
// request rather than a discipline.
|
|
2160
|
+
const waiting = describePending(readPending(out, spec.folder).pending)
|
|
2161
|
+
|
|
2162
|
+
// Read AFTER the verdict half above, so a claim that just cleared the gate
|
|
2163
|
+
// renders as cleared rather than as still owing. Corrupt contributes nothing:
|
|
2164
|
+
// the page is a convenience and the gate is not what it is for.
|
|
2165
|
+
const gateNow = readGate(out, spec.folder)
|
|
2166
|
+
const gate = gateNow.corrupt ? null : gateNow.gate
|
|
2167
|
+
|
|
2168
|
+
const now = new Date().toISOString()
|
|
2169
|
+
let data = collectReview({ spec, git, mode, ref, base, now, notes, gate })
|
|
2170
|
+
|
|
2171
|
+
// A CLEAN WORKING TREE IS NOT "NOTHING TO REVIEW". It is the state a phase
|
|
2172
|
+
// ends in: the page is rendered before the commit, the commit happens
|
|
2173
|
+
// immediately after, and from then on the working view is empty for the rest
|
|
2174
|
+
// of the spec's life. Falling back to the branch range is what keeps the page
|
|
2175
|
+
// answering after that commit.
|
|
2176
|
+
//
|
|
2177
|
+
// What could fool this: a *fresh* branch is clean too, and its branch range is
|
|
2178
|
+
// empty as well. That costs nothing, because the fallback is kept only when it
|
|
2179
|
+
// actually found something — so a spec with no work at all prints exactly what
|
|
2180
|
+
// it printed before any of this existed.
|
|
2181
|
+
//
|
|
2182
|
+
// An explicit `--branch` is never re-interpreted, and a non-empty working tree
|
|
2183
|
+
// is never swapped out from under the reader. The swap only ever replaces an
|
|
2184
|
+
// empty view, so no information is lost by it.
|
|
2185
|
+
let fellBack = false
|
|
2186
|
+
if (!flags.branch && data.totals.files === 0) {
|
|
2187
|
+
const fallbackBase = reviewBase()
|
|
2188
|
+
const mergeBase = trimmed(['merge-base', fallbackBase, 'HEAD'])
|
|
2189
|
+
// Cannot tell -> do nothing, exactly as the `--branch` path refuses. No
|
|
2190
|
+
// merge-base means base and HEAD share no history, and diffing against the
|
|
2191
|
+
// base tip would report every file in the project as changed.
|
|
2192
|
+
if (mergeBase) {
|
|
2193
|
+
const wider = collectReview({
|
|
2194
|
+
spec,
|
|
2195
|
+
git,
|
|
2196
|
+
mode: 'branch',
|
|
2197
|
+
ref: mergeBase,
|
|
2198
|
+
base: fallbackBase,
|
|
2199
|
+
now,
|
|
2200
|
+
notes,
|
|
2201
|
+
gate,
|
|
2202
|
+
fellBack: true,
|
|
2203
|
+
})
|
|
2204
|
+
if (wider.totals.files > 0) {
|
|
2205
|
+
data = wider
|
|
2206
|
+
mode = 'branch'
|
|
2207
|
+
base = fallbackBase
|
|
2208
|
+
ref = mergeBase
|
|
2209
|
+
fellBack = true
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
// The written review is the model's half, and it arrives as JSON so no prose
|
|
2215
|
+
// ever has to round-trip through markup. Absent → the page renders without it.
|
|
2216
|
+
if (flags.review) {
|
|
2217
|
+
const raw = fs.readFileSync(path.resolve(flags.review), 'utf8')
|
|
2218
|
+
data.review = JSON.parse(raw)
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
writeReviewPage(out, renderReviewPage(data, { reviewHtml: renderReviewBlock(data.review) }))
|
|
2222
|
+
|
|
2223
|
+
// The publish-ready copy, ONLY when asked. An ordinary render must not pay for
|
|
2224
|
+
// a second copy of the whole diff on disk for a path most renders never take.
|
|
2225
|
+
let publishCopy = null
|
|
2226
|
+
if (flags.publishCopy) {
|
|
2227
|
+
publishCopy = reviewPublishPath(out)
|
|
2228
|
+
writeReviewPage(
|
|
2229
|
+
publishCopy,
|
|
2230
|
+
renderReviewFragment(data, { reviewHtml: renderReviewBlock(data.review) }),
|
|
2231
|
+
)
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
// Read, never written, and never interpreted: the engine cannot publish, and
|
|
2235
|
+
// names this file only so the skill that can never has to build a path.
|
|
2236
|
+
const urlFile = reviewUrlPath(out)
|
|
2237
|
+
const url = readReviewUrl(out)
|
|
2238
|
+
|
|
2239
|
+
// Resolved before the --json early return, so both outputs agree.
|
|
2240
|
+
const reader = resolveReader(config, process.env)
|
|
2241
|
+
|
|
2242
|
+
// A remote reader cannot open a path on this machine — that is the whole of
|
|
2243
|
+
// what detection established. Serving is how the engine answers it: a local
|
|
2244
|
+
// process, ended by one flag, leaving nothing behind. PUBLISHING is still
|
|
2245
|
+
// never automatic here; it leaves a page this tooling cannot remove, so it
|
|
2246
|
+
// stays an explicit ask no detection can stand in for.
|
|
2247
|
+
let served = null
|
|
2248
|
+
// What to say about the server, if anything. `current` and `unknown` say
|
|
2249
|
+
// nothing at all: the ordinary render must read exactly as it did before any
|
|
2250
|
+
// of this existed.
|
|
2251
|
+
let serverSaid = null
|
|
2252
|
+
if (reader.reader === 'remote' && config.review.serveOnRemote) {
|
|
2253
|
+
const up = await ensureReviewServer(dir, config, { host: '0.0.0.0' })
|
|
2254
|
+
if (up.replaced === 'engine') {
|
|
2255
|
+
serverSaid = up.error
|
|
2256
|
+
? // BOTH facts. A reader told only "could not start" cannot see why it
|
|
2257
|
+
// was trying, and "it is running an old engine and I could not replace
|
|
2258
|
+
// it" is the pair that explains the page they are about to open.
|
|
2259
|
+
`the server was running engine ${up.engineWas} and could not be replaced (${up.error}) — ` +
|
|
2260
|
+
`its pages are drawn by that engine`
|
|
2261
|
+
: `the server was running engine ${up.engineWas}; restarted on ${up.engineIs}`
|
|
2262
|
+
}
|
|
2263
|
+
if (!up.error) {
|
|
2264
|
+
// The URLs come from the bind the server HAS, not the one asked for just
|
|
2265
|
+
// above — adoption can hand back a loopback server whatever was
|
|
2266
|
+
// requested. See `reviewServedUrls`.
|
|
2267
|
+
const urls = reviewServedUrls(up, lanAddresses(), spec.folder)
|
|
2268
|
+
if (urls) {
|
|
2269
|
+
served = { ...urls, port: up.port, token: up.token, started: up.started }
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
if (flags.json) {
|
|
2275
|
+
process.stdout.write(
|
|
2276
|
+
JSON.stringify(
|
|
2277
|
+
{
|
|
2278
|
+
spec: spec.folder,
|
|
2279
|
+
branch: spec.branch,
|
|
2280
|
+
worktree: spec.worktreePath,
|
|
2281
|
+
mode,
|
|
2282
|
+
base,
|
|
2283
|
+
fellBack,
|
|
2284
|
+
out,
|
|
2285
|
+
publishCopy,
|
|
2286
|
+
reader: reader.reader,
|
|
2287
|
+
readerWhy: reader.why,
|
|
2288
|
+
served,
|
|
2289
|
+
...(serverSaid ? { server: serverSaid } : {}),
|
|
2290
|
+
fileUrl: reviewFileUrl(out),
|
|
2291
|
+
urlFile,
|
|
2292
|
+
url,
|
|
2293
|
+
notesFile: reviewNotesPath(out),
|
|
2294
|
+
reviewed: Boolean(data.review),
|
|
2295
|
+
totals: data.totals,
|
|
2296
|
+
notes: data.notes,
|
|
2297
|
+
merged,
|
|
2298
|
+
resolved: resolvedNow,
|
|
2299
|
+
...(verdictReport ? { verdict: verdictReport } : {}),
|
|
2300
|
+
...(outcomeSaid ? { outcome: outcomeSaid } : {}),
|
|
2301
|
+
...(claimed ? { claimed } : {}),
|
|
2302
|
+
...(dropped ? { dropped } : {}),
|
|
2303
|
+
...(waiting.length ? { pending: waiting } : {}),
|
|
2304
|
+
files: data.files.map((f) => ({
|
|
2305
|
+
path: f.path,
|
|
2306
|
+
status: f.status,
|
|
2307
|
+
additions: f.additions,
|
|
2308
|
+
deletions: f.deletions,
|
|
2309
|
+
whole: f.whole,
|
|
2310
|
+
noise: f.noise,
|
|
2311
|
+
hash: f.hash,
|
|
2312
|
+
accepted: f.accepted,
|
|
2313
|
+
acceptedAt: f.acceptedAt,
|
|
2314
|
+
comments: f.comments,
|
|
2315
|
+
})),
|
|
2316
|
+
},
|
|
2317
|
+
null,
|
|
2318
|
+
2,
|
|
2319
|
+
) + '\n',
|
|
2320
|
+
)
|
|
2321
|
+
return
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
const t = data.totals
|
|
2325
|
+
const n = data.notes.totals
|
|
2326
|
+
const hasNotes = n.accepted + n.lapsed + n.unresolved + n.resolved > 0
|
|
2327
|
+
process.stdout.write(
|
|
2328
|
+
`spec-env review: ${spec.folder} (${
|
|
2329
|
+
fellBack ? `working tree clean — since ${base}` : mode === 'branch' ? `since ${base}` : 'uncommitted'
|
|
2330
|
+
})\n` +
|
|
2331
|
+
` ${t.files} file${t.files === 1 ? '' : 's'}, +${t.additions} -${t.deletions}\n` +
|
|
2332
|
+
(merged
|
|
2333
|
+
? ` merged: ${merged.accepted} accept${merged.accepted === 1 ? '' : 's'}, ` +
|
|
2334
|
+
`${merged.unaccepted} withdrawn, ${merged.comments} comment${merged.comments === 1 ? '' : 's'}\n`
|
|
2335
|
+
: '') +
|
|
2336
|
+
(resolvedNow
|
|
2337
|
+
? ` resolved: ${resolvedNow.applied} comment${resolvedNow.applied === 1 ? '' : 's'}` +
|
|
2338
|
+
(resolvedNow.unknown.length
|
|
2339
|
+
? ` (skipped ${resolvedNow.unknown.length} unknown id: ${resolvedNow.unknown.join(', ')})`
|
|
2340
|
+
: '') +
|
|
2341
|
+
'\n'
|
|
2342
|
+
: '') +
|
|
2343
|
+
// The verdict is said ONCE, on the line that already carries the review
|
|
2344
|
+
// state. An approve of a clean read has no counts to print, so the line
|
|
2345
|
+
// appears for a verdict too — but never for a blob that carried none.
|
|
2346
|
+
(hasNotes || verdictReport
|
|
2347
|
+
? ` notes: ${n.accepted} accepted · ${n.lapsed} lapsed · ` +
|
|
2348
|
+
`${n.unresolved} open · ${n.resolved} resolved` +
|
|
2349
|
+
(verdictReport ? ` · ${verdictSaid(verdictReport)}` : '') +
|
|
2350
|
+
'\n'
|
|
2351
|
+
: '') +
|
|
2352
|
+
(claimed
|
|
2353
|
+
? ` claimed: ${claimed.code} — ${claimed.accepted} accept${claimed.accepted === 1 ? '' : 's'}, ` +
|
|
2354
|
+
`${claimed.unaccepted} withdrawn, ${claimed.comments} comment${claimed.comments === 1 ? '' : 's'}\n`
|
|
2355
|
+
: '') +
|
|
2356
|
+
(dropped ? ` dropped: ${dropped.code} — merged nothing\n` : '') +
|
|
2357
|
+
(waiting.length
|
|
2358
|
+
? ` pending: ${waiting.length} waiting\n` +
|
|
2359
|
+
waiting
|
|
2360
|
+
.map((p) => ` ${p.code} · ${p.verdict || 'no verdict'} · ${pendingAge(p.at, now)}\n`)
|
|
2361
|
+
.join('')
|
|
2362
|
+
: '') +
|
|
2363
|
+
(outcomeSaid ? ` outcome: ${outcomeSaid}\n` : '') +
|
|
2364
|
+
// Said only when it is true, so a review with no sidecar reads exactly as
|
|
2365
|
+
// it did before any of this existed.
|
|
2366
|
+
(stored.corrupt && !flags.notes
|
|
2367
|
+
? ` notes: ${reviewNotesPath(out)} is not readable JSON — ignored, not overwritten\n`
|
|
2368
|
+
: '') +
|
|
2369
|
+
// Said only when there is something to say. `unknown` is the ordinary
|
|
2370
|
+
// state on a local machine, and announcing it would be noise about a
|
|
2371
|
+
// healthy session.
|
|
2372
|
+
(reader.reader === 'unknown'
|
|
2373
|
+
? ''
|
|
2374
|
+
: ` reader: ${reader.reader}${reader.why ? ` (${reader.why})` : ''}\n`) +
|
|
2375
|
+
` page: ${out}\n` +
|
|
2376
|
+
// Served: the `open:` line is a URL the reader can actually use, and
|
|
2377
|
+
// `page:` above still says where the file is. Not served — including every
|
|
2378
|
+
// way serving can fail — falls back to exactly the output this printed
|
|
2379
|
+
// before, dead link and all: that is the floor, never made worse.
|
|
2380
|
+
(served
|
|
2381
|
+
? ` open: ${served.url}\n` +
|
|
2382
|
+
// Said only when there is a runner-up. One address is not a choice,
|
|
2383
|
+
// and an `also:` line naming nothing reads as a warning.
|
|
2384
|
+
(served.alternates.length
|
|
2385
|
+
? served.alternates.map((u) => ` also: ${u}\n`).join('')
|
|
2386
|
+
: '') +
|
|
2387
|
+
// A loopback server is reachable from this machine and nowhere else.
|
|
2388
|
+
// Said here rather than left to be discovered by a phone that cannot
|
|
2389
|
+
// open the URL — and it names the command instead of describing it.
|
|
2390
|
+
(served.loopback
|
|
2391
|
+
? ' local only: this server is bound to 127.0.0.1 — not reachable from your phone.\n' +
|
|
2392
|
+
` widen: ${served.widen}\n`
|
|
2393
|
+
: '') +
|
|
2394
|
+
(served.started && !served.loopback
|
|
2395
|
+
? ' serving: every provisioned spec, to anyone with this URL on your network.\n' +
|
|
2396
|
+
' stop: skitterspec spec-env review serve --stop\n'
|
|
2397
|
+
: '') +
|
|
2398
|
+
// One line, and only when something was actually done on the reader's
|
|
2399
|
+
// behalf. An action nobody asked for is reported, not hidden — the
|
|
2400
|
+
// same rule teardown follows.
|
|
2401
|
+
(serverSaid ? ` ${serverSaid}\n` : '')
|
|
2402
|
+
: ` open: ${reviewFileUrl(out)}${
|
|
2403
|
+
reader.reader === 'remote' ? ' (will not open where you are reading)' : ''
|
|
2404
|
+
}\n` +
|
|
2405
|
+
(serverSaid ? ` ${serverSaid}\n` : '') +
|
|
2406
|
+
(reader.reader === 'remote'
|
|
2407
|
+
? ' serve: skitterspec spec-env review serve --host 0.0.0.0\n'
|
|
2408
|
+
: '')) +
|
|
2409
|
+
// Named on its own line so the skill never has to build the path itself.
|
|
2410
|
+
(publishCopy ? ` publish: ${publishCopy}\n` : '') +
|
|
2411
|
+
(url ? ` published: ${url}\n` : '') +
|
|
2412
|
+
(t.files === 0 ? ' nothing to review — no changes found.\n' : ''),
|
|
2413
|
+
)
|
|
2414
|
+
}
|
|
2415
|
+
|
|
1288
2416
|
// Start/stop a spec's host dev servers on its reserved port block. Host dev
|
|
1289
2417
|
// servers (e.g. `pnpm dev`) need a block even on a worktree-only spec, so `up`
|
|
1290
2418
|
// allocates a slot if the spec has none (idempotent). The planner is pure
|
|
@@ -1365,6 +2493,441 @@ function proxyProcFor(config, routesFileAbs) {
|
|
|
1365
2493
|
}
|
|
1366
2494
|
}
|
|
1367
2495
|
|
|
2496
|
+
// Where a checkout keeps its copy of the daemon, relative to its root: this
|
|
2497
|
+
// monorepo developing itself, and a project that installed the package. Naming
|
|
2498
|
+
// the distribution here is a path, not provider machinery — `init.js` and
|
|
2499
|
+
// `PROVIDER_COMMANDS` above already know package names by name.
|
|
2500
|
+
const DAEMON_LOCATIONS = [
|
|
2501
|
+
path.join('node_modules', '@skitterbyte', 'skitterspec', 'src', 'env', 'serve.js'),
|
|
2502
|
+
path.join('packages', 'common', 'src', 'env', 'serve.js'),
|
|
2503
|
+
]
|
|
2504
|
+
|
|
2505
|
+
/**
|
|
2506
|
+
* The `serve.js` the daemon should actually execute.
|
|
2507
|
+
*
|
|
2508
|
+
* NOT `__dirname` — that is the module directory of whichever copy of the CLI
|
|
2509
|
+
* is running, and running one from a worktree pinned the daemon to that
|
|
2510
|
+
* worktree's tree (its `review.js` then resolves the page template into the
|
|
2511
|
+
* worktree's `assets/`). Teardown removed the worktree, the daemon carried on
|
|
2512
|
+
* answering on its port, and every render failed with ENOENT for every spec.
|
|
2513
|
+
*
|
|
2514
|
+
* `root` is the primary checkout, which every spec-env command has already
|
|
2515
|
+
* resolved. A copy found there outlives every worktree, which is the whole
|
|
2516
|
+
* point. Three states, and the third is the common one — a global install or
|
|
2517
|
+
* `npx` has no copy under the checkout at all — so it falls back to the running
|
|
2518
|
+
* module rather than refusing to serve. Being wrong there costs exactly what
|
|
2519
|
+
* happens today; refusing would cost a feature.
|
|
2520
|
+
*/
|
|
2521
|
+
function daemonScript(root) {
|
|
2522
|
+
if (root) {
|
|
2523
|
+
for (const rel of DAEMON_LOCATIONS) {
|
|
2524
|
+
const candidate = path.join(root, rel)
|
|
2525
|
+
if (fs.existsSync(candidate)) return candidate
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
return path.join(__dirname, 'env', 'serve.js')
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
/**
|
|
2532
|
+
* Is a running server's recorded script still on disk?
|
|
2533
|
+
*
|
|
2534
|
+
* A POSITIVE SIGNAL, and the one adoption was missing: a live pid and a
|
|
2535
|
+
* readable settings file were treated as proof the server works, and neither
|
|
2536
|
+
* can see that the code the process is executing has been deleted.
|
|
2537
|
+
*
|
|
2538
|
+
* WHAT WOULD FOOL THIS: a settings file written before `script` was recorded
|
|
2539
|
+
* has no key to check. That absence is not evidence — it describes every
|
|
2540
|
+
* healthy server started by an older build — so it adopts as before. The
|
|
2541
|
+
* destructive reading would kill a working server over a key it never had
|
|
2542
|
+
* (`.claude/rules/negative-checks.md` rule 4).
|
|
2543
|
+
*/
|
|
2544
|
+
function serverScriptOk(settings) {
|
|
2545
|
+
const script = settings && settings.script
|
|
2546
|
+
if (!script) return true
|
|
2547
|
+
return fs.existsSync(script)
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
// The supervised review-server process descriptor. Same shape as the proxy's:
|
|
2551
|
+
// a tiny detached node process reading its settings from a file, so a restart is
|
|
2552
|
+
// a rewrite of that file rather than an argv change.
|
|
2553
|
+
function serveProcFor(config, settingsFileAbs, { root = null } = {}) {
|
|
2554
|
+
const sdir = stateDirLabel(config)
|
|
2555
|
+
return {
|
|
2556
|
+
name: 'review-serve',
|
|
2557
|
+
command: `node ${daemonScript(root)} ${settingsFileAbs}`,
|
|
2558
|
+
script: daemonScript(root),
|
|
2559
|
+
env: {},
|
|
2560
|
+
logFile: `${sdir}/logs/review-serve.log`,
|
|
2561
|
+
pidFile: `${sdir}/pids/review-serve.pid`,
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
// Interface names that mean "a network the reader's phone is not on". On the
|
|
2566
|
+
// machine this was written for, `bridge100`/`bridge101` are Parallels and `en0`
|
|
2567
|
+
// is the wifi the phone shares — so the real address is neither first nor last
|
|
2568
|
+
// in `networkInterfaces()` order, and order alone is a coin toss.
|
|
2569
|
+
//
|
|
2570
|
+
// WHAT WOULD FOOL THIS: it reads interface NAMES, so a VPN on a renamed adapter,
|
|
2571
|
+
// an unusual driver, or a platform that names things differently all rank wrong.
|
|
2572
|
+
// That is exactly why the runners-up are printed rather than discarded — a bad
|
|
2573
|
+
// guess costs a glance, not a dead end.
|
|
2574
|
+
const VIRTUAL_IFACE = /^(bridge|vmnet|vnic|vboxnet|docker|utun|tap|tun|veth|ppp|awdl|llw)/i
|
|
2575
|
+
const PHYSICAL_IFACE = /^(en|eth|wl)\d/i
|
|
2576
|
+
|
|
2577
|
+
// Within a tier, the range a phone most plausibly shares. Only ever a
|
|
2578
|
+
// tie-break: a corporate LAN is legitimately 10/8, so this must never outrank
|
|
2579
|
+
// the interface name.
|
|
2580
|
+
function rangeRank(address) {
|
|
2581
|
+
if (/^192\.168\./.test(address)) return 0
|
|
2582
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(address)) return 1
|
|
2583
|
+
if (/^10\./.test(address)) return 2
|
|
2584
|
+
return 3
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
/**
|
|
2588
|
+
* Order this machine's non-loopback IPv4 addresses, best candidate first.
|
|
2589
|
+
*
|
|
2590
|
+
* PURE — takes the interface map as an argument rather than reading
|
|
2591
|
+
* `os.networkInterfaces()`, so a test states the machine it describes instead of
|
|
2592
|
+
* depending on the one it runs on. Same discipline as `detectReader` and its
|
|
2593
|
+
* environment, and for the same reason.
|
|
2594
|
+
*/
|
|
2595
|
+
function rankLanAddresses(nets) {
|
|
2596
|
+
const out = []
|
|
2597
|
+
for (const name of Object.keys(nets || {})) {
|
|
2598
|
+
for (const net of nets[name] || []) {
|
|
2599
|
+
if (net.family !== 'IPv4' || net.internal) continue
|
|
2600
|
+
const tier = PHYSICAL_IFACE.test(name) ? 0 : VIRTUAL_IFACE.test(name) ? 2 : 1
|
|
2601
|
+
out.push({ address: net.address, iface: name, tier })
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
// Stable sort, so an unrankable set keeps discovery order rather than
|
|
2605
|
+
// shuffling between runs.
|
|
2606
|
+
return out
|
|
2607
|
+
.map((e, i) => ({ ...e, i }))
|
|
2608
|
+
.sort((a, b) => a.tier - b.tier || rangeRank(a.address) - rangeRank(b.address) || a.i - b.i)
|
|
2609
|
+
.map(({ address, iface }) => ({ address, iface }))
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
// Every non-loopback IPv4 address of this machine, best candidate first, for
|
|
2613
|
+
// printing a URL a phone on the same network can actually open.
|
|
2614
|
+
function lanAddresses(nets = require('node:os').networkInterfaces()) {
|
|
2615
|
+
return rankLanAddresses(nets).map((e) => e.address)
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
/**
|
|
2619
|
+
* Bring the review server up, or adopt the one already running.
|
|
2620
|
+
*
|
|
2621
|
+
* Shared by `serve` (which prints its own report) and by `review` on a remote
|
|
2622
|
+
* reader (which needs a URL, not a report). Only the STARTING is shared — how
|
|
2623
|
+
* each one talks about the result is its own business — so the two can never
|
|
2624
|
+
* drift on how a server comes up.
|
|
2625
|
+
*
|
|
2626
|
+
* Reuse is deliberate and load-bearing: restarting mints a fresh token, which
|
|
2627
|
+
* would silently kill a URL the operator already has open on their phone. Only
|
|
2628
|
+
* an explicit `serve` invocation (`restart`) is allowed to do that.
|
|
2629
|
+
*
|
|
2630
|
+
* Returns `{ port, token, loopback, pid, started }`, or `{ error }` — never
|
|
2631
|
+
* throws, because every caller's fallback is to carry on without a server.
|
|
2632
|
+
*/
|
|
2633
|
+
async function ensureReviewServer(dir, config, { host = '127.0.0.1', port, restart = false } = {}) {
|
|
2634
|
+
const sdir = stateDirLabel(config)
|
|
2635
|
+
const abs = (rel) => path.resolve(dir, rel)
|
|
2636
|
+
const settingsFile = `${sdir}/review-serve.json`
|
|
2637
|
+
const proc = serveProcFor(config, abs(settingsFile), { root: dir })
|
|
2638
|
+
|
|
2639
|
+
// A POSITIVE SIGNAL, not an absence: a pidfile on disk proves nothing (a
|
|
2640
|
+
// crashed process leaves one behind), so `isAlive` is what decides.
|
|
2641
|
+
const pid = readPid(abs(proc.pidFile))
|
|
2642
|
+
const running = pid && isAlive(pid) ? pid : null
|
|
2643
|
+
|
|
2644
|
+
let replaced = false
|
|
2645
|
+
// What the replaced server was running, and the URL it was answering on.
|
|
2646
|
+
// Both survive into the result so the caller can say what happened, and the
|
|
2647
|
+
// token survives into the NEW server so the operator's link keeps working.
|
|
2648
|
+
let engineWas = null
|
|
2649
|
+
let reuseToken = null
|
|
2650
|
+
if (running && !restart) {
|
|
2651
|
+
let settings = null
|
|
2652
|
+
try {
|
|
2653
|
+
settings = JSON.parse(fs.readFileSync(abs(settingsFile), 'utf-8'))
|
|
2654
|
+
} catch {}
|
|
2655
|
+
if (settings && settings.port) {
|
|
2656
|
+
if (serverScriptOk(settings)) {
|
|
2657
|
+
const now = engineVersionFor(proc.script)
|
|
2658
|
+
const verdict = staleServer(settings.engine, now)
|
|
2659
|
+
if (verdict !== 'stale') {
|
|
2660
|
+
const lb = settings.host === '127.0.0.1' || settings.host === 'localhost'
|
|
2661
|
+
return {
|
|
2662
|
+
port: settings.port,
|
|
2663
|
+
token: settings.token || null,
|
|
2664
|
+
loopback: lb,
|
|
2665
|
+
pid: running,
|
|
2666
|
+
started: false,
|
|
2667
|
+
// `current` needs no comment and `unknown` claims nothing — a server
|
|
2668
|
+
// from before the version was recorded is healthy, not suspect.
|
|
2669
|
+
engine: verdict,
|
|
2670
|
+
engineWas: settings.engine || null,
|
|
2671
|
+
engineIs: now,
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
// STALE: alive, addressable, its script still on disk — and drawing
|
|
2675
|
+
// every page with an engine that has been replaced underneath it. This
|
|
2676
|
+
// is invisible to every other check here, because each render IS
|
|
2677
|
+
// current: the counts move, the timestamp moves, the diff is right.
|
|
2678
|
+
// Only the renderer is old. Replace it rather than serve yesterday's
|
|
2679
|
+
// output under today's timestamp.
|
|
2680
|
+
replaced = 'engine'
|
|
2681
|
+
engineWas = settings.engine || null
|
|
2682
|
+
// KEEP THE URL. The operator is usually holding the old link on a phone,
|
|
2683
|
+
// and a token minted here would kill it silently — the very thing the
|
|
2684
|
+
// adoption path exists to avoid. Reused only when the bind is unchanged;
|
|
2685
|
+
// a loopback restart has no token to carry and needs none.
|
|
2686
|
+
reuseToken = settings.token || null
|
|
2687
|
+
} else {
|
|
2688
|
+
// Alive, addressable, and executing code that has been deleted — the
|
|
2689
|
+
// worktree it was started from is gone. It answers on the port and fails
|
|
2690
|
+
// on every page, so adopting it is worse than replacing it.
|
|
2691
|
+
replaced = 'script'
|
|
2692
|
+
}
|
|
2693
|
+
} else {
|
|
2694
|
+
// Running, but its settings are unreadable — we cannot address it, and
|
|
2695
|
+
// killing a server we cannot describe is worse than declining to use it.
|
|
2696
|
+
return { error: 'unreadable', pid: running }
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
const usePort = Number(port || config.review.servePort)
|
|
2701
|
+
const loopback = host === '127.0.0.1' || host === 'localhost'
|
|
2702
|
+
// The token is the ONLY guard on a non-loopback bind, so it is minted with the
|
|
2703
|
+
// bind rather than offered as an option to forget — except when replacing a
|
|
2704
|
+
// server on the same bind, where carrying the old one keeps a link that is
|
|
2705
|
+
// already open on someone's phone alive.
|
|
2706
|
+
const token = loopback ? null : reuseToken || mintToken()
|
|
2707
|
+
|
|
2708
|
+
if (running) await stopProcess(proc, { rootDir: dir })
|
|
2709
|
+
|
|
2710
|
+
// ASK ABOUT BOTH ADDRESSES, because a loopback bind and a wildcard bind
|
|
2711
|
+
// COEXIST under BSD semantics and neither probe sees the other:
|
|
2712
|
+
//
|
|
2713
|
+
// - probing 127.0.0.1 for a 0.0.0.0 bind succeeds while a wildcard squatter
|
|
2714
|
+
// holds the port — the substitution that let a leaked daemon sit on 7777
|
|
2715
|
+
// while every render claimed to have started a server
|
|
2716
|
+
// - probing 0.0.0.0 alone succeeds while something holds 127.0.0.1, which
|
|
2717
|
+
// would leave a server answering on the LAN and not on `localhost` — the
|
|
2718
|
+
// `local:` URL this CLI prints would be dead
|
|
2719
|
+
//
|
|
2720
|
+
// A port either half-taken is not usable, so both are asked and either
|
|
2721
|
+
// refuses. Deduped, so a loopback bind asks once.
|
|
2722
|
+
//
|
|
2723
|
+
// ASKED ONE AT A TIME, and that is load-bearing rather than tidy. The probe
|
|
2724
|
+
// binds, so two probes of one port contend with each other — and the BSD
|
|
2725
|
+
// coexistence this comment relies on is exactly what Linux does NOT do, where
|
|
2726
|
+
// a wildcard and a loopback bind of one port are mutually exclusive. Asked
|
|
2727
|
+
// concurrently the pair therefore reported a free port as busy, and the review
|
|
2728
|
+
// server refused to start on every Linux machine while macOS stayed green.
|
|
2729
|
+
// `portsInUseOn` owns the ordering; see its comment for the verification.
|
|
2730
|
+
const busy = await portsInUseOn(usePort, [host, '127.0.0.1'])
|
|
2731
|
+
if (busy.length) return { error: 'busy', port: usePort, replaced, engineWas }
|
|
2732
|
+
|
|
2733
|
+
fs.mkdirSync(path.dirname(abs(settingsFile)), { recursive: true })
|
|
2734
|
+
fs.writeFileSync(
|
|
2735
|
+
abs(settingsFile),
|
|
2736
|
+
// `script` is recorded so adoption has something to check. Without it the
|
|
2737
|
+
// only evidence a server is healthy is that its process exists. `engine` is
|
|
2738
|
+
// the second half of the same idea: the script can still be on disk and be a
|
|
2739
|
+
// DIFFERENT VERSION of itself, which is invisible to every other check here
|
|
2740
|
+
// — the process is alive, the file exists, and every page it renders is
|
|
2741
|
+
// drawn by code that was replaced underneath it.
|
|
2742
|
+
JSON.stringify(
|
|
2743
|
+
{ dir, port: usePort, host, token, script: proc.script, engine: engineVersionFor(proc.script) },
|
|
2744
|
+
null,
|
|
2745
|
+
2,
|
|
2746
|
+
) + '\n',
|
|
2747
|
+
)
|
|
2748
|
+
const res = startProcess(proc, { cwd: dir, rootDir: dir })
|
|
2749
|
+
const up = await waitListening([usePort], { host: loopback ? host : '127.0.0.1' })
|
|
2750
|
+
// A PORT ANSWERING IS NOT PROOF THAT OUR PROCESS IS ANSWERING IT. `waitListening`
|
|
2751
|
+
// connects, and anything already bound satisfies a connect — so on its own it
|
|
2752
|
+
// reports success for a daemon that died on EADDRINUSE seconds earlier. The
|
|
2753
|
+
// process we spawned still being alive is the specific evidence; the port
|
|
2754
|
+
// answering is merely consistent with it.
|
|
2755
|
+
if (!up || !isAlive(res.pid)) {
|
|
2756
|
+
// The stale context rides out on the failure too. A caller that only learns
|
|
2757
|
+
// "could not start" cannot say WHY it was trying, and "your server is running
|
|
2758
|
+
// an old engine and I could not replace it" is two facts the reader needs.
|
|
2759
|
+
return { error: up ? 'died' : 'silent', port: usePort, pid: res.pid, replaced, engineWas }
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
return {
|
|
2763
|
+
port: usePort,
|
|
2764
|
+
token,
|
|
2765
|
+
loopback,
|
|
2766
|
+
pid: res.pid,
|
|
2767
|
+
started: true,
|
|
2768
|
+
replaced,
|
|
2769
|
+
engineWas,
|
|
2770
|
+
engineIs: engineVersionFor(proc.script),
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
/**
|
|
2775
|
+
* `spec-env review serve` — stand up the local diff server.
|
|
2776
|
+
*
|
|
2777
|
+
* Three actions on one verb: start (the default), `--stop`, `--status`. The
|
|
2778
|
+
* pidfile is the single source of truth for all three, so `--status` cannot
|
|
2779
|
+
* claim a server that died and `--stop` cannot kill something it did not start.
|
|
2780
|
+
*/
|
|
2781
|
+
async function specEnvReviewServe(dir, config, flags) {
|
|
2782
|
+
const sdir = stateDirLabel(config)
|
|
2783
|
+
const abs = (rel) => path.resolve(dir, rel)
|
|
2784
|
+
const settingsFile = `${sdir}/review-serve.json`
|
|
2785
|
+
const proc = serveProcFor(config, abs(settingsFile), { root: dir })
|
|
2786
|
+
|
|
2787
|
+
// A POSITIVE SIGNAL, not an absence: a pidfile on disk proves nothing (a
|
|
2788
|
+
// crashed process leaves one behind), so `isAlive` is what decides. Three
|
|
2789
|
+
// states — running, not running, and a stale file, which reads as not running
|
|
2790
|
+
// and is overwritten rather than reported as an error.
|
|
2791
|
+
const pid = readPid(abs(proc.pidFile))
|
|
2792
|
+
const running = pid && isAlive(pid) ? pid : null
|
|
2793
|
+
|
|
2794
|
+
if (flags.status) {
|
|
2795
|
+
if (!running) {
|
|
2796
|
+
process.stdout.write('spec-env review serve: not running.\n')
|
|
2797
|
+
return
|
|
2798
|
+
}
|
|
2799
|
+
let settings = {}
|
|
2800
|
+
try {
|
|
2801
|
+
settings = JSON.parse(fs.readFileSync(abs(settingsFile), 'utf-8'))
|
|
2802
|
+
} catch {}
|
|
2803
|
+
// Three states, and only one of them is worth a line. `current` is the
|
|
2804
|
+
// ordinary answer and needs no comment; `unknown` is a server from before
|
|
2805
|
+
// this was recorded, which is healthy and must not be accused of anything.
|
|
2806
|
+
const verdict = staleServer(settings.engine, engineVersionFor(proc.script))
|
|
2807
|
+
process.stdout.write(
|
|
2808
|
+
`spec-env review serve: running (pid ${running})\n` +
|
|
2809
|
+
(settings.port ? ` local: ${serveUrl('127.0.0.1', settings)}\n` : '') +
|
|
2810
|
+
(settings.engine ? ` engine: ${settings.engine}\n` : '') +
|
|
2811
|
+
(verdict === 'stale'
|
|
2812
|
+
? ` the engine moved under it — this one is ${engineVersionFor(proc.script)}\n`
|
|
2813
|
+
: ''),
|
|
2814
|
+
)
|
|
2815
|
+
return
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
if (flags.stop) {
|
|
2819
|
+
if (!running) {
|
|
2820
|
+
process.stdout.write('spec-env review serve: not running — nothing to stop.\n')
|
|
2821
|
+
return
|
|
2822
|
+
}
|
|
2823
|
+
await stopProcess(proc, { rootDir: dir })
|
|
2824
|
+
process.stdout.write(`spec-env review serve: stopped (pid ${running}).\n`)
|
|
2825
|
+
return
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
// Read what the running server is bound to BEFORE replacing it, so a restart
|
|
2829
|
+
// without `--host` keeps that bind instead of silently narrowing to loopback.
|
|
2830
|
+
let currentSettings = null
|
|
2831
|
+
try {
|
|
2832
|
+
currentSettings = running ? JSON.parse(fs.readFileSync(abs(settingsFile), 'utf-8')) : null
|
|
2833
|
+
} catch {}
|
|
2834
|
+
|
|
2835
|
+
const started = await ensureReviewServer(dir, config, {
|
|
2836
|
+
host: restartHost(flags.host, currentSettings),
|
|
2837
|
+
port: flags.port,
|
|
2838
|
+
restart: true,
|
|
2839
|
+
})
|
|
2840
|
+
|
|
2841
|
+
if (started.error === 'busy') {
|
|
2842
|
+
process.stdout.write(
|
|
2843
|
+
`spec-env review serve: port ${started.port} is already in use — ` +
|
|
2844
|
+
'pass --port, or --stop if this is an older server.\n',
|
|
2845
|
+
)
|
|
2846
|
+
return
|
|
2847
|
+
}
|
|
2848
|
+
if (started.error === 'silent') {
|
|
2849
|
+
process.stdout.write(
|
|
2850
|
+
`spec-env review serve: started (pid ${started.pid}) but port ${started.port} never came up — ` +
|
|
2851
|
+
`see ${proc.logFile}\n`,
|
|
2852
|
+
)
|
|
2853
|
+
return
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
const { port, token, loopback } = started
|
|
2857
|
+
const res = { pid: started.pid }
|
|
2858
|
+
|
|
2859
|
+
const specs = servableSpecs(dir, config, gitReader(dir))
|
|
2860
|
+
process.stdout.write(
|
|
2861
|
+
`spec-env review serve: serving ${specs.length} spec${specs.length === 1 ? '' : 's'} ` +
|
|
2862
|
+
`(pid ${res.pid})\n` +
|
|
2863
|
+
` local: ${serveUrl('127.0.0.1', { port, token })}\n` +
|
|
2864
|
+
(loopback
|
|
2865
|
+
? ''
|
|
2866
|
+
: lanAddresses()
|
|
2867
|
+
.map((a) => ` lan: ${serveUrl(a, { port, token })}\n`)
|
|
2868
|
+
.join('') +
|
|
2869
|
+
' anyone with the lan URL can read every spec\'s diff while this runs.\n') +
|
|
2870
|
+
' stop: skitterspec spec-env review serve --stop\n',
|
|
2871
|
+
)
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
/**
|
|
2875
|
+
* The URLs to print for a served page — derived from the bind the server
|
|
2876
|
+
* ACTUALLY has, never from the one the caller asked for.
|
|
2877
|
+
*
|
|
2878
|
+
* `specEnvReview` requests `0.0.0.0` on a remote reader, but
|
|
2879
|
+
* `ensureReviewServer` adopts a server that is already running rather than
|
|
2880
|
+
* restarting it — deliberately, since a restart mints a fresh token and kills
|
|
2881
|
+
* the URL already open on someone's phone. So the server in hand may be
|
|
2882
|
+
* loopback-bound whatever was asked for, and printing `lanAddresses()` anyway
|
|
2883
|
+
* produced a URL that could not be opened, with nothing saying why.
|
|
2884
|
+
*
|
|
2885
|
+
* Loopback does not go widened silently. Restarting to satisfy the printout
|
|
2886
|
+
* would break the open URL to fix a description, so it prints the address that
|
|
2887
|
+
* works and names the command that widens it.
|
|
2888
|
+
*/
|
|
2889
|
+
function reviewServedUrls(up, addrs, folder) {
|
|
2890
|
+
const page = (host) => `${serveUrl(host, up)}${encodeURIComponent(folder)}`
|
|
2891
|
+
if (up.loopback) {
|
|
2892
|
+
return {
|
|
2893
|
+
url: page('127.0.0.1'),
|
|
2894
|
+
// No runners-up: every other address on this machine is one the server
|
|
2895
|
+
// is not listening on.
|
|
2896
|
+
alternates: [],
|
|
2897
|
+
loopback: true,
|
|
2898
|
+
widen: 'skitterspec spec-env review serve --host 0.0.0.0',
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
// Best candidate first, with the rest kept: the ranking reads interface names
|
|
2902
|
+
// and can be wrong, so the alternates are offered rather than thrown away. No
|
|
2903
|
+
// address at all means nothing to offer, and the `file://` fallback is the
|
|
2904
|
+
// honest answer.
|
|
2905
|
+
if (!addrs.length) return null
|
|
2906
|
+
return { url: page(addrs[0]), alternates: addrs.slice(1).map(page), loopback: false, widen: null }
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
/**
|
|
2910
|
+
* The host a `--restart` should bind to.
|
|
2911
|
+
*
|
|
2912
|
+
* An explicit `--host` wins. Otherwise KEEP WHAT THE RUNNING SERVER HAD: a
|
|
2913
|
+
* restart defaulting back to `127.0.0.1` narrowed the bind silently, which is
|
|
2914
|
+
* how a `--host 0.0.0.0` server became unreachable without anyone touching a
|
|
2915
|
+
* flag.
|
|
2916
|
+
*
|
|
2917
|
+
* WHAT WOULD FOOL THIS: nothing running, or a settings file with no `host`.
|
|
2918
|
+
* Both read as loopback — the narrow branch — because widening a bind by
|
|
2919
|
+
* inference is the one direction that must never happen by accident
|
|
2920
|
+
* (`.claude/rules/negative-checks.md` rule 4).
|
|
2921
|
+
*/
|
|
2922
|
+
function restartHost(flagHost, settings) {
|
|
2923
|
+
if (flagHost) return flagHost
|
|
2924
|
+
return (settings && settings.host) || '127.0.0.1'
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
function serveUrl(hostname, { port, token }) {
|
|
2928
|
+
return `http://${hostname}:${port}/${token ? token + '/' : ''}`
|
|
2929
|
+
}
|
|
2930
|
+
|
|
1368
2931
|
// Connect the canonical origin to ONE spec (exclusive model): (re)start the
|
|
1369
2932
|
// bundled proxy pointing at that spec's warm dev servers. `connect main` stops
|
|
1370
2933
|
// the proxy so the primary checkout owns the canonical ports again.
|
|
@@ -1384,9 +2947,20 @@ async function specEnvConnect(dir, config, specArg) {
|
|
|
1384
2947
|
const routesFile = `${sdir}/proxy.json`
|
|
1385
2948
|
const connectedFile = `${sdir}/connected`
|
|
1386
2949
|
const proxyProc = proxyProcFor(config, abs(routesFile))
|
|
1387
|
-
const target = specArg || 'main'
|
|
1388
2950
|
|
|
1389
|
-
|
|
2951
|
+
// DISCONNECT IS NAMED, NOT ASSUMED. A missing spec used to mean `main` — so
|
|
2952
|
+
// the bare form handed the ports BACK, the one verb in the family whose
|
|
2953
|
+
// zero-arg behaviour was the opposite of acting on your spec. It now resolves
|
|
2954
|
+
// like every other verb: the worktree you are standing in, else the sole
|
|
2955
|
+
// provisioned spec, else a refusal that names the candidates.
|
|
2956
|
+
//
|
|
2957
|
+
// This reverses `feat-script-only-commands` Decision 8, deliberately and as
|
|
2958
|
+
// the whole point of the change rather than as a side effect of one — see
|
|
2959
|
+
// `feat-bare-argument-parity`. The literal `main` is honoured even where the
|
|
2960
|
+
// base branch is called something else, matching `live main`, so the muscle
|
|
2961
|
+
// memory works in either repo.
|
|
2962
|
+
const base = resolveBaseBranch(config, gitReader(dir))
|
|
2963
|
+
if (specArg === 'main' || specArg === base) {
|
|
1390
2964
|
const res = await stopProcess(proxyProc, { rootDir: dir })
|
|
1391
2965
|
for (const f of [connectedFile, routesFile]) {
|
|
1392
2966
|
try {
|
|
@@ -1403,7 +2977,11 @@ async function specEnvConnect(dir, config, specArg) {
|
|
|
1403
2977
|
return
|
|
1404
2978
|
}
|
|
1405
2979
|
|
|
1406
|
-
|
|
2980
|
+
// Ambiguity REFUSES here rather than degrading. `live` can fall back to its
|
|
2981
|
+
// status report; `connect` has no read-only answer to fall back to, and a
|
|
2982
|
+
// fallback to `main` would reinstate the very inversion above — disconnecting
|
|
2983
|
+
// you at the moment you are least sure what is connected.
|
|
2984
|
+
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
1407
2985
|
const registry = readRegistry(dir, config)
|
|
1408
2986
|
if (!Object.prototype.hasOwnProperty.call(registry.slots, spec.folder)) {
|
|
1409
2987
|
process.stdout.write(
|
|
@@ -1491,9 +3069,13 @@ async function specEnvLive(dir, config, positional) {
|
|
|
1491
3069
|
)
|
|
1492
3070
|
return
|
|
1493
3071
|
}
|
|
1494
|
-
const { action, specArg } = liveGrammar(dir, config, positional)
|
|
3072
|
+
const { action, specArg, note } = liveGrammar(dir, config, positional)
|
|
1495
3073
|
switch (action) {
|
|
1496
3074
|
case 'status':
|
|
3075
|
+
// Only the bare form sets a note, and only for the one ambiguity the
|
|
3076
|
+
// report cannot describe. It prints ABOVE the report, not instead of it:
|
|
3077
|
+
// you asked a question and should still get the answer.
|
|
3078
|
+
if (note) process.stdout.write(note)
|
|
1497
3079
|
specEnvLiveStatus(dir, config, specArg)
|
|
1498
3080
|
break
|
|
1499
3081
|
case 'take':
|
|
@@ -1530,7 +3112,7 @@ const LIVE_VERBS = new Set(['status', 'take', 'release', 'abort'])
|
|
|
1530
3112
|
// matching `connect main`, so the muscle memory works in either repo.
|
|
1531
3113
|
function liveGrammar(dir, config, positional) {
|
|
1532
3114
|
const [first, second] = positional
|
|
1533
|
-
if (!first) return
|
|
3115
|
+
if (!first) return bareLive(dir, config)
|
|
1534
3116
|
if (LIVE_VERBS.has(first)) return { action: first, specArg: second }
|
|
1535
3117
|
if (first === 'main' || first === resolveBaseBranch(config, gitReader(dir))) {
|
|
1536
3118
|
return { action: 'release', specArg: undefined }
|
|
@@ -1538,6 +3120,52 @@ function liveGrammar(dir, config, positional) {
|
|
|
1538
3120
|
return { action: 'take', specArg: first }
|
|
1539
3121
|
}
|
|
1540
3122
|
|
|
3123
|
+
/**
|
|
3124
|
+
* `/spec-live` with nothing after it: take the spec you are on, when there is
|
|
3125
|
+
* exactly one answer and the workbench is free — otherwise print the status
|
|
3126
|
+
* report.
|
|
3127
|
+
*
|
|
3128
|
+
* TWO POSITIVE SIGNALS, both required, and neither is an absence: a spec must
|
|
3129
|
+
* RESOLVE (not "no error"), and the primary checkout must be demonstrably on
|
|
3130
|
+
* base with no receipt (not "no evidence it is busy"). Every other state —
|
|
3131
|
+
* several worktrees, none, a spec already live, a hand-switched branch — is
|
|
3132
|
+
* *cannot tell*, and cannot-tell prints the report. That is the whole safety
|
|
3133
|
+
* argument for letting a bare command switch a branch at all: the one case it
|
|
3134
|
+
* acts on is the case with a single possible meaning.
|
|
3135
|
+
*
|
|
3136
|
+
* It decides WHICH VERB, never whether the verb is allowed. `specEnvLiveTake`
|
|
3137
|
+
* keeps every refusal it already had — dirty tree, hotfix, stateful spec,
|
|
3138
|
+
* migrations, a held instance — through `planTake`. Re-checking any of them here
|
|
3139
|
+
* would be a second copy free to drift from the first.
|
|
3140
|
+
*/
|
|
3141
|
+
function bareLive(dir, config) {
|
|
3142
|
+
const choice = provisionedSpecChoice(dir, config)
|
|
3143
|
+
if (!choice.folder) {
|
|
3144
|
+
// Several worktrees is the only cannot-tell the status report does not
|
|
3145
|
+
// explain — it reports on the repo, not on what you might have meant. With
|
|
3146
|
+
// none provisioned the report's own `in-flight:` line already says it.
|
|
3147
|
+
const note = choice.candidates.length
|
|
3148
|
+
? `spec-env live: ${choice.candidates.length} specs have worktrees — name the one you mean:\n` +
|
|
3149
|
+
choice.candidates.map((f, i) => ` ${i + 1}. ${f}`).join('\n') +
|
|
3150
|
+
'\n'
|
|
3151
|
+
: undefined
|
|
3152
|
+
return { action: 'status', specArg: undefined, note }
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
// The workbench must be FREE, not merely un-refused. When it is not, the
|
|
3156
|
+
// report names the branch, the in-flight spec and the receipt — so it already
|
|
3157
|
+
// says why nothing was taken, and a note here would only repeat it.
|
|
3158
|
+
const primary = assertPrimaryOnMain(config, gitReader(dir))
|
|
3159
|
+
const receipt = readReceipt(dir, config)
|
|
3160
|
+
if (!primary.onBase || (receipt && receipt.spec)) {
|
|
3161
|
+
return { action: 'status', specArg: undefined }
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
// Resolved here rather than passed as `undefined`, so the verb acts on the
|
|
3165
|
+
// spec this function actually decided about.
|
|
3166
|
+
return { action: 'take', specArg: choice.folder }
|
|
3167
|
+
}
|
|
3168
|
+
|
|
1541
3169
|
// Take the running instance: rebase the spec's branch onto base, free it from its
|
|
1542
3170
|
// worktree, and check it out in the primary checkout so the dev server reloads it.
|
|
1543
3171
|
async function specEnvLiveTake(dir, config, specArg) {
|
|
@@ -1803,16 +3431,54 @@ async function specEnv(rest) {
|
|
|
1803
3431
|
const [sub, ...args] = rest
|
|
1804
3432
|
let dir = process.cwd()
|
|
1805
3433
|
const positional = []
|
|
1806
|
-
const flags = {
|
|
3434
|
+
const flags = {
|
|
3435
|
+
keepVolumes: false,
|
|
3436
|
+
force: false,
|
|
3437
|
+
also: [],
|
|
3438
|
+
olderThanDays: null,
|
|
3439
|
+
branch: false,
|
|
3440
|
+
out: null,
|
|
3441
|
+
review: null,
|
|
3442
|
+
notes: null,
|
|
3443
|
+
resolve: null,
|
|
3444
|
+
outcome: null,
|
|
3445
|
+
claim: null,
|
|
3446
|
+
drop: null,
|
|
3447
|
+
json: false,
|
|
3448
|
+
}
|
|
1807
3449
|
for (let i = 0; i < args.length; i++) {
|
|
1808
3450
|
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
1809
3451
|
else if (args[i] === '--keep-volumes') flags.keepVolumes = true
|
|
1810
3452
|
else if (args[i] === '--force') flags.force = true
|
|
1811
3453
|
else if (args[i] === '--also') flags.also.push(args[++i])
|
|
1812
3454
|
else if (args[i] === '--older-than') flags.olderThanDays = Number(args[++i])
|
|
3455
|
+
else if (args[i] === '--branch') flags.branch = true
|
|
3456
|
+
else if (args[i] === '--stop') flags.stop = true
|
|
3457
|
+
else if (args[i] === '--status') flags.status = true
|
|
3458
|
+
else if (args[i] === '--port') flags.port = args[++i]
|
|
3459
|
+
else if (args[i] === '--host') flags.host = args[++i]
|
|
3460
|
+
else if (args[i] === '--publish-copy') flags.publishCopy = true
|
|
3461
|
+
else if (args[i] === '--out') flags.out = args[++i]
|
|
3462
|
+
else if (args[i] === '--review') flags.review = args[++i]
|
|
3463
|
+
else if (args[i] === '--notes') flags.notes = args[++i]
|
|
3464
|
+
else if (args[i] === '--resolve') flags.resolve = args[++i]
|
|
3465
|
+
else if (args[i] === '--outcome') flags.outcome = args[++i]
|
|
3466
|
+
else if (args[i] === '--claim') flags.claim = args[++i]
|
|
3467
|
+
else if (args[i] === '--claim-since') flags.claimSince = args[++i]
|
|
3468
|
+
else if (args[i] === '--drop') flags.drop = args[++i]
|
|
3469
|
+
else if (args[i] === '--json') flags.json = true
|
|
3470
|
+
else if (args[i] === '--check') flags.check = true
|
|
3471
|
+
else if (args[i] === '--for-command') flags.forCommand = args[++i]
|
|
3472
|
+
else if (args[i] === '--phase') flags.phase = args[++i]
|
|
3473
|
+
else if (args[i] === '--record-primary') flags.recordPrimary = true
|
|
3474
|
+
else if (args[i] === '--assert-primary-clean') flags.assertPrimaryClean = true
|
|
1813
3475
|
else positional.push(args[i])
|
|
1814
3476
|
}
|
|
1815
3477
|
dir = path.resolve(dir)
|
|
3478
|
+
// Where the caller actually is, kept before the re-anchor below. Only `stage`
|
|
3479
|
+
// wants it: every other subcommand asks about the repo, while that one asks
|
|
3480
|
+
// about the tree in front of you, and the two differ inside a worktree.
|
|
3481
|
+
const invokedFrom = dir
|
|
1816
3482
|
// Anchor on the primary checkout so every subcommand resolves {repo}, worktree
|
|
1817
3483
|
// paths, and the registry identically whether run from main or a worktree.
|
|
1818
3484
|
dir = resolvePrimaryCheckout(dir, gitReader(dir))
|
|
@@ -1852,20 +3518,57 @@ async function specEnv(rest) {
|
|
|
1852
3518
|
specEnvStatus(dir, config)
|
|
1853
3519
|
break
|
|
1854
3520
|
case 'resolve':
|
|
1855
|
-
specEnvResolve(dir, config, positional[0])
|
|
3521
|
+
specEnvResolve(dir, config, positional[0], flags)
|
|
3522
|
+
break
|
|
3523
|
+
case 'stage':
|
|
3524
|
+
specEnvStage(dir, config, positional[0], flags, invokedFrom)
|
|
3525
|
+
break
|
|
3526
|
+
case 'review':
|
|
3527
|
+
// `serve` is the one review sub-action rather than a verb of its own: it
|
|
3528
|
+
// answers the same question ("show me this diff") from the same engine,
|
|
3529
|
+
// and a sibling verb would have to re-derive every bit of that.
|
|
3530
|
+
if (positional[0] === 'serve') {
|
|
3531
|
+
await specEnvReviewServe(dir, config, flags)
|
|
3532
|
+
break
|
|
3533
|
+
}
|
|
3534
|
+
// The gate verbs sit here for the same reason `serve` does: they answer
|
|
3535
|
+
// about the page this command renders, keyed to the same path, and a
|
|
3536
|
+
// sibling verb would have to re-derive every bit of that.
|
|
3537
|
+
if (positional[0] === 'arm') {
|
|
3538
|
+
specEnvReviewArm(dir, config, positional[1], flags)
|
|
3539
|
+
break
|
|
3540
|
+
}
|
|
3541
|
+
if (positional[0] === 'gate') {
|
|
3542
|
+
specEnvReviewGate(dir, config, positional[1], flags, invokedFrom)
|
|
3543
|
+
break
|
|
3544
|
+
}
|
|
3545
|
+
if (positional[0] === 'skip') {
|
|
3546
|
+
// The one positional is the REASON, not a spec: the two are
|
|
3547
|
+
// indistinguishable as free text, and the spec is the one thing this
|
|
3548
|
+
// engine can already resolve from where you are standing.
|
|
3549
|
+
specEnvReviewSkip(dir, config, positional[1], flags)
|
|
3550
|
+
break
|
|
3551
|
+
}
|
|
3552
|
+
await specEnvReview(dir, config, positional[0], flags)
|
|
1856
3553
|
break
|
|
1857
3554
|
case 'live':
|
|
1858
3555
|
await specEnvLive(dir, config, positional)
|
|
1859
3556
|
break
|
|
1860
3557
|
default:
|
|
1861
3558
|
process.stdout.write(
|
|
1862
|
-
'Usage: skitterspec spec-env <up|down|prune|dev|connect|integrate|hotfix|live|status|resolve> [spec] [--keep-volumes] [--force] [--also <tag>] [--older-than <days>]\n' +
|
|
1863
|
-
|
|
1864
|
-
'
|
|
1865
|
-
'
|
|
1866
|
-
'
|
|
1867
|
-
'
|
|
1868
|
-
'
|
|
3559
|
+
'Usage: skitterspec spec-env <up|down|prune|dev|connect|integrate|hotfix|live|review|stage|status|resolve> [spec] [--keep-volumes] [--force] [--also <tag>] [--older-than <days>] [--branch] [--out <file>] [--review <json>] [--notes <json>] [--resolve <json>] [--outcome <text>] [--claim <code>] [--drop <code>] [--json] [--record-primary] [--assert-primary-clean]\n' +
|
|
3560
|
+
' review serve [--port <n>] [--host <addr>] [--stop] [--status] serve every diff locally\n' +
|
|
3561
|
+
' review arm [spec] [--phase <n>] a phase ended — its diff now owes a verdict\n' +
|
|
3562
|
+
' review gate [spec] [--check] [--json] is one owed? --check exits non-zero if so\n' +
|
|
3563
|
+
' [--for-command <cmdline>] ...but only when that command is a git commit\n' +
|
|
3564
|
+
' review skip "<reason>" move on without one, on the record\n' +
|
|
3565
|
+
' review [spec] --claim-since <iso> claim the one pass that arrived since <iso>\n' +
|
|
3566
|
+
' [spec] is optional everywhere: omit it and the worktree you are standing\n' +
|
|
3567
|
+
' in is used, else the sole provisioned spec (several -> it lists them).\n' +
|
|
3568
|
+
' A bare `live` takes that spec when the workbench is free, and prints the\n' +
|
|
3569
|
+
' status report when it cannot tell. `live status` still reports on the\n' +
|
|
3570
|
+
' whole repo. `live main` / `connect main` (or your base branch) hand the\n' +
|
|
3571
|
+
' instance and the ports back.\n',
|
|
1869
3572
|
)
|
|
1870
3573
|
}
|
|
1871
3574
|
}
|
|
@@ -1960,6 +3663,11 @@ async function run(argv) {
|
|
|
1960
3663
|
case 'update':
|
|
1961
3664
|
// `update` is a resync — refresh managed files, keep customized ones
|
|
1962
3665
|
// (--force to overwrite). Leaves specs/ and live .core config alone.
|
|
3666
|
+
// `--check` reports what it WOULD change and writes nothing.
|
|
3667
|
+
if (opts.check) {
|
|
3668
|
+
checkSync(dir, { claudeMd: opts.claudeMd })
|
|
3669
|
+
break
|
|
3670
|
+
}
|
|
1963
3671
|
resync(dir, { claudeMd: opts.claudeMd, force: opts.force, diff: opts.diff })
|
|
1964
3672
|
await cleanupReleaseTooling(dir, opts)
|
|
1965
3673
|
break
|
|
@@ -1968,4 +3676,15 @@ async function run(argv) {
|
|
|
1968
3676
|
}
|
|
1969
3677
|
}
|
|
1970
3678
|
|
|
1971
|
-
module.exports = {
|
|
3679
|
+
module.exports = {
|
|
3680
|
+
run,
|
|
3681
|
+
parse,
|
|
3682
|
+
HELP,
|
|
3683
|
+
unknownCommand,
|
|
3684
|
+
rankLanAddresses,
|
|
3685
|
+
serveProcFor,
|
|
3686
|
+
serverScriptOk,
|
|
3687
|
+
daemonScript,
|
|
3688
|
+
reviewServedUrls,
|
|
3689
|
+
restartHost,
|
|
3690
|
+
}
|