@skitterbyte/skitterspec 22.0.0 → 22.2.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.
Files changed (37) hide show
  1. package/README.md +14 -0
  2. package/assets/claude-md-section.md +13 -0
  3. package/assets/commands/allow-main.md +33 -0
  4. package/assets/commands/spec-skip.md +24 -0
  5. package/assets/core/env.config.json.example +2 -1
  6. package/assets/core/env.config.md +83 -2
  7. package/assets/hooks/main-guard.cjs +145 -0
  8. package/assets/hooks/review-gate.cjs +8 -3
  9. package/assets/review/page.html +132 -2
  10. package/assets/rules/offered-unblocks.md +100 -0
  11. package/assets/rules/spec-planning.md +160 -11
  12. package/assets/rules/spec-reports.md +66 -9
  13. package/assets/skills/no-spec/SKILL.md +221 -0
  14. package/assets/skills/spec/SKILL.md +138 -37
  15. package/assets/skills/spec-bug/SKILL.md +8 -2
  16. package/assets/skills/spec-cancel/SKILL.md +29 -0
  17. package/assets/skills/spec-complete/SKILL.md +25 -0
  18. package/assets/skills/spec-diff/SKILL.md +36 -2
  19. package/assets/skills/spec-init/SKILL.md +4 -2
  20. package/assets/skills/spec-next/SKILL.md +50 -3
  21. package/assets/skills/spec-reviewed/SKILL.md +3 -3
  22. package/assets/skills/spec-start/SKILL.md +14 -0
  23. package/package.json +1 -1
  24. package/src/cli.js +749 -62
  25. package/src/env/config.js +121 -4
  26. package/src/env/hooks.js +102 -33
  27. package/src/env/live.js +30 -1
  28. package/src/env/mainguard.js +240 -0
  29. package/src/env/provision.js +106 -10
  30. package/src/env/registry.js +70 -6
  31. package/src/env/resolve.js +113 -4
  32. package/src/env/review.js +324 -20
  33. package/src/env/reviewers/coderabbit.js +228 -0
  34. package/src/env/reviewers.js +517 -0
  35. package/src/env/serve.js +189 -44
  36. package/src/env/teardown.js +12 -1
  37. 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 { planUp, planCheckoutUp } = require('./env/provision.js')
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
- process.stdout.write(` ${folder}${ports}\n ${path.relative(dir, wt) || wt}\n`)
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,253 @@ function specEnvUpCheckout(dir, config, spec) {
606
644
  process.stdout.write(out.join('\n') + '\n')
607
645
  }
608
646
 
609
- function specEnvUp(dir, config, specArg) {
610
- const spec = resolveSpecWithWorktree(dir, config, specArg)
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 = {}) {
859
+ const docs = flags.docs === true
860
+
861
+ // THE AUTHORING LANE — `up <name> --docs` for a spec that is not written yet.
862
+ // `/spec` provisions BEFORE it writes a line (feat-main-is-a-landing-zone
863
+ // phase 2), so the name resolves to nothing at exactly the moment this runs.
864
+ // It opens only on a positive signal, all three parts: the not-found refusal
865
+ // itself (by code, never by message), `--docs`, and a spec-shaped name — so a
866
+ // typo'd `up` without the flag, or with a name no spec could have, refuses
867
+ // exactly as before. Worktree mode only: in checkout mode `/spec` writes
868
+ // where the session stands and there is nothing to provision first.
869
+ let spec
870
+ let authoring = false
871
+ try {
872
+ spec = resolveSpecWithWorktree(dir, config, specArg)
873
+ } catch (err) {
874
+ const specShaped = typeof specArg === 'string' && /^(feat|bug|hotfix)-./.test(specArg)
875
+ if (err.code !== 'SPEC_NOT_FOUND' || !docs || !specShaped || config.mode === 'checkout') {
876
+ throw err
877
+ }
878
+ // Record first, then plan — the same order `nospec` keeps, for the same
879
+ // reason: the record is what makes the name resolvable (a bare `resolve`
880
+ // in the fresh worktree must name this spec before its folder exists), so
881
+ // a caller that runs the printed commands must never own a worktree the
882
+ // engine cannot name. `spec: true` is what makes the record resolve with
883
+ // the spec's own type and slug rather than as `/no-spec` work.
884
+ const beforeAuth = readRegistry(dir, config)
885
+ const resolved = resolveSpecless(specArg, dir, config, { spec: true })
886
+ writeRegistry(
887
+ dir,
888
+ config,
889
+ recordSpecless(beforeAuth, specArg, { branch: resolved.branch, spec: true }),
890
+ )
891
+ spec = resolved
892
+ authoring = true
893
+ }
611
894
 
612
895
  // Checkout mode: the branch is built in the primary checkout, so none of the
613
896
  // worktree machinery below applies — no slot, no trust entry and no bootstrap.
@@ -648,8 +931,13 @@ function specEnvUp(dir, config, specArg) {
648
931
  const upGit = gitReader(dir)
649
932
  const upStatus = upGit(['status', '--porcelain'])
650
933
  const upDirtyPaths = dirtyPaths(upGit)
651
- const upOnFork = specOnForkPoint(dir, upGit, spec)
652
- const upSpecUntracked = specIsUntracked(dir, upGit, spec)
934
+ // An authoring run does not ask whether the spec is on the fork point — it is
935
+ // known to be nowhere, and that is the healthy state of the lane, not the
936
+ // "would fork without the spec it is for" refusal. `onFork: undefined` is the
937
+ // gate's cannot-tell branch, which allows (negative-checks rule 4). The
938
+ // untracked read is skipped the same way: there is no folder to ask about.
939
+ const upOnFork = authoring ? { onFork: undefined, foundOn: null } : specOnForkPoint(dir, upGit, spec)
940
+ const upSpecUntracked = authoring ? false : specIsUntracked(dir, upGit, spec)
653
941
 
654
942
  // Trust the shared worktree root so edits into the freshly-provisioned worktree
655
943
  // don't prompt. One absolute entry (the root) covers every spec; self-heals on
@@ -657,21 +945,38 @@ function specEnvUp(dir, config, specArg) {
657
945
  const worktreeRootAbs = path.dirname(spec.worktreePath)
658
946
  const trust = ensureWorktreeDirTrusted(dir, worktreeRootAbs)
659
947
 
660
- const wantsDocker = spec.stack === 'docker' && config.docker.enabled
948
+ // Documents mode brings no stack up, so a Docker spec provisioned this way is
949
+ // a worktree-only run. Asked through `resolveStack`, which `planUp` also
950
+ // calls, so the slot allocated here and the commands planned there cannot
951
+ // disagree about whether there is a stack — they did, for a header-less spec.
952
+ const wantsDocker = resolveStack(spec, config, {
953
+ docs,
954
+ composeFilePresent: composeFilePresent(dir, config),
955
+ }).wantsDocker
661
956
 
662
957
  // Slot allocation is Docker-only: a worktree-only spec never touches the
663
958
  // registry (no slot, no port block). Its re-run signal is the worktree already
664
959
  // existing on disk (attach the branch, don't `-b`); a Docker spec's is its slot.
960
+ //
961
+ // THE WORKTREE ON DISK COUNTS IN BOTH PATHS, and documents mode is what makes
962
+ // that necessary rather than merely tidy. A Docker spec provisioned with
963
+ // `--docs` allocates no slot, so a later `up` without the flag would read the
964
+ // registry, find nothing, and plan `git worktree add -b <branch>` over a
965
+ // worktree and a branch that both already exist — a plan that cannot run. The
966
+ // stronger signal is the honest one either way: a tree on disk is a re-run,
967
+ // whatever the registry remembers.
665
968
  let slot = null
666
969
  let attached
970
+ const worktreeOnDisk = fs.existsSync(spec.worktreePath)
667
971
  if (wantsDocker) {
668
972
  const before = readRegistry(dir, config)
669
- attached = Object.prototype.hasOwnProperty.call(before.slots, spec.folder)
973
+ attached =
974
+ Object.prototype.hasOwnProperty.call(before.slots, spec.folder) || worktreeOnDisk
670
975
  const alloc = allocateSlot(before, spec.folder)
671
976
  slot = alloc.slot
672
977
  writeRegistry(dir, config, alloc.registry) // the engine's only write (Docker path)
673
978
  } else {
674
- attached = fs.existsSync(spec.worktreePath)
979
+ attached = worktreeOnDisk
675
980
  }
676
981
 
677
982
  // The tree gate: the same facts the checkout planner gets, all of them read
@@ -683,7 +988,8 @@ function specEnvUp(dir, config, specArg) {
683
988
  specFoundOn: upOnFork.foundOn,
684
989
  forkRef: spec.baseRef || currentBranch(upGit) || 'HEAD',
685
990
  specUntracked: upSpecUntracked,
686
- })
991
+ composeFilePresent: composeFilePresent(dir, config),
992
+ }, { docs })
687
993
 
688
994
  if (plan.blocked) {
689
995
  process.stdout.write(`spec-env up: blocked — ${plan.reason}.\n`)
@@ -715,6 +1021,17 @@ function specEnvUp(dir, config, specArg) {
715
1021
  // the operator's only evidence of which mode actually resolved — print it
716
1022
  // whenever it was set explicitly, right or wrong.
717
1023
  out.push(' mode: worktree (each spec gets its own checkout)')
1024
+ // Said positively and only when asked for, so the absent setup step below
1025
+ // reads as a choice rather than as a project with nothing configured.
1026
+ if (plan.docs) {
1027
+ out.push(' docs: documents only — no setup commands, no docker')
1028
+ }
1029
+ // Positive, so an operator reading the plan knows the missing spec folder is
1030
+ // the lane working rather than something the engine failed to find.
1031
+ if (authoring) {
1032
+ out.push(' authoring: no spec document yet — /spec writes it in the worktree')
1033
+ out.push(` recorded: added to ${config.registry} until the spec is written`)
1034
+ }
718
1035
  if (trust.reason === 'malformed') {
719
1036
  out.push(
720
1037
  ' trusted: ! .claude/settings.local.json is not valid JSON — left it;' +
@@ -749,6 +1066,21 @@ function specEnvUp(dir, config, specArg) {
749
1066
  process.stdout.write(out.join('\n') + '\n')
750
1067
  }
751
1068
 
1069
+ /**
1070
+ * The recorded specless branches (`/no-spec` work), or an empty map.
1071
+ *
1072
+ * Wrapped because every caller wants the same cannot-tell behaviour: a
1073
+ * malformed or unreadable registry must not stop a resolution that the folder
1074
+ * search can answer on its own (`.claude/rules/negative-checks.md` rule 4).
1075
+ */
1076
+ function speclessMap(dir, config) {
1077
+ try {
1078
+ return readRegistry(dir, config).specless || {}
1079
+ } catch {
1080
+ return {}
1081
+ }
1082
+ }
1083
+
752
1084
  // A read-only git reader over `cwd`: returns trimmed stdout, or null on failure.
753
1085
  function gitReader(cwd) {
754
1086
  return (argv) => {
@@ -927,7 +1259,11 @@ function specEnvDown(dir, config, specArg, flags) {
927
1259
 
928
1260
  const base = resolveBaseBranch(config, gitReader(dir))
929
1261
  const worktreeState = worktreeGitState(spec.worktreePath, base)
930
- const plan = planDown(spec, config, flags, { worktreeState, timestamp: compactTimestamp() })
1262
+ const plan = planDown(spec, config, flags, {
1263
+ worktreeState,
1264
+ timestamp: compactTimestamp(),
1265
+ composeFilePresent: composeFilePresent(dir, config),
1266
+ })
931
1267
 
932
1268
  if (plan.blocked) {
933
1269
  process.stdout.write(
@@ -939,12 +1275,25 @@ function specEnvDown(dir, config, specArg, flags) {
939
1275
 
940
1276
  // Free the slot (the engine's only write on down) — only if one was held; a
941
1277
  // worktree-only teardown never touches the registry.
942
- if (hasSlot) {
943
- writeRegistry(dir, config, freeSlot(registry, spec.folder))
1278
+ //
1279
+ // A SPECLESS BRANCH IS FORGOTTEN HERE TOO, and it has to be: the record is the
1280
+ // only thing that made the name resolvable, so leaving it behind would keep a
1281
+ // torn-down `/no-spec` branch answering to `review`, `integrate` and a bare
1282
+ // `resolve` with a worktree path that is no longer on disk. `forgetSpecless`
1283
+ // is idempotent, so this costs a write and nothing else.
1284
+ const wasSpecless = isSpecless(registry, spec.folder)
1285
+ if (hasSlot || wasSpecless) {
1286
+ let next = registry
1287
+ if (hasSlot) next = freeSlot(next, spec.folder)
1288
+ if (wasSpecless) next = forgetSpecless(next, spec.folder)
1289
+ writeRegistry(dir, config, next)
944
1290
  }
945
1291
 
946
1292
  const out = []
947
- out.push(`spec-env down: ${spec.folder}${hasSlot ? ' (slot freed)' : ''}`)
1293
+ out.push(
1294
+ `spec-env down: ${spec.folder}` +
1295
+ (hasSlot ? ' (slot freed)' : wasSpecless ? ' (specless record forgotten)' : ''),
1296
+ )
948
1297
  out.push('')
949
1298
  out.push(` worktree: ${spec.worktreePath}`)
950
1299
  out.push(` volumes: ${plan.volumesDropped ? 'dropped' : 'kept'}`)
@@ -1066,7 +1415,7 @@ function volumeCreatedAt(names) {
1066
1415
  */
1067
1416
  function provisionedSpecChoice(dir, config, cwd = process.cwd()) {
1068
1417
  const worktreePaths = liveWorktreePaths(gitReader(dir))
1069
- const provisioned = allSpecs(dir, config, worktreePaths)
1418
+ const provisioned = allSpecs(dir, config, worktreePaths, speclessMap(dir, config))
1070
1419
  .map((s) => ({ folder: s.folder, wt: path.resolve(s.worktreePath) }))
1071
1420
  // The primary checkout is itself in `git worktree list`; a spec is
1072
1421
  // provisioned only when it has its OWN worktree, separate from it.
@@ -1146,7 +1495,18 @@ function resolveSpecWithWorktree(dir, config, specArg) {
1146
1495
  // another's. A worktree left behind by a declined teardown can still answer
1147
1496
  // with a stale bucket — that is a leftover to prune, not a lookup to distrust.
1148
1497
  const preferDirs = worktreeGuess === dir ? [] : [worktreeGuess]
1149
- return resolveSpec(specArg, dir, config, { searchDirs, preferDirs })
1498
+ // A SPECLESS BRANCH RESOLVES TOO, on the registry's word and nothing else.
1499
+ // `/no-spec` work has a worktree and a branch and no document under
1500
+ // `specs/**`, so every verb below — down, integrate, review, resolve, status —
1501
+ // would refuse it as "spec not found". Passing the recorded map here is what
1502
+ // makes one name resolvable without making every typo resolvable: `resolveSpec`
1503
+ // consults it only after the folder search has come up empty, and only for a
1504
+ // name the engine itself wrote down.
1505
+ return resolveSpec(specArg, dir, config, {
1506
+ searchDirs,
1507
+ preferDirs,
1508
+ specless: speclessMap(dir, config),
1509
+ })
1150
1510
  }
1151
1511
 
1152
1512
  // Every spec folder name found under specs/* across the given checkout roots.
@@ -1204,7 +1564,7 @@ function specEnvPrune(dir, config, flags) {
1204
1564
  }
1205
1565
 
1206
1566
  const worktrees = liveWorktreePaths(gitReader(dir))
1207
- const specs = allSpecs(dir, config, worktrees)
1567
+ const specs = allSpecs(dir, config, worktrees, speclessMap(dir, config))
1208
1568
  const liveSlugs = liveSlugsForSpecs(specs, worktrees)
1209
1569
 
1210
1570
  const olderThanDays =
@@ -1582,7 +1942,9 @@ function specEnvResolve(dir, config, specArg, flags = {}) {
1582
1942
  if (flags.recordPrimary) return recordPrimary(dir, config, r)
1583
1943
  if (flags.assertPrimaryClean) return assertPrimaryClean(dir, config, r)
1584
1944
  process.stdout.write(
1585
- `spec: ${r.folder} (${r.bucket})\n` +
1945
+ // A specless branch has no bucket because it has no document — say that
1946
+ // rather than printing `(null)`, which reads as a lookup that failed.
1947
+ `spec: ${r.folder} (${r.specless ? 'no spec' : r.bucket})\n` +
1586
1948
  `type/slug: ${r.type} / ${r.slug}\n` +
1587
1949
  `branch: ${r.branch}\n` +
1588
1950
  `worktree: ${r.worktreePath}\n` +
@@ -1796,6 +2158,21 @@ function gateTarget(dir, config, specArg) {
1796
2158
  * `--claim-since` to take, so the rule that a pass is never claimed without a
1797
2159
  * person asking (`/spec-diff` §0) is untouched by anything here.
1798
2160
  */
2161
+ /**
2162
+ * How often the wait says it is alive, in ms. `0` disables it.
2163
+ *
2164
+ * Three states, not two (`.claude/rules/negative-checks.md` rule 4): a value
2165
+ * that is not a number is a cannot-tell, and it takes the DEFAULT rather than
2166
+ * disabling — being wrong that way costs a line of stderr nobody reads, where
2167
+ * the opposite silently removes the thing keeping the wait alive.
2168
+ */
2169
+ function resolveHeartbeatMs(flags) {
2170
+ if (flags.heartbeat === undefined || flags.heartbeat === null) return WAIT_HEARTBEAT_MS
2171
+ const seconds = Number(flags.heartbeat)
2172
+ if (!Number.isFinite(seconds) || seconds < 0) return WAIT_HEARTBEAT_MS
2173
+ return seconds * 1000
2174
+ }
2175
+
1799
2176
  async function specEnvReviewWait(dir, config, specArg, flags) {
1800
2177
  let spec
1801
2178
  try {
@@ -1836,7 +2213,30 @@ async function specEnvReviewWait(dir, config, specArg, flags) {
1836
2213
  )
1837
2214
  }
1838
2215
 
1839
- const result = await waitForPass(() => readPending(out, spec.folder), flags.since, { timeoutMs })
2216
+ // THE HEARTBEAT GOES TO STDERR, and that is the whole reason it is safe.
2217
+ // Stdout is the result a backgrounded caller parses and, under a monitor,
2218
+ // every stdout line becomes a notification — so a proof-of-life line there
2219
+ // would either corrupt the answer or wake somebody every five minutes.
2220
+ // Stderr reaches the output file and raises neither.
2221
+ //
2222
+ // SUPPRESSED UNDER `--json`, which has one machine-readable payload and must
2223
+ // not grow a second stream beside it.
2224
+ const heartbeatMs = resolveHeartbeatMs(flags)
2225
+ const onHeartbeat =
2226
+ flags.json || heartbeatMs === 0
2227
+ ? null
2228
+ : ({ elapsedMs }) => {
2229
+ process.stderr.write(
2230
+ `spec-env review wait: ${spec.folder} — still waiting (${Math.round(elapsedMs / 60000)}m), ` +
2231
+ `window since ${flags.since}\n`,
2232
+ )
2233
+ }
2234
+
2235
+ const result = await waitForPass(() => readPending(out, spec.folder), flags.since, {
2236
+ timeoutMs,
2237
+ onHeartbeat,
2238
+ heartbeatMs: heartbeatMs || WAIT_HEARTBEAT_MS,
2239
+ })
1840
2240
 
1841
2241
  if (flags.json) {
1842
2242
  process.stdout.write(`${JSON.stringify({ spec: spec.folder, since: flags.since, ...result }, null, 2)}\n`)
@@ -1957,6 +2357,24 @@ function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
1957
2357
  }
1958
2358
  }
1959
2359
 
2360
+ // RECORDED BEFORE THE ANSWER IS WRITTEN, so the offer this call hands back is
2361
+ // also the one it spends. Explicit rather than automatic: a plain read of the
2362
+ // gate must stay a read, because `/spec-next` makes one on every run.
2363
+ //
2364
+ // Best-effort, like the render record. A sidecar that could not be written
2365
+ // costs a second offer, which is the harmless direction — where refusing to
2366
+ // answer would block a commit over a bookkeeping failure.
2367
+ if (flags.offered && judged.state === 'armed' && target.spec) {
2368
+ const marked = markGateOffered(judged.gate, { at: new Date().toISOString() })
2369
+ if (marked.marked) {
2370
+ try {
2371
+ writeGate(target.out, marked.gate)
2372
+ } catch {
2373
+ /* the gate is the guard; this is only its memory of having asked */
2374
+ }
2375
+ }
2376
+ }
2377
+
1960
2378
  if (flags.json) {
1961
2379
  process.stdout.write(
1962
2380
  JSON.stringify(
@@ -1967,6 +2385,9 @@ function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
1967
2385
  armedAt: judged.gate ? judged.gate.armedAt : null,
1968
2386
  phase: judged.gate ? judged.gate.phase : null,
1969
2387
  required: config.review.required,
2388
+ // Absent stays absent: only an `armed` gate has a way out to declare,
2389
+ // and every cannot-tell state carries none.
2390
+ ...(judged.offer ? { offer: judged.offer } : {}),
1970
2391
  log: judged.gate && Array.isArray(judged.gate.log) ? judged.gate.log : [],
1971
2392
  },
1972
2393
  null,
@@ -2187,6 +2608,60 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
2187
2608
  // of the wrong spec looks exactly like a review of the right one.
2188
2609
  const spec = resolveSpecWithWorktree(dir, config, specArg)
2189
2610
 
2611
+ // THE SIDECAR PATHS, RESOLVED BEFORE THE WORKTREE CHECK — and that ordering
2612
+ // is the whole of this feature. `.spec-env/reviews/` lives in the PRIMARY
2613
+ // CHECKOUT, keyed by the spec's folder name; not one byte of it comes from
2614
+ // the worktree. `--out` moves the page and its sidecars together.
2615
+ const out = reviewOutPath(dir, spec.folder, flags.out)
2616
+
2617
+ // The other half of a confirmation: a pass the operator says is not theirs.
2618
+ // Left in the store it is reported on every render until they stop reading the
2619
+ // line — which is how the real one gets waved away too.
2620
+ //
2621
+ // AHEAD OF THE WORKTREE CHECK, because a pass that needs disowning has almost
2622
+ // always outlived its spec. `review waiting` finds the stranded ones and tells
2623
+ // the reader to `--drop` them; refusing that on a missing worktree made the
2624
+ // hint impossible to follow for every pass it could find, and ten real passes
2625
+ // sat behind it. Disowning reads and writes this one file, so the tree it was
2626
+ // being refused for was never involved.
2627
+ //
2628
+ // IT DOES NOT RETURN when it succeeds. A spec that still HAS a worktree falls
2629
+ // through to the render exactly as before, so the page is rewritten without
2630
+ // the pass on it; the gate below is what turns a successful drop into an
2631
+ // ending, and only when there is no page left to write.
2632
+ //
2633
+ // WHAT WOULD FOOL THIS: a spec whose FOLDER was deleted rather than completed.
2634
+ // `resolveSpecWithWorktree` above needs a document to resolve a name, so such
2635
+ // a pass stays unreachable by name however this gate is ordered — only
2636
+ // `spec-env review waiting`, which scans the store directly, can see it.
2637
+ // Relaxing the worktree check does not cover that case and was never meant to:
2638
+ // the two absences are different, and only one of them is what the ten
2639
+ // stranded passes were behind.
2640
+ let dropped = null
2641
+ if (flags.drop) {
2642
+ const heldRead = readPending(out, spec.folder)
2643
+ if (heldRead.corrupt) {
2644
+ process.stdout.write(
2645
+ `spec-env review: ${reviewPendingPath(out)} is not readable JSON — ` +
2646
+ 'move it aside rather than losing the passes it holds.\n',
2647
+ )
2648
+ return
2649
+ }
2650
+ // Same match and the SAME SILENCE as a claim. A drop that listed the codes
2651
+ // it could not find would hand a guesser exactly what the claim withholds.
2652
+ const result = claimPending(heldRead.pending, String(flags.drop).trim())
2653
+ if (!result.pass) {
2654
+ process.stdout.write(
2655
+ `spec-env review: no pending pass with that code` +
2656
+ `${result.count ? ` (${result.count} waiting)` : ''}\n`,
2657
+ )
2658
+ return
2659
+ }
2660
+ writePending(out, result.pending)
2661
+ // Nothing is merged, and the notes sidecar is not touched.
2662
+ dropped = { code: result.pass.code, remaining: result.count }
2663
+ }
2664
+
2190
2665
  // `--docs` reviews the spec's OWN DOCUMENTS from the tree in hand, and so
2191
2666
  // never consults a worktree — a spec in `backlog/` has none, which is exactly
2192
2667
  // the case with no page today.
@@ -2220,9 +2695,63 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
2220
2695
  }
2221
2696
  })()
2222
2697
  if (!docs && !liveHere && !fs.existsSync(spec.worktreePath)) {
2698
+ // A DROP THAT ALREADY HAPPENED IS AN ENDING, not a refusal. The work this
2699
+ // invocation asked for is done and written; there is simply no page left to
2700
+ // rewrite, which is a fact about the spec rather than a failure of the run.
2701
+ if (dropped) {
2702
+ // THE SAME SPELLING the render uses for the same event. A drop reported
2703
+ // one way here and another way there is two events as far as anyone
2704
+ // grepping their scrollback is concerned.
2705
+ process.stdout.write(
2706
+ `spec-env review: ${spec.folder}\n` +
2707
+ ` dropped: ${dropped.code} — merged nothing` +
2708
+ `${dropped.remaining ? ` (${dropped.remaining} still waiting)` : ''}\n` +
2709
+ ' no worktree, so nothing was rendered.\n',
2710
+ )
2711
+ return
2712
+ }
2713
+
2714
+ // THE REFUSAL NAMES THE EXIT THAT WORKS. It used to say "run /spec-start to
2715
+ // provision it", which for a completed spec is advice to resurrect it in
2716
+ // order to throw a pass away — and that was the only advice a reader holding
2717
+ // a stranded pass ever got.
2718
+ //
2719
+ // A STRANDED PASS STAYS UNCLAIMABLE, deliberately. Once the branch has
2720
+ // merged and the worktree is gone a `commit` verdict has nowhere to land, so
2721
+ // honouring one would report work that did not happen. Disowning is the only
2722
+ // honest ending, so it is the only one offered.
2723
+ // WHICH MESSAGE depends on a POSITIVE SIGNAL — a pass actually sitting in
2724
+ // the store. That is not decoration: the two readers are in opposite
2725
+ // situations, and one sentence cannot serve both.
2726
+ //
2727
+ // No pass waiting is overwhelmingly the commonest case — a spec in
2728
+ // `backlog/` that nobody has started — and there `/spec-start` is exactly
2729
+ // the right advice, so that message is left byte-for-byte as it was.
2730
+ //
2731
+ // A pass waiting means the spec has almost certainly finished, and telling
2732
+ // that reader to provision a worktree is telling them to resurrect a
2733
+ // completed spec in order to throw a pass away. A corrupt store reads as
2734
+ // zero, so it takes the untouched message rather than inventing a count.
2735
+ const held = readPending(out, spec.folder)
2736
+ const count = held.corrupt ? 0 : (held.pending.passes || []).length
2737
+ if (!count) {
2738
+ process.stdout.write(
2739
+ `spec-env review: ${spec.folder} has no worktree at ${spec.worktreePath} — ` +
2740
+ 'run /spec-start to provision it, or --docs to review the spec itself.\n',
2741
+ )
2742
+ return
2743
+ }
2223
2744
  process.stdout.write(
2224
- `spec-env review: ${spec.folder} has no worktree at ${spec.worktreePath} — ` +
2225
- 'run /spec-start to provision it, or --docs to review the spec itself.\n',
2745
+ `spec-env review: ${spec.folder} has no worktree at ${spec.worktreePath}.\n` +
2746
+ ` ${count} waiting — disown ${count === 1 ? 'it' : 'one'} with --drop <code>, ` +
2747
+ 'or `spec-env review waiting` to list them.\n' +
2748
+ // WHY CLAIMING IS NOT OFFERED, said rather than left to be discovered.
2749
+ // A stranded pass is unclaimable by design: with no worktree a `commit`
2750
+ // verdict has nowhere to land, so honouring one would report work that
2751
+ // did not happen. Disowning is the only honest ending, so it is the
2752
+ // only one named.
2753
+ ' It cannot be claimed: with no worktree, a verdict has nowhere to land.\n' +
2754
+ " --docs reviews the spec's own documents.\n",
2226
2755
  )
2227
2756
  return
2228
2757
  }
@@ -2284,9 +2813,6 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
2284
2813
  mode = 'branch'
2285
2814
  }
2286
2815
 
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
2816
  const stored = readNotes(out, spec.folder)
2291
2817
  let notes = stored.notes
2292
2818
  let merged = null
@@ -2414,37 +2940,27 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
2414
2940
  accepted: parsed.accepted.length,
2415
2941
  unaccepted: parsed.unaccepted.length,
2416
2942
  comments: parsed.comments.length,
2943
+ // THE INSTRUCTION THE PASS CARRIED, and the one field here that is not a
2944
+ // count. An action blob concludes nothing — it asks for something to be
2945
+ // done and hands the page straight back — so a claim that reported only
2946
+ // the counts was indistinguishable from a pass carrying no decision at
2947
+ // all, and `/spec-diff` §2b could never fire. That is what a pressed
2948
+ // `live-on` looked like: "0 accepts, 0 withdrawn, 0 comments".
2949
+ //
2950
+ // `null` rather than absent, deliberately. Every other optional key on
2951
+ // this payload is spread away so an existing consumer sees nothing new,
2952
+ // but `claimed` is itself conditional — anything reading it was written
2953
+ // after this shipped, and a present `null` is what separates "carried no
2954
+ // action" from "an engine too old to say".
2955
+ //
2956
+ // It is NOT a verdict and must never reach `sentVerdict`: `ACTIONS` is
2957
+ // disjoint from `VERDICTS` by construction, and the gate reads the
2958
+ // verdict. This is a field beside it, not a widening of it.
2959
+ action: parsed.action || null,
2417
2960
  }
2418
2961
  merged = { accepted: claimed.accepted, unaccepted: claimed.unaccepted, comments: claimed.comments }
2419
2962
  }
2420
2963
 
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
2964
 
2449
2965
  if (flags.notes) {
2450
2966
  // Refuse rather than write over notes we could not read: an unreadable
@@ -2689,8 +3205,79 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
2689
3205
  data.review = JSON.parse(raw)
2690
3206
  }
2691
3207
 
3208
+ // THE MACHINE HALF. It composes with the written review rather than replacing
3209
+ // it: a page can carry a person's read and a tool's findings at once, and
3210
+ // which is which is on every check as its `source`.
3211
+ //
3212
+ // `--docs` deliberately never runs them. A spec document is prose, not the
3213
+ // code these tools read, and spending a rate-limited review on one is waste.
3214
+ let reviewerOutcomes = null
3215
+ if (flags.runReviewers && !docs && (config.review.reviewers || []).length) {
3216
+ const scope = mode === 'branch' ? 'branch' : 'working'
3217
+ const diffHash = diffHashOf(data.files)
3218
+ const { cache, corrupt } = readChecks(out, spec.folder)
3219
+ let checks
3220
+ let outcomes
3221
+ if (!corrupt && cacheHit(cache, spec.folder, diffHash)) {
3222
+ // KEYED ON THE DIFF, NOT A CLOCK. Re-rendering unchanged work must not
3223
+ // spend another review against an hourly limit — and any change at all
3224
+ // must re-run, because findings about code that has moved on are worse
3225
+ // than no findings.
3226
+ checks = cache.checks
3227
+ outcomes = asCached(cache.outcomes)
3228
+ } else {
3229
+ const ran = await runReviewers(config, {
3230
+ worktree: spec.worktreePath,
3231
+ base: base || reviewBase(),
3232
+ scope,
3233
+ spec: spec.folder,
3234
+ adapters: BUNDLED_ADAPTERS,
3235
+ })
3236
+ checks = ran.checks
3237
+ outcomes = ran.outcomes
3238
+ // Best-effort. A cache that could not be written costs a re-run next
3239
+ // time, which is the harmless direction — it must never fail the render.
3240
+ try {
3241
+ writeChecks(out, {
3242
+ version: CHECKS_VERSION,
3243
+ spec: spec.folder,
3244
+ diffHash,
3245
+ at: now,
3246
+ checks,
3247
+ outcomes,
3248
+ })
3249
+ } catch {
3250
+ /* a cache is an optimisation, never a record */
3251
+ }
3252
+ }
3253
+ reviewerOutcomes = outcomes
3254
+ // The written review's checks lead — a person's read is what the reader
3255
+ // came for — and the machine's follow, grouped by the reviewer that
3256
+ // produced them because `runReviewers` runs in sequence.
3257
+ data.review = {
3258
+ ...(data.review || {}),
3259
+ checks: [...((data.review && data.review.checks) || []), ...checks],
3260
+ reviewers: outcomes,
3261
+ }
3262
+ }
3263
+
2692
3264
  writeReviewPage(out, renderReviewPage(data, { reviewHtml: renderReviewBlock(data.review) }))
2693
3265
 
3266
+ // RECORD WHAT THIS RENDER DECLARED, for the served copy to read back. The
3267
+ // daemon re-renders per request and is not the caller, so without this it can
3268
+ // only see what the work IS — enough for `authoring`, `nospec` and
3269
+ // `committing`, and blind to `midrun` and `refresh`, which say whether the run
3270
+ // had finished. `buttonsForView` takes only those two from here.
3271
+ //
3272
+ // Best-effort, like the reviewer cache: a record that could not be written
3273
+ // costs a served page its narrowing, which is the harmless direction, and it
3274
+ // must never fail a render.
3275
+ try {
3276
+ writeRenderRecord(out, { spec: spec.folder, buttons, at: now })
3277
+ } catch {
3278
+ /* the page is the artefact; this is a note for the daemon */
3279
+ }
3280
+
2694
3281
  // The publish-ready copy, ONLY when asked. An ordinary render must not pay for
2695
3282
  // a second copy of the whole diff on disk for a path most renders never take.
2696
3283
  let publishCopy = null
@@ -2823,6 +3410,10 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
2823
3410
  // reporting it would make every existing consumer see a new key.
2824
3411
  ...(data.buttons ? { buttons: data.buttons } : {}),
2825
3412
  reviewed: Boolean(data.review),
3413
+ // Absent unless reviewers actually ran, so a consumer that never
3414
+ // configured one sees no new key. An empty ARRAY would be a claim
3415
+ // that they ran and said nothing.
3416
+ ...(reviewerOutcomes ? { reviewers: reviewerOutcomes } : {}),
2826
3417
  totals: data.totals,
2827
3418
  notes: data.notes,
2828
3419
  merged,
@@ -2882,7 +3473,13 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
2882
3473
  : '') +
2883
3474
  (claimed
2884
3475
  ? ` claimed: ${claimed.code} — ${claimed.accepted} accept${claimed.accepted === 1 ? '' : 's'}, ` +
2885
- `${claimed.unaccepted} withdrawn, ${claimed.comments} comment${claimed.comments === 1 ? '' : 's'}\n`
3476
+ `${claimed.unaccepted} withdrawn, ${claimed.comments} comment${claimed.comments === 1 ? '' : 's'}` +
3477
+ // Last on the line rather than folded into the counts: a reader
3478
+ // scanning for what a press did finds the verb at the end of the one
3479
+ // line the claim prints, and a pass with no action reads exactly as it
3480
+ // did before this existed.
3481
+ (claimed.action ? ` · action: ${claimed.action}` : '') +
3482
+ '\n'
2886
3483
  : '') +
2887
3484
  (dropped ? ` dropped: ${dropped.code} — merged nothing\n` : '') +
2888
3485
  (waiting.length
@@ -3735,6 +4332,26 @@ async function specEnvConnect(dir, config, specArg) {
3735
4332
 
3736
4333
  // Dispatch `skitterspec spec-env <sub> [args] [--dir path]`. No-ops with a clear
3737
4334
  // message when the feature isn't enabled (no specs/.core/env.config.json).
4335
+ // Is the project's configured compose file actually on disk?
4336
+ //
4337
+ // Read from the PRIMARY CHECKOUT rather than the worktree, and that is forced
4338
+ // rather than chosen: a fresh provision plans before `git worktree add` runs, so
4339
+ // the worktree does not exist yet to be looked in. A compose file is committed
4340
+ // at the repo root in every layout this supports, so the two answers agree
4341
+ // wherever both could be read.
4342
+ //
4343
+ // Any failure to look is `undefined` — cannot-tell, which callers route to the
4344
+ // branch that does not escalate — never `false`.
4345
+ function composeFilePresent(dir, config) {
4346
+ const file = config && config.docker && config.docker.composeFile
4347
+ if (!file) return undefined
4348
+ try {
4349
+ return fs.existsSync(path.join(dir, file))
4350
+ } catch {
4351
+ return undefined
4352
+ }
4353
+ }
4354
+
3738
4355
  // Run a mutating git command; return { ok, err } (stderr captured, not swallowed).
3739
4356
  function runGit(cwd, args) {
3740
4357
  try {
@@ -3776,7 +4393,7 @@ async function specEnvLive(dir, config, positional, flags) {
3776
4393
  specEnvLiveStatus(dir, config, specArg, flags)
3777
4394
  break
3778
4395
  case 'take':
3779
- await specEnvLiveTake(dir, config, specArg)
4396
+ await specEnvLiveTake(dir, config, specArg, flags)
3780
4397
  break
3781
4398
  case 'release':
3782
4399
  await specEnvLiveRelease(dir, config, specArg)
@@ -3865,7 +4482,7 @@ function bareLive(dir, config) {
3865
4482
 
3866
4483
  // Take the running instance: rebase the spec's branch onto base, free it from its
3867
4484
  // worktree, and check it out in the primary checkout so the dev server reloads it.
3868
- async function specEnvLiveTake(dir, config, specArg) {
4485
+ async function specEnvLiveTake(dir, config, specArg, flags = {}) {
3869
4486
  const spec = resolveSpecWithWorktree(dir, config, specArg)
3870
4487
 
3871
4488
  // Probe the primary checkout's git state (IO stays here; the planner is pure).
@@ -3913,7 +4530,34 @@ async function specEnvLiveTake(dir, config, specArg) {
3913
4530
  })
3914
4531
 
3915
4532
  if (plan.blocked) {
3916
- process.stdout.write(`spec-env live take: blocked — ${plan.reason}.\n`)
4533
+ // A MACHINE-READABLE REFUSAL, so a caller reads the offer rather than
4534
+ // parsing the sentence. `take` had no `--json` at all, and a skill
4535
+ // scraping `blocked — …` prose for a command to run is exactly the kind of
4536
+ // second implementation that drifts out of step with the first.
4537
+ if (flags.json) {
4538
+ process.stdout.write(
4539
+ JSON.stringify(
4540
+ {
4541
+ spec: spec.folder,
4542
+ blocked: true,
4543
+ reason: plan.reason,
4544
+ // Absent stays absent — most refusals have no way out to declare.
4545
+ ...(plan.offer ? { offer: plan.offer } : {}),
4546
+ },
4547
+ null,
4548
+ 2,
4549
+ ) + '\n',
4550
+ )
4551
+ return
4552
+ }
4553
+ // THE OFFER IS NAMED IN THE TEXT TOO, not only in `--json`. A reader in a
4554
+ // plain terminal is the one person a picker cannot reach, and they are
4555
+ // entitled to the same way out — so the refusal prints what it would have
4556
+ // offered rather than making a harness the only route to it.
4557
+ process.stdout.write(
4558
+ `spec-env live take: blocked — ${plan.reason}.\n` +
4559
+ (plan.offer ? ` ${plan.offer.label}: ${plan.offer.command}\n` : ''),
4560
+ )
3917
4561
  return
3918
4562
  }
3919
4563
 
@@ -4210,10 +4854,13 @@ async function specEnv(rest) {
4210
4854
  branch: false,
4211
4855
  out: null,
4212
4856
  review: null,
4857
+ runReviewers: false,
4213
4858
  notes: null,
4214
4859
  resolve: null,
4215
4860
  outcome: null,
4216
4861
  claim: null,
4862
+ offered: false,
4863
+ heartbeat: null,
4217
4864
  drop: null,
4218
4865
  buttons: null,
4219
4866
  json: false,
@@ -4235,6 +4882,7 @@ async function specEnv(rest) {
4235
4882
  else if (args[i] === '--buttons') flags.buttons = args[++i]
4236
4883
  else if (args[i] === '--out') flags.out = args[++i]
4237
4884
  else if (args[i] === '--review') flags.review = args[++i]
4885
+ else if (args[i] === '--run-reviewers') flags.runReviewers = true
4238
4886
  else if (args[i] === '--notes') flags.notes = args[++i]
4239
4887
  else if (args[i] === '--verdict') flags.verdict = args[++i]
4240
4888
  // `--set` keeps an EMPTY STRING rather than coercing it away: empty is the
@@ -4246,15 +4894,18 @@ async function specEnv(rest) {
4246
4894
  else if (args[i] === '--outcome') flags.outcome = args[++i]
4247
4895
  else if (args[i] === '--claim') flags.claim = args[++i]
4248
4896
  else if (args[i] === '--claim-since') flags.claimSince = args[++i]
4897
+ else if (args[i] === '--heartbeat') flags.heartbeat = args[++i]
4249
4898
  else if (args[i] === '--since') flags.since = args[++i]
4250
4899
  else if (args[i] === '--timeout') flags.timeout = args[++i]
4251
4900
  else if (args[i] === '--drop') flags.drop = args[++i]
4252
4901
  else if (args[i] === '--json') flags.json = true
4253
4902
  else if (args[i] === '--check') flags.check = true
4254
4903
  else if (args[i] === '--for-command') flags.forCommand = args[++i]
4904
+ else if (args[i] === '--offered') flags.offered = true
4255
4905
  else if (args[i] === '--phase') flags.phase = args[++i]
4256
4906
  else if (args[i] === '--record-primary') flags.recordPrimary = true
4257
4907
  else if (args[i] === '--docs') flags.docs = true
4908
+ else if (args[i] === '--session') flags.session = args[++i]
4258
4909
  else if (args[i] === '--assert-primary-clean') flags.assertPrimaryClean = true
4259
4910
  else positional.push(args[i])
4260
4911
  }
@@ -4268,7 +4919,7 @@ async function specEnv(rest) {
4268
4919
  // paths, and the registry identically whether run from main or a worktree.
4269
4920
  dir = resolvePrimaryCheckout(dir, gitReader(dir))
4270
4921
 
4271
- const { config, present, unknown } = loadEnvConfig(dir)
4922
+ const { config, present, unknown, badReviewers } = loadEnvConfig(dir)
4272
4923
  if (!present) {
4273
4924
  process.stdout.write(
4274
4925
  'spec-env: isolation not enabled (no specs/.core/env.config.json).\n' +
@@ -4295,9 +4946,43 @@ async function specEnv(rest) {
4295
4946
  process.stderr.write(`spec-env: ${ENV_CONFIG_LABEL} — unknown key "${key}" is ignored.\n`)
4296
4947
  }
4297
4948
 
4949
+ // The same advisory, for the one place the check above structurally cannot
4950
+ // reach: `review.reviewers` is an array, and `collectUnknownKeys` deliberately
4951
+ // does not walk into one. Dropping an entry silently is worse than dropping a
4952
+ // key silently — the page then shows no line for that reviewer at all, which
4953
+ // reads as "not configured" rather than "configured wrong".
4954
+ for (const bad of badReviewers || []) {
4955
+ process.stderr.write(
4956
+ `spec-env: ${ENV_CONFIG_LABEL} — review.reviewers[${bad.index}] ${bad.reason}; it is ignored.\n`,
4957
+ )
4958
+ }
4959
+
4960
+ // A `use` NAMING AN ADAPTER THIS BUILD DOES NOT HAVE, said here rather than
4961
+ // waiting for the run to report it. The config module deliberately does not
4962
+ // know the registry — shape is its business and the adapter list is the
4963
+ // runner's — so the check lives at the one place that can see both.
4964
+ //
4965
+ // It is advisory like the two above: an older engine reading a config written
4966
+ // for a newer one is a forward-compat gap, not a mistake, and the run reports
4967
+ // it again as that reviewer's own outcome either way.
4968
+ for (const r of config.review.reviewers || []) {
4969
+ if (r.use && !Object.prototype.hasOwnProperty.call(BUNDLED_ADAPTERS, r.use)) {
4970
+ process.stderr.write(
4971
+ `spec-env: ${ENV_CONFIG_LABEL} — review.reviewers "${r.use}" is not a bundled adapter ` +
4972
+ `(have: ${Object.keys(BUNDLED_ADAPTERS).join(', ') || 'none'}); it will not run.\n`,
4973
+ )
4974
+ }
4975
+ }
4976
+
4298
4977
  switch (sub) {
4299
4978
  case 'up':
4300
- specEnvUp(dir, config, positional[0])
4979
+ specEnvUp(dir, config, positional[0], flags)
4980
+ break
4981
+ case 'nospec':
4982
+ specEnvNospec(dir, config, positional[0], flags)
4983
+ break
4984
+ case 'main':
4985
+ specEnvMain(dir, config, positional, flags, present, invokedFrom)
4301
4986
  break
4302
4987
  case 'down':
4303
4988
  specEnvDown(dir, config, positional[0], flags)
@@ -4371,7 +5056,7 @@ async function specEnv(rest) {
4371
5056
  break
4372
5057
  default:
4373
5058
  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` +
5059
+ `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
5060
  ' review serve [--port <n>] [--host <addr>] [--stop] [--status] serve every diff locally\n' +
4376
5061
  ' review serve --rotate-token mint a new URL token; every handed-out link dies\n' +
4377
5062
  ' review allow <network|remote> [--off] permit a review tier (writes env.config.json)\n' +
@@ -4379,6 +5064,8 @@ async function specEnv(rest) {
4379
5064
  ' review gate [spec] [--check] [--json] is one owed? --check exits non-zero if so\n' +
4380
5065
  ' [--for-command <cmdline>] ...but only when that command is a git commit\n' +
4381
5066
  ' review skip "<reason>" move on without one, on the record\n' +
5067
+ ' review gate [spec] --offered record that the bypass was offered (spends it)\n' +
5068
+ ' review wait [spec] --heartbeat <secs> proof-of-life cadence on stderr; 0 disables\n' +
4382
5069
  ' review wait [spec] --since <iso> block until a verdict arrives ([--timeout <s>])\n' +
4383
5070
  ' review waiting [--json] every pass waiting, across every spec\n' +
4384
5071
  ' review [spec] --claim-since <iso> claim the one pass that arrived since <iso>\n' +