@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.
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 +50 -9
  13. package/assets/skills/no-spec/SKILL.md +219 -0
  14. package/assets/skills/spec/SKILL.md +135 -36
  15. package/assets/skills/spec-bug/SKILL.md +5 -0
  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 +33 -1
  19. package/assets/skills/spec-init/SKILL.md +4 -2
  20. package/assets/skills/spec-next/SKILL.md +47 -2
  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 +702 -59
  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 +92 -3
  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,220 @@ function specEnvUpCheckout(dir, config, spec) {
606
644
  process.stdout.write(out.join('\n') + '\n')
607
645
  }
608
646
 
609
- function specEnvUp(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 = {}) {
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
- const wantsDocker = spec.stack === 'docker' && config.docker.enabled
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 = Object.prototype.hasOwnProperty.call(before.slots, spec.folder)
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 = fs.existsSync(spec.worktreePath)
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, { worktreeState, timestamp: compactTimestamp() })
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
- if (hasSlot) {
943
- writeRegistry(dir, config, freeSlot(registry, spec.folder))
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(`spec-env down: ${spec.folder}${hasSlot ? ' (slot freed)' : ''}`)
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
- return resolveSpec(specArg, dir, config, { searchDirs, preferDirs })
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
- `spec: ${r.folder} (${r.bucket})\n` +
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
- const result = await waitForPass(() => readPending(out, spec.folder), flags.since, { timeoutMs })
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
- 'run /spec-start to provision it, or --docs to review the spec itself.\n',
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'}\n`
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
- process.stdout.write(`spec-env live take: blocked — ${plan.reason}.\n`)
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' +