@skitterbyte/skitterspec 22.0.0 → 22.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/assets/claude-md-section.md +13 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/env.config.json.example +2 -1
- package/assets/core/env.config.md +83 -2
- package/assets/hooks/main-guard.cjs +145 -0
- package/assets/hooks/review-gate.cjs +8 -3
- package/assets/review/page.html +132 -2
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +160 -11
- package/assets/rules/spec-reports.md +50 -9
- package/assets/skills/no-spec/SKILL.md +219 -0
- package/assets/skills/spec/SKILL.md +135 -36
- package/assets/skills/spec-bug/SKILL.md +5 -0
- package/assets/skills/spec-cancel/SKILL.md +29 -0
- package/assets/skills/spec-complete/SKILL.md +25 -0
- package/assets/skills/spec-diff/SKILL.md +33 -1
- package/assets/skills/spec-init/SKILL.md +4 -2
- package/assets/skills/spec-next/SKILL.md +47 -2
- package/assets/skills/spec-reviewed/SKILL.md +3 -3
- package/assets/skills/spec-start/SKILL.md +14 -0
- package/package.json +1 -1
- package/src/cli.js +702 -59
- package/src/env/config.js +121 -4
- package/src/env/hooks.js +102 -33
- package/src/env/live.js +30 -1
- package/src/env/mainguard.js +240 -0
- package/src/env/provision.js +106 -10
- package/src/env/registry.js +70 -6
- package/src/env/resolve.js +92 -3
- package/src/env/review.js +324 -20
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +189 -44
- package/src/env/teardown.js +12 -1
- package/src/init.js +125 -15
package/src/cli.js
CHANGED
|
@@ -40,6 +40,8 @@ const SPEC_ENV_VERBS = Object.freeze([
|
|
|
40
40
|
'stage',
|
|
41
41
|
'status',
|
|
42
42
|
'resolve',
|
|
43
|
+
'nospec',
|
|
44
|
+
'main',
|
|
43
45
|
])
|
|
44
46
|
const {
|
|
45
47
|
readRegistry,
|
|
@@ -47,9 +49,13 @@ const {
|
|
|
47
49
|
allocateSlot,
|
|
48
50
|
freeSlot,
|
|
49
51
|
portOffset,
|
|
52
|
+
recordSpecless,
|
|
53
|
+
forgetSpecless,
|
|
54
|
+
isSpecless,
|
|
50
55
|
} = require('./env/registry.js')
|
|
51
56
|
const {
|
|
52
57
|
resolveSpec,
|
|
58
|
+
resolveSpecless,
|
|
53
59
|
resolveBaseBranch,
|
|
54
60
|
liveWorktreePaths,
|
|
55
61
|
collectSpecFolders,
|
|
@@ -106,6 +112,7 @@ const {
|
|
|
106
112
|
claimPending,
|
|
107
113
|
passesSince,
|
|
108
114
|
waitForPass,
|
|
115
|
+
WAIT_HEARTBEAT_MS,
|
|
109
116
|
waitingPasses,
|
|
110
117
|
describePending,
|
|
111
118
|
pendingAge,
|
|
@@ -119,14 +126,32 @@ const {
|
|
|
119
126
|
reviewGatePath,
|
|
120
127
|
readGate,
|
|
121
128
|
writeGate,
|
|
129
|
+
writeRenderRecord,
|
|
122
130
|
armGate,
|
|
123
131
|
disarmGate,
|
|
124
132
|
gateState,
|
|
133
|
+
markGateOffered,
|
|
125
134
|
} = require('./env/review.js')
|
|
126
|
-
const {
|
|
135
|
+
const {
|
|
136
|
+
CHECKS_VERSION,
|
|
137
|
+
BUNDLED_ADAPTERS,
|
|
138
|
+
runReviewers,
|
|
139
|
+
diffHashOf,
|
|
140
|
+
readChecks,
|
|
141
|
+
writeChecks,
|
|
142
|
+
cacheHit,
|
|
143
|
+
asCached,
|
|
144
|
+
} = require('./env/reviewers.js')
|
|
145
|
+
const { planUp, planCheckoutUp, worktreeCd, resolveStack } = require('./env/provision.js')
|
|
127
146
|
const { classifyDirtyTree, dirtyPaths, specDocsIn } = require('./env/classify.js')
|
|
128
147
|
const { isGitCommit } = require('./env/commitcmd.js')
|
|
129
148
|
const { planDown, planDownCheckout } = require('./env/teardown.js')
|
|
149
|
+
const {
|
|
150
|
+
checkMainWrite,
|
|
151
|
+
recordAllow,
|
|
152
|
+
clearAllow,
|
|
153
|
+
mainGuardStatus,
|
|
154
|
+
} = require('./env/mainguard.js')
|
|
130
155
|
const { planPrune, liveSlugsForSpecs, reconcileRegistry } = require('./env/prune.js')
|
|
131
156
|
const { planIntegrate, planIntegrateCheckout } = require('./env/integrate.js')
|
|
132
157
|
const { planHotfixLand } = require('./env/hotfix.js')
|
|
@@ -181,6 +206,13 @@ Usage:
|
|
|
181
206
|
specs/.core/env.config.json). Subcommands:
|
|
182
207
|
up <spec> print the plan to provision a worktree +
|
|
183
208
|
Docker stack (prints commands; creates nothing)
|
|
209
|
+
--docs: a tree to write documents in — no
|
|
210
|
+
setup commands, no Docker
|
|
211
|
+
nospec <name> record a branch with no spec document
|
|
212
|
+
(/no-spec work) and print its plan
|
|
213
|
+
main <check|allow|status>
|
|
214
|
+
the main guard — is a write about to land
|
|
215
|
+
on the base branch? (/allow-main lifts it)
|
|
184
216
|
down <spec> tear down (guards; --keep-volumes, --force)
|
|
185
217
|
prune reap orphaned test-DB volumes (--older-than <days>)
|
|
186
218
|
dev up <spec> start host dev servers on the spec's ports
|
|
@@ -194,7 +226,8 @@ Usage:
|
|
|
194
226
|
review <spec> write an HTML page of the spec's diff
|
|
195
227
|
(--branch for the whole spec; --out, --json)
|
|
196
228
|
(--notes <json> merges a review pass back;
|
|
197
|
-
--resolve <json> records what was done
|
|
229
|
+
--resolve <json> records what was done;
|
|
230
|
+
--run-reviewers runs review.reviewers)
|
|
198
231
|
stage [spec] split the uncommitted tree into this spec's
|
|
199
232
|
documents and everything else
|
|
200
233
|
status list provisioned specs + port blocks
|
|
@@ -377,8 +410,8 @@ function specEnvReviewWaiting(dir, config, flags) {
|
|
|
377
410
|
|
|
378
411
|
function specEnvStatus(dir, config) {
|
|
379
412
|
const worktreePaths = liveWorktreePaths(gitReader(dir))
|
|
380
|
-
const provisioned = allSpecs(dir, config, worktreePaths)
|
|
381
|
-
.map((s) => ({ folder: s.folder, wt: path.resolve(s.worktreePath) }))
|
|
413
|
+
const provisioned = allSpecs(dir, config, worktreePaths, speclessMap(dir, config))
|
|
414
|
+
.map((s) => ({ folder: s.folder, wt: path.resolve(s.worktreePath), specless: s.specless }))
|
|
382
415
|
// The primary checkout is itself in `git worktree list`; a spec is
|
|
383
416
|
// provisioned only when it has its OWN worktree, separate from it.
|
|
384
417
|
.filter((s) => s.wt !== dir && worktreePaths.has(s.wt))
|
|
@@ -394,14 +427,19 @@ function specEnvStatus(dir, config) {
|
|
|
394
427
|
|
|
395
428
|
const registry = readRegistry(dir, config)
|
|
396
429
|
process.stdout.write('Provisioned specs:\n')
|
|
397
|
-
for (const { folder, wt } of provisioned) {
|
|
430
|
+
for (const { folder, wt, specless } of provisioned) {
|
|
398
431
|
const slot = registry.slots[folder]
|
|
399
432
|
let ports = ''
|
|
400
433
|
if (slot !== undefined) {
|
|
401
434
|
const off = portOffset(slot, config)
|
|
402
435
|
ports = ` slot ${slot} ports ${off}-${off + config.docker.portsPerSpec - 1}`
|
|
403
436
|
}
|
|
404
|
-
|
|
437
|
+
// SAID, not left to be noticed. A `/no-spec` branch appears in this list
|
|
438
|
+
// because it has a worktree like everything else, and a reader who goes
|
|
439
|
+
// looking for `specs/**/<name>/` must not conclude the repo is broken when
|
|
440
|
+
// there is nothing there — it is not missing, there never was one.
|
|
441
|
+
const tag = specless ? ' (no spec)' : ''
|
|
442
|
+
process.stdout.write(` ${folder}${tag}${ports}\n ${path.relative(dir, wt) || wt}\n`)
|
|
405
443
|
}
|
|
406
444
|
process.stdout.write(waitingSection(dir, config))
|
|
407
445
|
}
|
|
@@ -606,8 +644,220 @@ function specEnvUpCheckout(dir, config, spec) {
|
|
|
606
644
|
process.stdout.write(out.join('\n') + '\n')
|
|
607
645
|
}
|
|
608
646
|
|
|
609
|
-
|
|
647
|
+
/**
|
|
648
|
+
* `spec-env main <check|allow|status>` — the main guard.
|
|
649
|
+
*
|
|
650
|
+
* `check` is what the hook calls, so it follows `review gate --check`'s
|
|
651
|
+
* contract exactly: **exit 1 and print the reason** when the write should be
|
|
652
|
+
* refused, exit 0 and say nothing otherwise. Every cannot-tell is an exit 0.
|
|
653
|
+
*
|
|
654
|
+
* `allow` and `status` are for a person. `allow` is reached only through
|
|
655
|
+
* `/allow-main`, which is user-only — a guard the model can lift is decoration.
|
|
656
|
+
*/
|
|
657
|
+
function specEnvMain(dir, config, positional, flags = {}, present = true, invokedFrom = dir) {
|
|
658
|
+
const action = positional[0] || 'status'
|
|
659
|
+
const sessionId = flags.session || process.env.CLAUDE_CODE_SESSION_ID || null
|
|
660
|
+
const git = gitReader(dir)
|
|
661
|
+
|
|
662
|
+
if (action === 'check') {
|
|
663
|
+
// `invokedFrom`, NOT `dir`. Every subcommand is re-anchored on the primary
|
|
664
|
+
// checkout before it runs, so asking `dir` whether it is the primary
|
|
665
|
+
// checkout always answers yes — and the guard would fire on a write inside
|
|
666
|
+
// somebody's worktree, which is the one place it must never fire.
|
|
667
|
+
const from = invokedFrom || dir
|
|
668
|
+
const verdict = checkMainWrite(from, config, gitReader(from), { sessionId, present })
|
|
669
|
+
if (!verdict.refuse) return
|
|
670
|
+
process.stdout.write(`spec-env main: ${verdict.reason}\n`)
|
|
671
|
+
process.exitCode = 1
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (action === 'allow') {
|
|
676
|
+
// The primary checkout owns the allow file, so an `/allow-main` typed from a
|
|
677
|
+
// worktree still lands where the guard reads it.
|
|
678
|
+
const root = dir
|
|
679
|
+
|
|
680
|
+
if (flags.off || positional[1] === 'off') {
|
|
681
|
+
const had = clearAllow(root, config)
|
|
682
|
+
process.stdout.write(
|
|
683
|
+
had
|
|
684
|
+
? 'spec-env main: allow cleared — the base branch is guarded again.\n'
|
|
685
|
+
: 'spec-env main: nothing to clear — the base branch was already guarded.\n',
|
|
686
|
+
)
|
|
687
|
+
return
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const reason = positional.slice(1).join(' ').trim() || null
|
|
691
|
+
const value = recordAllow(root, config, {
|
|
692
|
+
sessionId,
|
|
693
|
+
reason,
|
|
694
|
+
at: new Date().toISOString(),
|
|
695
|
+
})
|
|
696
|
+
const scope =
|
|
697
|
+
value.scope === 'session'
|
|
698
|
+
? 'this session only — it lapses when the session ends'
|
|
699
|
+
: 'this repo until cleared — no session id available, so `/allow-main off` is what ends it'
|
|
700
|
+
process.stdout.write(
|
|
701
|
+
`spec-env main: writes to the base branch are allowed (${scope}).\n` +
|
|
702
|
+
(reason ? ` reason: ${reason}\n` : ' reason: none given\n'),
|
|
703
|
+
)
|
|
704
|
+
return
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (action === 'status') {
|
|
708
|
+
const st = mainGuardStatus(dir, config, { sessionId, present })
|
|
709
|
+
if (flags.json) {
|
|
710
|
+
process.stdout.write(JSON.stringify(st, null, 2) + '\n')
|
|
711
|
+
return
|
|
712
|
+
}
|
|
713
|
+
if (st.state === 'unconfigured') {
|
|
714
|
+
process.stdout.write('spec-env main: isolation is not configured — the guard is inactive.\n')
|
|
715
|
+
return
|
|
716
|
+
}
|
|
717
|
+
if (st.state === 'off') {
|
|
718
|
+
process.stdout.write(
|
|
719
|
+
'spec-env main: off for this project (guards.mainIsLandingZone is false).\n',
|
|
720
|
+
)
|
|
721
|
+
return
|
|
722
|
+
}
|
|
723
|
+
if (st.state === 'allowed') {
|
|
724
|
+
process.stdout.write(
|
|
725
|
+
`spec-env main: allowed (${st.scope})\n reason: ${st.reason || 'none given'}\n` +
|
|
726
|
+
' /allow-main off ends it\n',
|
|
727
|
+
)
|
|
728
|
+
return
|
|
729
|
+
}
|
|
730
|
+
process.stdout.write(
|
|
731
|
+
'spec-env main: guarded — the base branch is a landing zone.\n' +
|
|
732
|
+
(st.otherSession
|
|
733
|
+
? ' an allow exists for a different session, so it does not apply here\n'
|
|
734
|
+
: ''),
|
|
735
|
+
)
|
|
736
|
+
return
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
process.stdout.write(
|
|
740
|
+
`spec-env main: unknown action "${action}" — one of check, allow, status.\n`,
|
|
741
|
+
)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* `spec-env nospec <name>` — record a SPECLESS branch and print its plan.
|
|
746
|
+
*
|
|
747
|
+
* `/no-spec` is the lane for work that genuinely has no spec: bumping four
|
|
748
|
+
* projects to a new version, a lockfile refresh, a rename. It exists because a
|
|
749
|
+
* guard that refuses writes on the base branch is a wall unless there is
|
|
750
|
+
* somewhere cheap to go, and because work done on the base branch renders no
|
|
751
|
+
* page and therefore gets no review at all.
|
|
752
|
+
*
|
|
753
|
+
* IT RECORDS FIRST, THEN PLANS, and the order is the correctness condition. The
|
|
754
|
+
* record is what makes the name resolvable (`resolve.js`'s specless fallback),
|
|
755
|
+
* so every later verb — `review`, `integrate`, `down`, `status`, a bare
|
|
756
|
+
* `resolve` — can find a branch with no document behind it. Plan first and a
|
|
757
|
+
* caller that runs the printed commands owns a worktree the engine cannot name.
|
|
758
|
+
*
|
|
759
|
+
* NO `--docs` HERE, deliberately, and it is the opposite call from `/spec`.
|
|
760
|
+
* Documents mode skips the `setup` commands because writing markdown needs no
|
|
761
|
+
* dependencies; `/no-spec` work is *code*, so it needs them. The two lanes want
|
|
762
|
+
* opposite halves of the same provision.
|
|
763
|
+
*
|
|
764
|
+
* It is a planner like `up`: it creates no worktree. The registry write is its
|
|
765
|
+
* only side effect, which is why it is safe to re-run.
|
|
766
|
+
*/
|
|
767
|
+
function specEnvNospec(dir, config, name, flags = {}) {
|
|
768
|
+
if (!name || !/^[a-z0-9][a-z0-9-]*$/.test(name)) {
|
|
769
|
+
process.stdout.write(
|
|
770
|
+
'spec-env nospec: needs a kebab-case name — it becomes the branch and the ' +
|
|
771
|
+
'worktree folder.\n e.g. skitterspec spec-env nospec bump-deps\n',
|
|
772
|
+
)
|
|
773
|
+
return
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// A name that is already a spec is refused rather than shadowed. Two things
|
|
777
|
+
// answering to one name is how `down` comes to tear the wrong tree down.
|
|
778
|
+
let clash = null
|
|
779
|
+
try {
|
|
780
|
+
clash = resolveSpec(name, dir, config, {
|
|
781
|
+
searchDirs: [...liveWorktreePaths(gitReader(dir))],
|
|
782
|
+
})
|
|
783
|
+
} catch {
|
|
784
|
+
// Not found is the expected case, and the only one that proceeds.
|
|
785
|
+
}
|
|
786
|
+
if (clash && !clash.specless) {
|
|
787
|
+
process.stdout.write(
|
|
788
|
+
`spec-env nospec: ${name} is already a spec (${clash.bucket}) — ` +
|
|
789
|
+
`use /spec-start ${name} instead.\n`,
|
|
790
|
+
)
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const before = readRegistry(dir, config)
|
|
795
|
+
const already = isSpecless(before, name)
|
|
796
|
+
const spec = resolveSpecless(name, dir, config, before.specless[name])
|
|
797
|
+
writeRegistry(dir, config, recordSpecless(before, name, { branch: spec.branch }))
|
|
798
|
+
|
|
799
|
+
const worktreeRootAbs = path.dirname(spec.worktreePath)
|
|
800
|
+
const trust = ensureWorktreeDirTrusted(dir, worktreeRootAbs)
|
|
801
|
+
const attached = fs.existsSync(spec.worktreePath)
|
|
802
|
+
|
|
803
|
+
const guard = worktreeCd(spec.worktreePath)
|
|
804
|
+
const setupCommands = (config.setup || []).map((cmd) => `${guard}; ${cmd}`)
|
|
805
|
+
|
|
806
|
+
const out = [
|
|
807
|
+
`spec-env nospec: ${name} ` +
|
|
808
|
+
(attached ? '(plan — worktree exists; will attach)' : '(plan — nothing created yet)'),
|
|
809
|
+
'',
|
|
810
|
+
` worktree: ${spec.worktreePath}`,
|
|
811
|
+
` branch: ${spec.branch}`,
|
|
812
|
+
' stack: worktree-only (no docker, no port block)',
|
|
813
|
+
` recorded: ${already ? 'already in' : 'added to'} ${config.registry} as a specless branch`,
|
|
814
|
+
]
|
|
815
|
+
if (trust.reason === 'malformed') {
|
|
816
|
+
out.push(
|
|
817
|
+
' trusted: ! .claude/settings.local.json is not valid JSON — left it;' +
|
|
818
|
+
`\n add ${worktreeRootAbs} to permissions.additionalDirectories yourself`,
|
|
819
|
+
)
|
|
820
|
+
} else {
|
|
821
|
+
out.push(
|
|
822
|
+
` trusted: ${worktreeRootAbs} ` +
|
|
823
|
+
`(${trust.changed ? 'added to' : 'already in'} .claude/settings.local.json)`,
|
|
824
|
+
)
|
|
825
|
+
}
|
|
826
|
+
out.push('')
|
|
827
|
+
out.push(' to provision, run:')
|
|
828
|
+
out.push(
|
|
829
|
+
attached
|
|
830
|
+
? ` git worktree add ${spec.worktreePath} ${spec.branch}`
|
|
831
|
+
: ` git worktree add ${spec.worktreePath} -b ${spec.branch}`,
|
|
832
|
+
)
|
|
833
|
+
if (setupCommands.length) {
|
|
834
|
+
out.push('')
|
|
835
|
+
out.push(' then, in the worktree, run:')
|
|
836
|
+
for (const cmd of setupCommands) out.push(` ${cmd}`)
|
|
837
|
+
}
|
|
838
|
+
if (flags.json) {
|
|
839
|
+
process.stdout.write(
|
|
840
|
+
JSON.stringify(
|
|
841
|
+
{
|
|
842
|
+
name,
|
|
843
|
+
branch: spec.branch,
|
|
844
|
+
worktreePath: spec.worktreePath,
|
|
845
|
+
specless: true,
|
|
846
|
+
attached,
|
|
847
|
+
recorded: !already,
|
|
848
|
+
},
|
|
849
|
+
null,
|
|
850
|
+
2,
|
|
851
|
+
) + '\n',
|
|
852
|
+
)
|
|
853
|
+
return
|
|
854
|
+
}
|
|
855
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function specEnvUp(dir, config, specArg, flags = {}) {
|
|
610
859
|
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
860
|
+
const docs = flags.docs === true
|
|
611
861
|
|
|
612
862
|
// Checkout mode: the branch is built in the primary checkout, so none of the
|
|
613
863
|
// worktree machinery below applies — no slot, no trust entry and no bootstrap.
|
|
@@ -657,21 +907,38 @@ function specEnvUp(dir, config, specArg) {
|
|
|
657
907
|
const worktreeRootAbs = path.dirname(spec.worktreePath)
|
|
658
908
|
const trust = ensureWorktreeDirTrusted(dir, worktreeRootAbs)
|
|
659
909
|
|
|
660
|
-
|
|
910
|
+
// Documents mode brings no stack up, so a Docker spec provisioned this way is
|
|
911
|
+
// a worktree-only run. Asked through `resolveStack`, which `planUp` also
|
|
912
|
+
// calls, so the slot allocated here and the commands planned there cannot
|
|
913
|
+
// disagree about whether there is a stack — they did, for a header-less spec.
|
|
914
|
+
const wantsDocker = resolveStack(spec, config, {
|
|
915
|
+
docs,
|
|
916
|
+
composeFilePresent: composeFilePresent(dir, config),
|
|
917
|
+
}).wantsDocker
|
|
661
918
|
|
|
662
919
|
// Slot allocation is Docker-only: a worktree-only spec never touches the
|
|
663
920
|
// registry (no slot, no port block). Its re-run signal is the worktree already
|
|
664
921
|
// existing on disk (attach the branch, don't `-b`); a Docker spec's is its slot.
|
|
922
|
+
//
|
|
923
|
+
// THE WORKTREE ON DISK COUNTS IN BOTH PATHS, and documents mode is what makes
|
|
924
|
+
// that necessary rather than merely tidy. A Docker spec provisioned with
|
|
925
|
+
// `--docs` allocates no slot, so a later `up` without the flag would read the
|
|
926
|
+
// registry, find nothing, and plan `git worktree add -b <branch>` over a
|
|
927
|
+
// worktree and a branch that both already exist — a plan that cannot run. The
|
|
928
|
+
// stronger signal is the honest one either way: a tree on disk is a re-run,
|
|
929
|
+
// whatever the registry remembers.
|
|
665
930
|
let slot = null
|
|
666
931
|
let attached
|
|
932
|
+
const worktreeOnDisk = fs.existsSync(spec.worktreePath)
|
|
667
933
|
if (wantsDocker) {
|
|
668
934
|
const before = readRegistry(dir, config)
|
|
669
|
-
attached =
|
|
935
|
+
attached =
|
|
936
|
+
Object.prototype.hasOwnProperty.call(before.slots, spec.folder) || worktreeOnDisk
|
|
670
937
|
const alloc = allocateSlot(before, spec.folder)
|
|
671
938
|
slot = alloc.slot
|
|
672
939
|
writeRegistry(dir, config, alloc.registry) // the engine's only write (Docker path)
|
|
673
940
|
} else {
|
|
674
|
-
attached =
|
|
941
|
+
attached = worktreeOnDisk
|
|
675
942
|
}
|
|
676
943
|
|
|
677
944
|
// The tree gate: the same facts the checkout planner gets, all of them read
|
|
@@ -683,7 +950,8 @@ function specEnvUp(dir, config, specArg) {
|
|
|
683
950
|
specFoundOn: upOnFork.foundOn,
|
|
684
951
|
forkRef: spec.baseRef || currentBranch(upGit) || 'HEAD',
|
|
685
952
|
specUntracked: upSpecUntracked,
|
|
686
|
-
|
|
953
|
+
composeFilePresent: composeFilePresent(dir, config),
|
|
954
|
+
}, { docs })
|
|
687
955
|
|
|
688
956
|
if (plan.blocked) {
|
|
689
957
|
process.stdout.write(`spec-env up: blocked — ${plan.reason}.\n`)
|
|
@@ -715,6 +983,11 @@ function specEnvUp(dir, config, specArg) {
|
|
|
715
983
|
// the operator's only evidence of which mode actually resolved — print it
|
|
716
984
|
// whenever it was set explicitly, right or wrong.
|
|
717
985
|
out.push(' mode: worktree (each spec gets its own checkout)')
|
|
986
|
+
// Said positively and only when asked for, so the absent setup step below
|
|
987
|
+
// reads as a choice rather than as a project with nothing configured.
|
|
988
|
+
if (plan.docs) {
|
|
989
|
+
out.push(' docs: documents only — no setup commands, no docker')
|
|
990
|
+
}
|
|
718
991
|
if (trust.reason === 'malformed') {
|
|
719
992
|
out.push(
|
|
720
993
|
' trusted: ! .claude/settings.local.json is not valid JSON — left it;' +
|
|
@@ -749,6 +1022,21 @@ function specEnvUp(dir, config, specArg) {
|
|
|
749
1022
|
process.stdout.write(out.join('\n') + '\n')
|
|
750
1023
|
}
|
|
751
1024
|
|
|
1025
|
+
/**
|
|
1026
|
+
* The recorded specless branches (`/no-spec` work), or an empty map.
|
|
1027
|
+
*
|
|
1028
|
+
* Wrapped because every caller wants the same cannot-tell behaviour: a
|
|
1029
|
+
* malformed or unreadable registry must not stop a resolution that the folder
|
|
1030
|
+
* search can answer on its own (`.claude/rules/negative-checks.md` rule 4).
|
|
1031
|
+
*/
|
|
1032
|
+
function speclessMap(dir, config) {
|
|
1033
|
+
try {
|
|
1034
|
+
return readRegistry(dir, config).specless || {}
|
|
1035
|
+
} catch {
|
|
1036
|
+
return {}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
752
1040
|
// A read-only git reader over `cwd`: returns trimmed stdout, or null on failure.
|
|
753
1041
|
function gitReader(cwd) {
|
|
754
1042
|
return (argv) => {
|
|
@@ -927,7 +1215,11 @@ function specEnvDown(dir, config, specArg, flags) {
|
|
|
927
1215
|
|
|
928
1216
|
const base = resolveBaseBranch(config, gitReader(dir))
|
|
929
1217
|
const worktreeState = worktreeGitState(spec.worktreePath, base)
|
|
930
|
-
const plan = planDown(spec, config, flags, {
|
|
1218
|
+
const plan = planDown(spec, config, flags, {
|
|
1219
|
+
worktreeState,
|
|
1220
|
+
timestamp: compactTimestamp(),
|
|
1221
|
+
composeFilePresent: composeFilePresent(dir, config),
|
|
1222
|
+
})
|
|
931
1223
|
|
|
932
1224
|
if (plan.blocked) {
|
|
933
1225
|
process.stdout.write(
|
|
@@ -939,12 +1231,25 @@ function specEnvDown(dir, config, specArg, flags) {
|
|
|
939
1231
|
|
|
940
1232
|
// Free the slot (the engine's only write on down) — only if one was held; a
|
|
941
1233
|
// worktree-only teardown never touches the registry.
|
|
942
|
-
|
|
943
|
-
|
|
1234
|
+
//
|
|
1235
|
+
// A SPECLESS BRANCH IS FORGOTTEN HERE TOO, and it has to be: the record is the
|
|
1236
|
+
// only thing that made the name resolvable, so leaving it behind would keep a
|
|
1237
|
+
// torn-down `/no-spec` branch answering to `review`, `integrate` and a bare
|
|
1238
|
+
// `resolve` with a worktree path that is no longer on disk. `forgetSpecless`
|
|
1239
|
+
// is idempotent, so this costs a write and nothing else.
|
|
1240
|
+
const wasSpecless = isSpecless(registry, spec.folder)
|
|
1241
|
+
if (hasSlot || wasSpecless) {
|
|
1242
|
+
let next = registry
|
|
1243
|
+
if (hasSlot) next = freeSlot(next, spec.folder)
|
|
1244
|
+
if (wasSpecless) next = forgetSpecless(next, spec.folder)
|
|
1245
|
+
writeRegistry(dir, config, next)
|
|
944
1246
|
}
|
|
945
1247
|
|
|
946
1248
|
const out = []
|
|
947
|
-
out.push(
|
|
1249
|
+
out.push(
|
|
1250
|
+
`spec-env down: ${spec.folder}` +
|
|
1251
|
+
(hasSlot ? ' (slot freed)' : wasSpecless ? ' (specless record forgotten)' : ''),
|
|
1252
|
+
)
|
|
948
1253
|
out.push('')
|
|
949
1254
|
out.push(` worktree: ${spec.worktreePath}`)
|
|
950
1255
|
out.push(` volumes: ${plan.volumesDropped ? 'dropped' : 'kept'}`)
|
|
@@ -1066,7 +1371,7 @@ function volumeCreatedAt(names) {
|
|
|
1066
1371
|
*/
|
|
1067
1372
|
function provisionedSpecChoice(dir, config, cwd = process.cwd()) {
|
|
1068
1373
|
const worktreePaths = liveWorktreePaths(gitReader(dir))
|
|
1069
|
-
const provisioned = allSpecs(dir, config, worktreePaths)
|
|
1374
|
+
const provisioned = allSpecs(dir, config, worktreePaths, speclessMap(dir, config))
|
|
1070
1375
|
.map((s) => ({ folder: s.folder, wt: path.resolve(s.worktreePath) }))
|
|
1071
1376
|
// The primary checkout is itself in `git worktree list`; a spec is
|
|
1072
1377
|
// provisioned only when it has its OWN worktree, separate from it.
|
|
@@ -1146,7 +1451,18 @@ function resolveSpecWithWorktree(dir, config, specArg) {
|
|
|
1146
1451
|
// another's. A worktree left behind by a declined teardown can still answer
|
|
1147
1452
|
// with a stale bucket — that is a leftover to prune, not a lookup to distrust.
|
|
1148
1453
|
const preferDirs = worktreeGuess === dir ? [] : [worktreeGuess]
|
|
1149
|
-
|
|
1454
|
+
// A SPECLESS BRANCH RESOLVES TOO, on the registry's word and nothing else.
|
|
1455
|
+
// `/no-spec` work has a worktree and a branch and no document under
|
|
1456
|
+
// `specs/**`, so every verb below — down, integrate, review, resolve, status —
|
|
1457
|
+
// would refuse it as "spec not found". Passing the recorded map here is what
|
|
1458
|
+
// makes one name resolvable without making every typo resolvable: `resolveSpec`
|
|
1459
|
+
// consults it only after the folder search has come up empty, and only for a
|
|
1460
|
+
// name the engine itself wrote down.
|
|
1461
|
+
return resolveSpec(specArg, dir, config, {
|
|
1462
|
+
searchDirs,
|
|
1463
|
+
preferDirs,
|
|
1464
|
+
specless: speclessMap(dir, config),
|
|
1465
|
+
})
|
|
1150
1466
|
}
|
|
1151
1467
|
|
|
1152
1468
|
// Every spec folder name found under specs/* across the given checkout roots.
|
|
@@ -1204,7 +1520,7 @@ function specEnvPrune(dir, config, flags) {
|
|
|
1204
1520
|
}
|
|
1205
1521
|
|
|
1206
1522
|
const worktrees = liveWorktreePaths(gitReader(dir))
|
|
1207
|
-
const specs = allSpecs(dir, config, worktrees)
|
|
1523
|
+
const specs = allSpecs(dir, config, worktrees, speclessMap(dir, config))
|
|
1208
1524
|
const liveSlugs = liveSlugsForSpecs(specs, worktrees)
|
|
1209
1525
|
|
|
1210
1526
|
const olderThanDays =
|
|
@@ -1582,7 +1898,9 @@ function specEnvResolve(dir, config, specArg, flags = {}) {
|
|
|
1582
1898
|
if (flags.recordPrimary) return recordPrimary(dir, config, r)
|
|
1583
1899
|
if (flags.assertPrimaryClean) return assertPrimaryClean(dir, config, r)
|
|
1584
1900
|
process.stdout.write(
|
|
1585
|
-
|
|
1901
|
+
// A specless branch has no bucket because it has no document — say that
|
|
1902
|
+
// rather than printing `(null)`, which reads as a lookup that failed.
|
|
1903
|
+
`spec: ${r.folder} (${r.specless ? 'no spec' : r.bucket})\n` +
|
|
1586
1904
|
`type/slug: ${r.type} / ${r.slug}\n` +
|
|
1587
1905
|
`branch: ${r.branch}\n` +
|
|
1588
1906
|
`worktree: ${r.worktreePath}\n` +
|
|
@@ -1796,6 +2114,21 @@ function gateTarget(dir, config, specArg) {
|
|
|
1796
2114
|
* `--claim-since` to take, so the rule that a pass is never claimed without a
|
|
1797
2115
|
* person asking (`/spec-diff` §0) is untouched by anything here.
|
|
1798
2116
|
*/
|
|
2117
|
+
/**
|
|
2118
|
+
* How often the wait says it is alive, in ms. `0` disables it.
|
|
2119
|
+
*
|
|
2120
|
+
* Three states, not two (`.claude/rules/negative-checks.md` rule 4): a value
|
|
2121
|
+
* that is not a number is a cannot-tell, and it takes the DEFAULT rather than
|
|
2122
|
+
* disabling — being wrong that way costs a line of stderr nobody reads, where
|
|
2123
|
+
* the opposite silently removes the thing keeping the wait alive.
|
|
2124
|
+
*/
|
|
2125
|
+
function resolveHeartbeatMs(flags) {
|
|
2126
|
+
if (flags.heartbeat === undefined || flags.heartbeat === null) return WAIT_HEARTBEAT_MS
|
|
2127
|
+
const seconds = Number(flags.heartbeat)
|
|
2128
|
+
if (!Number.isFinite(seconds) || seconds < 0) return WAIT_HEARTBEAT_MS
|
|
2129
|
+
return seconds * 1000
|
|
2130
|
+
}
|
|
2131
|
+
|
|
1799
2132
|
async function specEnvReviewWait(dir, config, specArg, flags) {
|
|
1800
2133
|
let spec
|
|
1801
2134
|
try {
|
|
@@ -1836,7 +2169,30 @@ async function specEnvReviewWait(dir, config, specArg, flags) {
|
|
|
1836
2169
|
)
|
|
1837
2170
|
}
|
|
1838
2171
|
|
|
1839
|
-
|
|
2172
|
+
// THE HEARTBEAT GOES TO STDERR, and that is the whole reason it is safe.
|
|
2173
|
+
// Stdout is the result a backgrounded caller parses and, under a monitor,
|
|
2174
|
+
// every stdout line becomes a notification — so a proof-of-life line there
|
|
2175
|
+
// would either corrupt the answer or wake somebody every five minutes.
|
|
2176
|
+
// Stderr reaches the output file and raises neither.
|
|
2177
|
+
//
|
|
2178
|
+
// SUPPRESSED UNDER `--json`, which has one machine-readable payload and must
|
|
2179
|
+
// not grow a second stream beside it.
|
|
2180
|
+
const heartbeatMs = resolveHeartbeatMs(flags)
|
|
2181
|
+
const onHeartbeat =
|
|
2182
|
+
flags.json || heartbeatMs === 0
|
|
2183
|
+
? null
|
|
2184
|
+
: ({ elapsedMs }) => {
|
|
2185
|
+
process.stderr.write(
|
|
2186
|
+
`spec-env review wait: ${spec.folder} — still waiting (${Math.round(elapsedMs / 60000)}m), ` +
|
|
2187
|
+
`window since ${flags.since}\n`,
|
|
2188
|
+
)
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
const result = await waitForPass(() => readPending(out, spec.folder), flags.since, {
|
|
2192
|
+
timeoutMs,
|
|
2193
|
+
onHeartbeat,
|
|
2194
|
+
heartbeatMs: heartbeatMs || WAIT_HEARTBEAT_MS,
|
|
2195
|
+
})
|
|
1840
2196
|
|
|
1841
2197
|
if (flags.json) {
|
|
1842
2198
|
process.stdout.write(`${JSON.stringify({ spec: spec.folder, since: flags.since, ...result }, null, 2)}\n`)
|
|
@@ -1957,6 +2313,24 @@ function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
|
|
|
1957
2313
|
}
|
|
1958
2314
|
}
|
|
1959
2315
|
|
|
2316
|
+
// RECORDED BEFORE THE ANSWER IS WRITTEN, so the offer this call hands back is
|
|
2317
|
+
// also the one it spends. Explicit rather than automatic: a plain read of the
|
|
2318
|
+
// gate must stay a read, because `/spec-next` makes one on every run.
|
|
2319
|
+
//
|
|
2320
|
+
// Best-effort, like the render record. A sidecar that could not be written
|
|
2321
|
+
// costs a second offer, which is the harmless direction — where refusing to
|
|
2322
|
+
// answer would block a commit over a bookkeeping failure.
|
|
2323
|
+
if (flags.offered && judged.state === 'armed' && target.spec) {
|
|
2324
|
+
const marked = markGateOffered(judged.gate, { at: new Date().toISOString() })
|
|
2325
|
+
if (marked.marked) {
|
|
2326
|
+
try {
|
|
2327
|
+
writeGate(target.out, marked.gate)
|
|
2328
|
+
} catch {
|
|
2329
|
+
/* the gate is the guard; this is only its memory of having asked */
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
|
|
1960
2334
|
if (flags.json) {
|
|
1961
2335
|
process.stdout.write(
|
|
1962
2336
|
JSON.stringify(
|
|
@@ -1967,6 +2341,9 @@ function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
|
|
|
1967
2341
|
armedAt: judged.gate ? judged.gate.armedAt : null,
|
|
1968
2342
|
phase: judged.gate ? judged.gate.phase : null,
|
|
1969
2343
|
required: config.review.required,
|
|
2344
|
+
// Absent stays absent: only an `armed` gate has a way out to declare,
|
|
2345
|
+
// and every cannot-tell state carries none.
|
|
2346
|
+
...(judged.offer ? { offer: judged.offer } : {}),
|
|
1970
2347
|
log: judged.gate && Array.isArray(judged.gate.log) ? judged.gate.log : [],
|
|
1971
2348
|
},
|
|
1972
2349
|
null,
|
|
@@ -2187,6 +2564,60 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
|
|
|
2187
2564
|
// of the wrong spec looks exactly like a review of the right one.
|
|
2188
2565
|
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
2189
2566
|
|
|
2567
|
+
// THE SIDECAR PATHS, RESOLVED BEFORE THE WORKTREE CHECK — and that ordering
|
|
2568
|
+
// is the whole of this feature. `.spec-env/reviews/` lives in the PRIMARY
|
|
2569
|
+
// CHECKOUT, keyed by the spec's folder name; not one byte of it comes from
|
|
2570
|
+
// the worktree. `--out` moves the page and its sidecars together.
|
|
2571
|
+
const out = reviewOutPath(dir, spec.folder, flags.out)
|
|
2572
|
+
|
|
2573
|
+
// The other half of a confirmation: a pass the operator says is not theirs.
|
|
2574
|
+
// Left in the store it is reported on every render until they stop reading the
|
|
2575
|
+
// line — which is how the real one gets waved away too.
|
|
2576
|
+
//
|
|
2577
|
+
// AHEAD OF THE WORKTREE CHECK, because a pass that needs disowning has almost
|
|
2578
|
+
// always outlived its spec. `review waiting` finds the stranded ones and tells
|
|
2579
|
+
// the reader to `--drop` them; refusing that on a missing worktree made the
|
|
2580
|
+
// hint impossible to follow for every pass it could find, and ten real passes
|
|
2581
|
+
// sat behind it. Disowning reads and writes this one file, so the tree it was
|
|
2582
|
+
// being refused for was never involved.
|
|
2583
|
+
//
|
|
2584
|
+
// IT DOES NOT RETURN when it succeeds. A spec that still HAS a worktree falls
|
|
2585
|
+
// through to the render exactly as before, so the page is rewritten without
|
|
2586
|
+
// the pass on it; the gate below is what turns a successful drop into an
|
|
2587
|
+
// ending, and only when there is no page left to write.
|
|
2588
|
+
//
|
|
2589
|
+
// WHAT WOULD FOOL THIS: a spec whose FOLDER was deleted rather than completed.
|
|
2590
|
+
// `resolveSpecWithWorktree` above needs a document to resolve a name, so such
|
|
2591
|
+
// a pass stays unreachable by name however this gate is ordered — only
|
|
2592
|
+
// `spec-env review waiting`, which scans the store directly, can see it.
|
|
2593
|
+
// Relaxing the worktree check does not cover that case and was never meant to:
|
|
2594
|
+
// the two absences are different, and only one of them is what the ten
|
|
2595
|
+
// stranded passes were behind.
|
|
2596
|
+
let dropped = null
|
|
2597
|
+
if (flags.drop) {
|
|
2598
|
+
const heldRead = readPending(out, spec.folder)
|
|
2599
|
+
if (heldRead.corrupt) {
|
|
2600
|
+
process.stdout.write(
|
|
2601
|
+
`spec-env review: ${reviewPendingPath(out)} is not readable JSON — ` +
|
|
2602
|
+
'move it aside rather than losing the passes it holds.\n',
|
|
2603
|
+
)
|
|
2604
|
+
return
|
|
2605
|
+
}
|
|
2606
|
+
// Same match and the SAME SILENCE as a claim. A drop that listed the codes
|
|
2607
|
+
// it could not find would hand a guesser exactly what the claim withholds.
|
|
2608
|
+
const result = claimPending(heldRead.pending, String(flags.drop).trim())
|
|
2609
|
+
if (!result.pass) {
|
|
2610
|
+
process.stdout.write(
|
|
2611
|
+
`spec-env review: no pending pass with that code` +
|
|
2612
|
+
`${result.count ? ` (${result.count} waiting)` : ''}\n`,
|
|
2613
|
+
)
|
|
2614
|
+
return
|
|
2615
|
+
}
|
|
2616
|
+
writePending(out, result.pending)
|
|
2617
|
+
// Nothing is merged, and the notes sidecar is not touched.
|
|
2618
|
+
dropped = { code: result.pass.code, remaining: result.count }
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2190
2621
|
// `--docs` reviews the spec's OWN DOCUMENTS from the tree in hand, and so
|
|
2191
2622
|
// never consults a worktree — a spec in `backlog/` has none, which is exactly
|
|
2192
2623
|
// the case with no page today.
|
|
@@ -2220,9 +2651,63 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
|
|
|
2220
2651
|
}
|
|
2221
2652
|
})()
|
|
2222
2653
|
if (!docs && !liveHere && !fs.existsSync(spec.worktreePath)) {
|
|
2654
|
+
// A DROP THAT ALREADY HAPPENED IS AN ENDING, not a refusal. The work this
|
|
2655
|
+
// invocation asked for is done and written; there is simply no page left to
|
|
2656
|
+
// rewrite, which is a fact about the spec rather than a failure of the run.
|
|
2657
|
+
if (dropped) {
|
|
2658
|
+
// THE SAME SPELLING the render uses for the same event. A drop reported
|
|
2659
|
+
// one way here and another way there is two events as far as anyone
|
|
2660
|
+
// grepping their scrollback is concerned.
|
|
2661
|
+
process.stdout.write(
|
|
2662
|
+
`spec-env review: ${spec.folder}\n` +
|
|
2663
|
+
` dropped: ${dropped.code} — merged nothing` +
|
|
2664
|
+
`${dropped.remaining ? ` (${dropped.remaining} still waiting)` : ''}\n` +
|
|
2665
|
+
' no worktree, so nothing was rendered.\n',
|
|
2666
|
+
)
|
|
2667
|
+
return
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
// THE REFUSAL NAMES THE EXIT THAT WORKS. It used to say "run /spec-start to
|
|
2671
|
+
// provision it", which for a completed spec is advice to resurrect it in
|
|
2672
|
+
// order to throw a pass away — and that was the only advice a reader holding
|
|
2673
|
+
// a stranded pass ever got.
|
|
2674
|
+
//
|
|
2675
|
+
// A STRANDED PASS STAYS UNCLAIMABLE, deliberately. Once the branch has
|
|
2676
|
+
// merged and the worktree is gone a `commit` verdict has nowhere to land, so
|
|
2677
|
+
// honouring one would report work that did not happen. Disowning is the only
|
|
2678
|
+
// honest ending, so it is the only one offered.
|
|
2679
|
+
// WHICH MESSAGE depends on a POSITIVE SIGNAL — a pass actually sitting in
|
|
2680
|
+
// the store. That is not decoration: the two readers are in opposite
|
|
2681
|
+
// situations, and one sentence cannot serve both.
|
|
2682
|
+
//
|
|
2683
|
+
// No pass waiting is overwhelmingly the commonest case — a spec in
|
|
2684
|
+
// `backlog/` that nobody has started — and there `/spec-start` is exactly
|
|
2685
|
+
// the right advice, so that message is left byte-for-byte as it was.
|
|
2686
|
+
//
|
|
2687
|
+
// A pass waiting means the spec has almost certainly finished, and telling
|
|
2688
|
+
// that reader to provision a worktree is telling them to resurrect a
|
|
2689
|
+
// completed spec in order to throw a pass away. A corrupt store reads as
|
|
2690
|
+
// zero, so it takes the untouched message rather than inventing a count.
|
|
2691
|
+
const held = readPending(out, spec.folder)
|
|
2692
|
+
const count = held.corrupt ? 0 : (held.pending.passes || []).length
|
|
2693
|
+
if (!count) {
|
|
2694
|
+
process.stdout.write(
|
|
2695
|
+
`spec-env review: ${spec.folder} has no worktree at ${spec.worktreePath} — ` +
|
|
2696
|
+
'run /spec-start to provision it, or --docs to review the spec itself.\n',
|
|
2697
|
+
)
|
|
2698
|
+
return
|
|
2699
|
+
}
|
|
2223
2700
|
process.stdout.write(
|
|
2224
|
-
`spec-env review: ${spec.folder} has no worktree at ${spec.worktreePath}
|
|
2225
|
-
|
|
2701
|
+
`spec-env review: ${spec.folder} has no worktree at ${spec.worktreePath}.\n` +
|
|
2702
|
+
` ${count} waiting — disown ${count === 1 ? 'it' : 'one'} with --drop <code>, ` +
|
|
2703
|
+
'or `spec-env review waiting` to list them.\n' +
|
|
2704
|
+
// WHY CLAIMING IS NOT OFFERED, said rather than left to be discovered.
|
|
2705
|
+
// A stranded pass is unclaimable by design: with no worktree a `commit`
|
|
2706
|
+
// verdict has nowhere to land, so honouring one would report work that
|
|
2707
|
+
// did not happen. Disowning is the only honest ending, so it is the
|
|
2708
|
+
// only one named.
|
|
2709
|
+
' It cannot be claimed: with no worktree, a verdict has nowhere to land.\n' +
|
|
2710
|
+
" --docs reviews the spec's own documents.\n",
|
|
2226
2711
|
)
|
|
2227
2712
|
return
|
|
2228
2713
|
}
|
|
@@ -2284,9 +2769,6 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
|
|
|
2284
2769
|
mode = 'branch'
|
|
2285
2770
|
}
|
|
2286
2771
|
|
|
2287
|
-
// The sidecar is keyed to the page's path, so resolve that first — `--out`
|
|
2288
|
-
// moves both together.
|
|
2289
|
-
const out = reviewOutPath(dir, spec.folder, flags.out)
|
|
2290
2772
|
const stored = readNotes(out, spec.folder)
|
|
2291
2773
|
let notes = stored.notes
|
|
2292
2774
|
let merged = null
|
|
@@ -2414,37 +2896,27 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
|
|
|
2414
2896
|
accepted: parsed.accepted.length,
|
|
2415
2897
|
unaccepted: parsed.unaccepted.length,
|
|
2416
2898
|
comments: parsed.comments.length,
|
|
2899
|
+
// THE INSTRUCTION THE PASS CARRIED, and the one field here that is not a
|
|
2900
|
+
// count. An action blob concludes nothing — it asks for something to be
|
|
2901
|
+
// done and hands the page straight back — so a claim that reported only
|
|
2902
|
+
// the counts was indistinguishable from a pass carrying no decision at
|
|
2903
|
+
// all, and `/spec-diff` §2b could never fire. That is what a pressed
|
|
2904
|
+
// `live-on` looked like: "0 accepts, 0 withdrawn, 0 comments".
|
|
2905
|
+
//
|
|
2906
|
+
// `null` rather than absent, deliberately. Every other optional key on
|
|
2907
|
+
// this payload is spread away so an existing consumer sees nothing new,
|
|
2908
|
+
// but `claimed` is itself conditional — anything reading it was written
|
|
2909
|
+
// after this shipped, and a present `null` is what separates "carried no
|
|
2910
|
+
// action" from "an engine too old to say".
|
|
2911
|
+
//
|
|
2912
|
+
// It is NOT a verdict and must never reach `sentVerdict`: `ACTIONS` is
|
|
2913
|
+
// disjoint from `VERDICTS` by construction, and the gate reads the
|
|
2914
|
+
// verdict. This is a field beside it, not a widening of it.
|
|
2915
|
+
action: parsed.action || null,
|
|
2417
2916
|
}
|
|
2418
2917
|
merged = { accepted: claimed.accepted, unaccepted: claimed.unaccepted, comments: claimed.comments }
|
|
2419
2918
|
}
|
|
2420
2919
|
|
|
2421
|
-
// The other half of a confirmation: a pass the operator says is not theirs.
|
|
2422
|
-
// Left in the store it is reported on every render until they stop reading the
|
|
2423
|
-
// line — which is how the real one gets waved away too.
|
|
2424
|
-
let dropped = null
|
|
2425
|
-
if (flags.drop) {
|
|
2426
|
-
const heldRead = readPending(out, spec.folder)
|
|
2427
|
-
if (heldRead.corrupt) {
|
|
2428
|
-
process.stdout.write(
|
|
2429
|
-
`spec-env review: ${reviewPendingPath(out)} is not readable JSON — ` +
|
|
2430
|
-
'move it aside rather than losing the passes it holds.\n',
|
|
2431
|
-
)
|
|
2432
|
-
return
|
|
2433
|
-
}
|
|
2434
|
-
// Same match and the SAME SILENCE as a claim. A drop that listed the codes
|
|
2435
|
-
// it could not find would hand a guesser exactly what the claim withholds.
|
|
2436
|
-
const result = claimPending(heldRead.pending, String(flags.drop).trim())
|
|
2437
|
-
if (!result.pass) {
|
|
2438
|
-
process.stdout.write(
|
|
2439
|
-
`spec-env review: no pending pass with that code` +
|
|
2440
|
-
`${result.count ? ` (${result.count} waiting)` : ''}\n`,
|
|
2441
|
-
)
|
|
2442
|
-
return
|
|
2443
|
-
}
|
|
2444
|
-
writePending(out, result.pending)
|
|
2445
|
-
// Nothing is merged, and the notes sidecar is not touched.
|
|
2446
|
-
dropped = { code: result.pass.code, remaining: result.count }
|
|
2447
|
-
}
|
|
2448
2920
|
|
|
2449
2921
|
if (flags.notes) {
|
|
2450
2922
|
// Refuse rather than write over notes we could not read: an unreadable
|
|
@@ -2689,8 +3161,79 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
|
|
|
2689
3161
|
data.review = JSON.parse(raw)
|
|
2690
3162
|
}
|
|
2691
3163
|
|
|
3164
|
+
// THE MACHINE HALF. It composes with the written review rather than replacing
|
|
3165
|
+
// it: a page can carry a person's read and a tool's findings at once, and
|
|
3166
|
+
// which is which is on every check as its `source`.
|
|
3167
|
+
//
|
|
3168
|
+
// `--docs` deliberately never runs them. A spec document is prose, not the
|
|
3169
|
+
// code these tools read, and spending a rate-limited review on one is waste.
|
|
3170
|
+
let reviewerOutcomes = null
|
|
3171
|
+
if (flags.runReviewers && !docs && (config.review.reviewers || []).length) {
|
|
3172
|
+
const scope = mode === 'branch' ? 'branch' : 'working'
|
|
3173
|
+
const diffHash = diffHashOf(data.files)
|
|
3174
|
+
const { cache, corrupt } = readChecks(out, spec.folder)
|
|
3175
|
+
let checks
|
|
3176
|
+
let outcomes
|
|
3177
|
+
if (!corrupt && cacheHit(cache, spec.folder, diffHash)) {
|
|
3178
|
+
// KEYED ON THE DIFF, NOT A CLOCK. Re-rendering unchanged work must not
|
|
3179
|
+
// spend another review against an hourly limit — and any change at all
|
|
3180
|
+
// must re-run, because findings about code that has moved on are worse
|
|
3181
|
+
// than no findings.
|
|
3182
|
+
checks = cache.checks
|
|
3183
|
+
outcomes = asCached(cache.outcomes)
|
|
3184
|
+
} else {
|
|
3185
|
+
const ran = await runReviewers(config, {
|
|
3186
|
+
worktree: spec.worktreePath,
|
|
3187
|
+
base: base || reviewBase(),
|
|
3188
|
+
scope,
|
|
3189
|
+
spec: spec.folder,
|
|
3190
|
+
adapters: BUNDLED_ADAPTERS,
|
|
3191
|
+
})
|
|
3192
|
+
checks = ran.checks
|
|
3193
|
+
outcomes = ran.outcomes
|
|
3194
|
+
// Best-effort. A cache that could not be written costs a re-run next
|
|
3195
|
+
// time, which is the harmless direction — it must never fail the render.
|
|
3196
|
+
try {
|
|
3197
|
+
writeChecks(out, {
|
|
3198
|
+
version: CHECKS_VERSION,
|
|
3199
|
+
spec: spec.folder,
|
|
3200
|
+
diffHash,
|
|
3201
|
+
at: now,
|
|
3202
|
+
checks,
|
|
3203
|
+
outcomes,
|
|
3204
|
+
})
|
|
3205
|
+
} catch {
|
|
3206
|
+
/* a cache is an optimisation, never a record */
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
reviewerOutcomes = outcomes
|
|
3210
|
+
// The written review's checks lead — a person's read is what the reader
|
|
3211
|
+
// came for — and the machine's follow, grouped by the reviewer that
|
|
3212
|
+
// produced them because `runReviewers` runs in sequence.
|
|
3213
|
+
data.review = {
|
|
3214
|
+
...(data.review || {}),
|
|
3215
|
+
checks: [...((data.review && data.review.checks) || []), ...checks],
|
|
3216
|
+
reviewers: outcomes,
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
|
|
2692
3220
|
writeReviewPage(out, renderReviewPage(data, { reviewHtml: renderReviewBlock(data.review) }))
|
|
2693
3221
|
|
|
3222
|
+
// RECORD WHAT THIS RENDER DECLARED, for the served copy to read back. The
|
|
3223
|
+
// daemon re-renders per request and is not the caller, so without this it can
|
|
3224
|
+
// only see what the work IS — enough for `authoring`, `nospec` and
|
|
3225
|
+
// `committing`, and blind to `midrun` and `refresh`, which say whether the run
|
|
3226
|
+
// had finished. `buttonsForView` takes only those two from here.
|
|
3227
|
+
//
|
|
3228
|
+
// Best-effort, like the reviewer cache: a record that could not be written
|
|
3229
|
+
// costs a served page its narrowing, which is the harmless direction, and it
|
|
3230
|
+
// must never fail a render.
|
|
3231
|
+
try {
|
|
3232
|
+
writeRenderRecord(out, { spec: spec.folder, buttons, at: now })
|
|
3233
|
+
} catch {
|
|
3234
|
+
/* the page is the artefact; this is a note for the daemon */
|
|
3235
|
+
}
|
|
3236
|
+
|
|
2694
3237
|
// The publish-ready copy, ONLY when asked. An ordinary render must not pay for
|
|
2695
3238
|
// a second copy of the whole diff on disk for a path most renders never take.
|
|
2696
3239
|
let publishCopy = null
|
|
@@ -2823,6 +3366,10 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
|
|
|
2823
3366
|
// reporting it would make every existing consumer see a new key.
|
|
2824
3367
|
...(data.buttons ? { buttons: data.buttons } : {}),
|
|
2825
3368
|
reviewed: Boolean(data.review),
|
|
3369
|
+
// Absent unless reviewers actually ran, so a consumer that never
|
|
3370
|
+
// configured one sees no new key. An empty ARRAY would be a claim
|
|
3371
|
+
// that they ran and said nothing.
|
|
3372
|
+
...(reviewerOutcomes ? { reviewers: reviewerOutcomes } : {}),
|
|
2826
3373
|
totals: data.totals,
|
|
2827
3374
|
notes: data.notes,
|
|
2828
3375
|
merged,
|
|
@@ -2882,7 +3429,13 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
|
|
|
2882
3429
|
: '') +
|
|
2883
3430
|
(claimed
|
|
2884
3431
|
? ` claimed: ${claimed.code} — ${claimed.accepted} accept${claimed.accepted === 1 ? '' : 's'}, ` +
|
|
2885
|
-
`${claimed.unaccepted} withdrawn, ${claimed.comments} comment${claimed.comments === 1 ? '' : 's'}
|
|
3432
|
+
`${claimed.unaccepted} withdrawn, ${claimed.comments} comment${claimed.comments === 1 ? '' : 's'}` +
|
|
3433
|
+
// Last on the line rather than folded into the counts: a reader
|
|
3434
|
+
// scanning for what a press did finds the verb at the end of the one
|
|
3435
|
+
// line the claim prints, and a pass with no action reads exactly as it
|
|
3436
|
+
// did before this existed.
|
|
3437
|
+
(claimed.action ? ` · action: ${claimed.action}` : '') +
|
|
3438
|
+
'\n'
|
|
2886
3439
|
: '') +
|
|
2887
3440
|
(dropped ? ` dropped: ${dropped.code} — merged nothing\n` : '') +
|
|
2888
3441
|
(waiting.length
|
|
@@ -3735,6 +4288,26 @@ async function specEnvConnect(dir, config, specArg) {
|
|
|
3735
4288
|
|
|
3736
4289
|
// Dispatch `skitterspec spec-env <sub> [args] [--dir path]`. No-ops with a clear
|
|
3737
4290
|
// message when the feature isn't enabled (no specs/.core/env.config.json).
|
|
4291
|
+
// Is the project's configured compose file actually on disk?
|
|
4292
|
+
//
|
|
4293
|
+
// Read from the PRIMARY CHECKOUT rather than the worktree, and that is forced
|
|
4294
|
+
// rather than chosen: a fresh provision plans before `git worktree add` runs, so
|
|
4295
|
+
// the worktree does not exist yet to be looked in. A compose file is committed
|
|
4296
|
+
// at the repo root in every layout this supports, so the two answers agree
|
|
4297
|
+
// wherever both could be read.
|
|
4298
|
+
//
|
|
4299
|
+
// Any failure to look is `undefined` — cannot-tell, which callers route to the
|
|
4300
|
+
// branch that does not escalate — never `false`.
|
|
4301
|
+
function composeFilePresent(dir, config) {
|
|
4302
|
+
const file = config && config.docker && config.docker.composeFile
|
|
4303
|
+
if (!file) return undefined
|
|
4304
|
+
try {
|
|
4305
|
+
return fs.existsSync(path.join(dir, file))
|
|
4306
|
+
} catch {
|
|
4307
|
+
return undefined
|
|
4308
|
+
}
|
|
4309
|
+
}
|
|
4310
|
+
|
|
3738
4311
|
// Run a mutating git command; return { ok, err } (stderr captured, not swallowed).
|
|
3739
4312
|
function runGit(cwd, args) {
|
|
3740
4313
|
try {
|
|
@@ -3776,7 +4349,7 @@ async function specEnvLive(dir, config, positional, flags) {
|
|
|
3776
4349
|
specEnvLiveStatus(dir, config, specArg, flags)
|
|
3777
4350
|
break
|
|
3778
4351
|
case 'take':
|
|
3779
|
-
await specEnvLiveTake(dir, config, specArg)
|
|
4352
|
+
await specEnvLiveTake(dir, config, specArg, flags)
|
|
3780
4353
|
break
|
|
3781
4354
|
case 'release':
|
|
3782
4355
|
await specEnvLiveRelease(dir, config, specArg)
|
|
@@ -3865,7 +4438,7 @@ function bareLive(dir, config) {
|
|
|
3865
4438
|
|
|
3866
4439
|
// Take the running instance: rebase the spec's branch onto base, free it from its
|
|
3867
4440
|
// worktree, and check it out in the primary checkout so the dev server reloads it.
|
|
3868
|
-
async function specEnvLiveTake(dir, config, specArg) {
|
|
4441
|
+
async function specEnvLiveTake(dir, config, specArg, flags = {}) {
|
|
3869
4442
|
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
3870
4443
|
|
|
3871
4444
|
// Probe the primary checkout's git state (IO stays here; the planner is pure).
|
|
@@ -3913,7 +4486,34 @@ async function specEnvLiveTake(dir, config, specArg) {
|
|
|
3913
4486
|
})
|
|
3914
4487
|
|
|
3915
4488
|
if (plan.blocked) {
|
|
3916
|
-
|
|
4489
|
+
// A MACHINE-READABLE REFUSAL, so a caller reads the offer rather than
|
|
4490
|
+
// parsing the sentence. `take` had no `--json` at all, and a skill
|
|
4491
|
+
// scraping `blocked — …` prose for a command to run is exactly the kind of
|
|
4492
|
+
// second implementation that drifts out of step with the first.
|
|
4493
|
+
if (flags.json) {
|
|
4494
|
+
process.stdout.write(
|
|
4495
|
+
JSON.stringify(
|
|
4496
|
+
{
|
|
4497
|
+
spec: spec.folder,
|
|
4498
|
+
blocked: true,
|
|
4499
|
+
reason: plan.reason,
|
|
4500
|
+
// Absent stays absent — most refusals have no way out to declare.
|
|
4501
|
+
...(plan.offer ? { offer: plan.offer } : {}),
|
|
4502
|
+
},
|
|
4503
|
+
null,
|
|
4504
|
+
2,
|
|
4505
|
+
) + '\n',
|
|
4506
|
+
)
|
|
4507
|
+
return
|
|
4508
|
+
}
|
|
4509
|
+
// THE OFFER IS NAMED IN THE TEXT TOO, not only in `--json`. A reader in a
|
|
4510
|
+
// plain terminal is the one person a picker cannot reach, and they are
|
|
4511
|
+
// entitled to the same way out — so the refusal prints what it would have
|
|
4512
|
+
// offered rather than making a harness the only route to it.
|
|
4513
|
+
process.stdout.write(
|
|
4514
|
+
`spec-env live take: blocked — ${plan.reason}.\n` +
|
|
4515
|
+
(plan.offer ? ` ${plan.offer.label}: ${plan.offer.command}\n` : ''),
|
|
4516
|
+
)
|
|
3917
4517
|
return
|
|
3918
4518
|
}
|
|
3919
4519
|
|
|
@@ -4210,10 +4810,13 @@ async function specEnv(rest) {
|
|
|
4210
4810
|
branch: false,
|
|
4211
4811
|
out: null,
|
|
4212
4812
|
review: null,
|
|
4813
|
+
runReviewers: false,
|
|
4213
4814
|
notes: null,
|
|
4214
4815
|
resolve: null,
|
|
4215
4816
|
outcome: null,
|
|
4216
4817
|
claim: null,
|
|
4818
|
+
offered: false,
|
|
4819
|
+
heartbeat: null,
|
|
4217
4820
|
drop: null,
|
|
4218
4821
|
buttons: null,
|
|
4219
4822
|
json: false,
|
|
@@ -4235,6 +4838,7 @@ async function specEnv(rest) {
|
|
|
4235
4838
|
else if (args[i] === '--buttons') flags.buttons = args[++i]
|
|
4236
4839
|
else if (args[i] === '--out') flags.out = args[++i]
|
|
4237
4840
|
else if (args[i] === '--review') flags.review = args[++i]
|
|
4841
|
+
else if (args[i] === '--run-reviewers') flags.runReviewers = true
|
|
4238
4842
|
else if (args[i] === '--notes') flags.notes = args[++i]
|
|
4239
4843
|
else if (args[i] === '--verdict') flags.verdict = args[++i]
|
|
4240
4844
|
// `--set` keeps an EMPTY STRING rather than coercing it away: empty is the
|
|
@@ -4246,15 +4850,18 @@ async function specEnv(rest) {
|
|
|
4246
4850
|
else if (args[i] === '--outcome') flags.outcome = args[++i]
|
|
4247
4851
|
else if (args[i] === '--claim') flags.claim = args[++i]
|
|
4248
4852
|
else if (args[i] === '--claim-since') flags.claimSince = args[++i]
|
|
4853
|
+
else if (args[i] === '--heartbeat') flags.heartbeat = args[++i]
|
|
4249
4854
|
else if (args[i] === '--since') flags.since = args[++i]
|
|
4250
4855
|
else if (args[i] === '--timeout') flags.timeout = args[++i]
|
|
4251
4856
|
else if (args[i] === '--drop') flags.drop = args[++i]
|
|
4252
4857
|
else if (args[i] === '--json') flags.json = true
|
|
4253
4858
|
else if (args[i] === '--check') flags.check = true
|
|
4254
4859
|
else if (args[i] === '--for-command') flags.forCommand = args[++i]
|
|
4860
|
+
else if (args[i] === '--offered') flags.offered = true
|
|
4255
4861
|
else if (args[i] === '--phase') flags.phase = args[++i]
|
|
4256
4862
|
else if (args[i] === '--record-primary') flags.recordPrimary = true
|
|
4257
4863
|
else if (args[i] === '--docs') flags.docs = true
|
|
4864
|
+
else if (args[i] === '--session') flags.session = args[++i]
|
|
4258
4865
|
else if (args[i] === '--assert-primary-clean') flags.assertPrimaryClean = true
|
|
4259
4866
|
else positional.push(args[i])
|
|
4260
4867
|
}
|
|
@@ -4268,7 +4875,7 @@ async function specEnv(rest) {
|
|
|
4268
4875
|
// paths, and the registry identically whether run from main or a worktree.
|
|
4269
4876
|
dir = resolvePrimaryCheckout(dir, gitReader(dir))
|
|
4270
4877
|
|
|
4271
|
-
const { config, present, unknown } = loadEnvConfig(dir)
|
|
4878
|
+
const { config, present, unknown, badReviewers } = loadEnvConfig(dir)
|
|
4272
4879
|
if (!present) {
|
|
4273
4880
|
process.stdout.write(
|
|
4274
4881
|
'spec-env: isolation not enabled (no specs/.core/env.config.json).\n' +
|
|
@@ -4295,9 +4902,43 @@ async function specEnv(rest) {
|
|
|
4295
4902
|
process.stderr.write(`spec-env: ${ENV_CONFIG_LABEL} — unknown key "${key}" is ignored.\n`)
|
|
4296
4903
|
}
|
|
4297
4904
|
|
|
4905
|
+
// The same advisory, for the one place the check above structurally cannot
|
|
4906
|
+
// reach: `review.reviewers` is an array, and `collectUnknownKeys` deliberately
|
|
4907
|
+
// does not walk into one. Dropping an entry silently is worse than dropping a
|
|
4908
|
+
// key silently — the page then shows no line for that reviewer at all, which
|
|
4909
|
+
// reads as "not configured" rather than "configured wrong".
|
|
4910
|
+
for (const bad of badReviewers || []) {
|
|
4911
|
+
process.stderr.write(
|
|
4912
|
+
`spec-env: ${ENV_CONFIG_LABEL} — review.reviewers[${bad.index}] ${bad.reason}; it is ignored.\n`,
|
|
4913
|
+
)
|
|
4914
|
+
}
|
|
4915
|
+
|
|
4916
|
+
// A `use` NAMING AN ADAPTER THIS BUILD DOES NOT HAVE, said here rather than
|
|
4917
|
+
// waiting for the run to report it. The config module deliberately does not
|
|
4918
|
+
// know the registry — shape is its business and the adapter list is the
|
|
4919
|
+
// runner's — so the check lives at the one place that can see both.
|
|
4920
|
+
//
|
|
4921
|
+
// It is advisory like the two above: an older engine reading a config written
|
|
4922
|
+
// for a newer one is a forward-compat gap, not a mistake, and the run reports
|
|
4923
|
+
// it again as that reviewer's own outcome either way.
|
|
4924
|
+
for (const r of config.review.reviewers || []) {
|
|
4925
|
+
if (r.use && !Object.prototype.hasOwnProperty.call(BUNDLED_ADAPTERS, r.use)) {
|
|
4926
|
+
process.stderr.write(
|
|
4927
|
+
`spec-env: ${ENV_CONFIG_LABEL} — review.reviewers "${r.use}" is not a bundled adapter ` +
|
|
4928
|
+
`(have: ${Object.keys(BUNDLED_ADAPTERS).join(', ') || 'none'}); it will not run.\n`,
|
|
4929
|
+
)
|
|
4930
|
+
}
|
|
4931
|
+
}
|
|
4932
|
+
|
|
4298
4933
|
switch (sub) {
|
|
4299
4934
|
case 'up':
|
|
4300
|
-
specEnvUp(dir, config, positional[0])
|
|
4935
|
+
specEnvUp(dir, config, positional[0], flags)
|
|
4936
|
+
break
|
|
4937
|
+
case 'nospec':
|
|
4938
|
+
specEnvNospec(dir, config, positional[0], flags)
|
|
4939
|
+
break
|
|
4940
|
+
case 'main':
|
|
4941
|
+
specEnvMain(dir, config, positional, flags, present, invokedFrom)
|
|
4301
4942
|
break
|
|
4302
4943
|
case 'down':
|
|
4303
4944
|
specEnvDown(dir, config, positional[0], flags)
|
|
@@ -4371,7 +5012,7 @@ async function specEnv(rest) {
|
|
|
4371
5012
|
break
|
|
4372
5013
|
default:
|
|
4373
5014
|
process.stdout.write(
|
|
4374
|
-
`Usage: skitterspec spec-env <${SPEC_ENV_VERBS.join('|')}> [spec] [--keep-volumes] [--force] [--also <tag>] [--older-than <days>] [--branch] [--out <file>] [--review <json>] [--notes <json>] [--verdict <word>] [--resolve <json>] [--outcome <text>] [--claim <code>] [--drop <code>] [--buttons <set>] [--json] [--record-primary] [--assert-primary-clean]\n` +
|
|
5015
|
+
`Usage: skitterspec spec-env <${SPEC_ENV_VERBS.join('|')}> [spec] [--keep-volumes] [--force] [--also <tag>] [--older-than <days>] [--branch] [--out <file>] [--review <json>] [--run-reviewers] [--notes <json>] [--verdict <word>] [--resolve <json>] [--outcome <text>] [--claim <code>] [--drop <code>] [--buttons <set>] [--json] [--record-primary] [--assert-primary-clean]\n` +
|
|
4375
5016
|
' review serve [--port <n>] [--host <addr>] [--stop] [--status] serve every diff locally\n' +
|
|
4376
5017
|
' review serve --rotate-token mint a new URL token; every handed-out link dies\n' +
|
|
4377
5018
|
' review allow <network|remote> [--off] permit a review tier (writes env.config.json)\n' +
|
|
@@ -4379,6 +5020,8 @@ async function specEnv(rest) {
|
|
|
4379
5020
|
' review gate [spec] [--check] [--json] is one owed? --check exits non-zero if so\n' +
|
|
4380
5021
|
' [--for-command <cmdline>] ...but only when that command is a git commit\n' +
|
|
4381
5022
|
' review skip "<reason>" move on without one, on the record\n' +
|
|
5023
|
+
' review gate [spec] --offered record that the bypass was offered (spends it)\n' +
|
|
5024
|
+
' review wait [spec] --heartbeat <secs> proof-of-life cadence on stderr; 0 disables\n' +
|
|
4382
5025
|
' review wait [spec] --since <iso> block until a verdict arrives ([--timeout <s>])\n' +
|
|
4383
5026
|
' review waiting [--json] every pass waiting, across every spec\n' +
|
|
4384
5027
|
' review [spec] --claim-since <iso> claim the one pass that arrived since <iso>\n' +
|