@skitterbyte/skitterspec 19.0.0 → 21.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,157 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Register the review-gate hook in the project's Claude Code settings.
5
+ *
6
+ * `.claude/settings.json` rather than `settings.local.json`, and the difference
7
+ * is the point: the trusted-worktree entry is an absolute path and therefore
8
+ * one machine's business, while this is the project's policy — a phase that
9
+ * ended owes a verdict — and it should reach everyone who clones the repo. The
10
+ * command is written with `${CLAUDE_PROJECT_DIR}` so it stays true wherever the
11
+ * checkout lives, worktrees included.
12
+ *
13
+ * Conservative in the same way `trust.js` is: every existing key is preserved,
14
+ * a file it cannot parse is left exactly as it is, and re-running changes
15
+ * nothing once the entry is there. Callers own the reporting.
16
+ */
17
+
18
+ const fs = require('node:fs')
19
+ const path = require('node:path')
20
+
21
+ // `.cjs`, and the extension is load-bearing. This file is copied INTO the target
22
+ // project, where that project's `package.json` decides how node parses a `.js` —
23
+ // so the CommonJS script shipped as `review-gate.js` died on its own first
24
+ // `require` in every `"type": "module"` project, printing a stack trace over
25
+ // every Bash tool call. `.cjs` settles the parse mode at the file, independently
26
+ // of the one file skitterspec does not control.
27
+ const HOOK_STEM = '.claude/hooks/review-gate'
28
+ const HOOK_SCRIPT = `${HOOK_STEM}.cjs`
29
+ const HOOK_COMMAND = `node "\${CLAUDE_PROJECT_DIR}/${HOOK_SCRIPT}"`
30
+
31
+ // Our script under ANY extension, so a registration naming the retired `.js` is
32
+ // recognised as ours and migrated rather than duplicated. The trailing lookahead
33
+ // is the boundary: without it `review-gate-extra.js` — somebody else's hook —
34
+ // matches on the stem and gets silently rewritten.
35
+ const HOOK_SCRIPT_RE = /[.]claude[/\\]hooks[/\\]review-gate(?:[.][A-Za-z0-9]+)?(?![\w.-])/
36
+ // Seconds. The engine call behind this is one git-free read of a small JSON
37
+ // file, so anything approaching this is a wedge rather than slow work — and a
38
+ // hook that times out fails OPEN, which is the answer we want for a wedge.
39
+ const HOOK_TIMEOUT = 10
40
+ const MATCHER = 'Bash'
41
+
42
+ function isObject(value) {
43
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
44
+ }
45
+
46
+ function settingsPath(dir) {
47
+ return path.join(dir, '.claude', 'settings.json')
48
+ }
49
+
50
+ function writeSettings(file, settings) {
51
+ fs.mkdirSync(path.dirname(file), { recursive: true })
52
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n')
53
+ }
54
+
55
+ function hookEntry() {
56
+ return {
57
+ matcher: MATCHER,
58
+ hooks: [{ type: 'command', command: HOOK_COMMAND, timeout: HOOK_TIMEOUT }],
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Is our hook already registered under `PreToolUse`? Pure.
64
+ *
65
+ * Matched on the SCRIPT PATH, not on the whole command string. An operator who
66
+ * added a timeout, wrapped the invocation, or changed the interpreter has
67
+ * registered our hook their way — re-adding a second copy beside theirs would
68
+ * run it twice and look like a bug in the gate.
69
+ */
70
+ function alreadyRegistered(preToolUse) {
71
+ return registeredHooks(preToolUse).length > 0
72
+ }
73
+
74
+ // Every hook object under `PreToolUse` whose command names our script, whatever
75
+ // extension it names it under. Pure.
76
+ function registeredHooks(preToolUse) {
77
+ if (!Array.isArray(preToolUse)) return []
78
+ const out = []
79
+ for (const group of preToolUse) {
80
+ if (!isObject(group) || !Array.isArray(group.hooks)) continue
81
+ for (const h of group.hooks) {
82
+ if (isObject(h) && typeof h.command === 'string' && HOOK_SCRIPT_RE.test(h.command)) out.push(h)
83
+ }
84
+ }
85
+ return out
86
+ }
87
+
88
+ /**
89
+ * Ensure the review-gate hook is registered in `dir`'s project settings.
90
+ * Idempotent and non-destructive. Returns `{ changed, reason }`:
91
+ * - `created` — no settings file; one was written
92
+ * - `added` — merged into an existing file
93
+ * - `migrated` — registered under a retired path; the path was rewritten
94
+ * - `present` — already registered (no write)
95
+ * - `malformed` — the file exists but is not parseable JSON (left untouched)
96
+ */
97
+ function ensureReviewGateHook(dir) {
98
+ const file = settingsPath(dir)
99
+
100
+ let raw
101
+ try {
102
+ raw = fs.readFileSync(file, 'utf-8')
103
+ } catch (error) {
104
+ if (error.code === 'ENOENT') {
105
+ writeSettings(file, { hooks: { PreToolUse: [hookEntry()] } })
106
+ return { changed: true, reason: 'created' }
107
+ }
108
+ throw error
109
+ }
110
+
111
+ let parsed
112
+ try {
113
+ parsed = JSON.parse(raw)
114
+ } catch {
115
+ // Never rewrite a settings file we could not read. It is the operator's
116
+ // config and everything else in it would be lost.
117
+ return { changed: false, reason: 'malformed' }
118
+ }
119
+ if (!isObject(parsed)) return { changed: false, reason: 'malformed' }
120
+
121
+ const hooks = isObject(parsed.hooks) ? parsed.hooks : {}
122
+ const preToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : []
123
+
124
+ // Registered already — but possibly under a path that no longer exists, since
125
+ // the script's extension changed. Rewrite the path IN PLACE rather than
126
+ // replacing the command: an operator who wrapped our hook, added a flag or
127
+ // changed the interpreter has registered it their way, and the thing that
128
+ // moved is the file, not their command. Adding a fresh entry beside theirs
129
+ // would run the gate twice and read as a bug in the gate; leaving the old one
130
+ // alone would aim the harness at a file this same upgrade retires.
131
+ const registered = registeredHooks(preToolUse)
132
+ if (registered.length) {
133
+ const stale = registered.filter((h) => !h.command.includes(HOOK_SCRIPT))
134
+ if (!stale.length) return { changed: false, reason: 'present' }
135
+ for (const h of stale) h.command = h.command.replace(HOOK_SCRIPT_RE, HOOK_SCRIPT)
136
+ writeSettings(file, parsed)
137
+ return { changed: true, reason: 'migrated' }
138
+ }
139
+
140
+ writeSettings(file, {
141
+ ...parsed,
142
+ hooks: { ...hooks, PreToolUse: [...preToolUse, hookEntry()] },
143
+ })
144
+ return { changed: true, reason: 'added' }
145
+ }
146
+
147
+ module.exports = {
148
+ ensureReviewGateHook,
149
+ alreadyRegistered,
150
+ registeredHooks,
151
+ hookEntry,
152
+ settingsPath,
153
+ HOOK_STEM,
154
+ HOOK_SCRIPT,
155
+ HOOK_SCRIPT_RE,
156
+ HOOK_COMMAND,
157
+ }
package/src/env/review.js CHANGED
@@ -230,8 +230,11 @@ function resolveReader(config, env = {}) {
230
230
  * `fellBack` records that `branch` was reached because the working tree was
231
231
  * clean, not because the caller asked for it — see the fallback in
232
232
  * `specEnvReview` (`cli.js`).
233
+ *
234
+ * `buttons` is the button set the page renders — see `BUTTON_SETS`. It is the
235
+ * caller's declaration about the work, not a reading of the gate.
233
236
  */
234
- function collectReview({ spec, git, mode = 'working', ref, base = null, now, notes = null, fellBack = false }) {
237
+ function collectReview({ spec, git, mode = 'working', ref, base = null, now, notes = null, gate = null, fellBack = false, buttons = null }) {
235
238
  const files = []
236
239
  for (const f of trackedFiles(git, ref)) {
237
240
  const { patch, whole } = patchFor(git, ref, f, false)
@@ -298,6 +301,13 @@ function collectReview({ spec, git, mode = 'working', ref, base = null, now, not
298
301
  // Absent stays absent, exactly as `phases` does: a spec this cannot read
299
302
  // adds no key and the page renders its header-less self.
300
303
  ...(context ? { context } : {}),
304
+ // Same rule again: a gate that was never armed and never skipped adds no
305
+ // key at all.
306
+ ...(gateForPage(gate) ? { gate: gateForPage(gate) } : {}),
307
+ // THE DEFAULT ADDS NO KEY, so a caller that did not ask for a button set —
308
+ // and a caller that asked for the default by name — renders the payload it
309
+ // rendered before this existed. Opting in is the only thing that shows.
310
+ ...(buttons && buttons !== DEFAULT_BUTTON_SET ? { buttons } : {}),
301
311
  // WHICH ENGINE DREW THIS PAGE. The render is always current — the git reads
302
312
  // happen per request — so a page rendered by a stale process looks entirely
303
313
  // right: the counts move, `generatedAt` moves, the diff is correct. Only the
@@ -396,16 +406,46 @@ const NOTES_VERSION = 1
396
406
  * `discuss` is the default because it is the behaviour that existed before any
397
407
  * verdict did. So a blob from an older page, or one a reader sent without
398
408
  * choosing, keeps doing exactly what it always did.
409
+ *
410
+ * `continue` is the mid-run verdict: *I have read it, carry on*. It names an
411
+ * action, which is what separates it from the `none` verdict that was removed —
412
+ * `none` recorded itself and did nothing, while this one resumes the run. What
413
+ * it does NOT do is commit, so it is deliberately absent from `COMMITTING`
414
+ * below and is therefore structurally incapable of clearing an armed gate: a
415
+ * phase that ended still owes a committing verdict or a recorded skip.
399
416
  */
400
- const VERDICTS = ['commit', 'commit-continue', 'changes', 'discuss']
417
+ const VERDICTS = ['commit', 'commit-continue', 'continue', 'changes', 'discuss']
401
418
  const DEFAULT_VERDICT = 'discuss'
402
419
 
403
420
  // The verdicts that COMMIT, and are therefore blocked by an open comment. One
404
- // list, so a fourth verdict cannot become a way around the single refusal this
421
+ // list, so a fifth verdict cannot become a way around the single refusal this
405
422
  // engine makes — adding a committing verdict means adding it here, and the
406
423
  // block follows for free.
424
+ //
425
+ // `continue` IS DELIBERATELY NOT HERE, and that omission is the whole of
426
+ // decision 2: waiting is what any offer does, while arming asserts an
427
+ // obligation that outlives the turn. A mid-run reader saying "carry on" has
428
+ // answered the offer in front of them and nothing else, so the gate a finished
429
+ // phase armed must survive it untouched.
407
430
  const COMMITTING = ['commit', 'commit-continue']
408
431
 
432
+ /**
433
+ * Which set of buttons a rendered page shows.
434
+ *
435
+ * DECLARED BY THE CALLER, NEVER DERIVED FROM THE GATE. Deriving it — mid-run
436
+ * iff the gate is unarmed — is tidier and wrong: a project running
437
+ * `review.required: false` never arms at all, so every one of its pages would
438
+ * lose the committing buttons and the reader could never commit from the page.
439
+ * The caller knows whether the work it just rendered is finished; the gate only
440
+ * knows whether this project opted into gating.
441
+ *
442
+ * `committing` is the default, so a caller that says nothing keeps today's page
443
+ * exactly — the key is left off the payload entirely rather than written out as
444
+ * the default, so an unchanged caller renders an unchanged page.
445
+ */
446
+ const BUTTON_SETS = ['committing', 'midrun']
447
+ const DEFAULT_BUTTON_SET = 'committing'
448
+
409
449
  /**
410
450
  * What an older sidecar's `approve` means now. Pure.
411
451
  *
@@ -705,6 +745,199 @@ function claimPending(pending, code) {
705
745
  return { pass, pending: { ...pending, passes: rest }, count: rest.length }
706
746
  }
707
747
 
748
+ /**
749
+ * Which pass arrived inside a WAIT WINDOW. Pure — it selects, never claims.
750
+ *
751
+ * THE WINDOW IS THE SCOPE, and it is what replaces "a person typed the
752
+ * command" when a watch wakes the session instead. A skill that asked to be
753
+ * woken when the store changed knows one thing nothing else does: the moment
754
+ * it started waiting. A pass sent after that, while it was waiting, is the
755
+ * pass it was waiting for — and a pass that was already there is a stranger's,
756
+ * or an older sitting's, and must not be swept up by a wait that was not about
757
+ * it.
758
+ *
759
+ * Three answers, and only one of them acts:
760
+ *
761
+ * - exactly one in the window → that is the pass, named by its code.
762
+ * - none → nothing to claim. Ordinary: the watch can fire on a write that was
763
+ * not a pass at all.
764
+ * - more than one → REFUSE and name the count, never pick. Two people, or two
765
+ * sittings, landed in one window; choosing between them is exactly the guess
766
+ * `claimPending` refuses to make, and the operator has the codes.
767
+ *
768
+ * A pass whose `at` cannot be parsed is never in the window. That is the
769
+ * cannot-tell case routed to inaction (`.claude/rules/negative-checks.md` rule
770
+ * 4): including it would auto-claim a pass whose age — the one tell a stranger
771
+ * has — could not be established.
772
+ */
773
+ function passesSince(pending, since) {
774
+ const from = Date.parse(since)
775
+ if (!Number.isFinite(from)) return { codes: [], usable: false }
776
+ const codes = (pending.passes || [])
777
+ .filter((p) => {
778
+ const at = Date.parse(p.at)
779
+ return Number.isFinite(at) && at >= from
780
+ })
781
+ .map((p) => p.code)
782
+ return { codes, usable: true }
783
+ }
784
+
785
+ /* ==========================================================================
786
+ * The gate — a standing obligation to review, not a message in flight
787
+ *
788
+ * A pending pass is something someone SENT. The gate is something the repo
789
+ * OWES: a phase ended, its page was rendered, and nobody has said what they
790
+ * concluded yet. Kept in its own sidecar for exactly that reason — claiming a
791
+ * pass consumes the pass, while only a COMMITTING verdict or a recorded skip
792
+ * consumes the gate, and one file holding both states would have to encode
793
+ * that difference anyway.
794
+ *
795
+ * It is the one thing in this engine that refuses. The marks still gate
796
+ * nothing and nothing counts them — what this asserts is narrower: a phase
797
+ * that ended is not finished until a person said something about it.
798
+ * ========================================================================== */
799
+
800
+ const GATE_VERSION = 1
801
+
802
+ // How a disarm happened. `verdict` is a review that reached a committing
803
+ // conclusion; `skip` is the operator saying, on the record, that they are
804
+ // moving on without one.
805
+ const DISARMED_BY = ['verdict', 'skip']
806
+
807
+ function reviewGatePath(outPath) {
808
+ return outPath.replace(/\.html$/, '') + '.gate.json'
809
+ }
810
+
811
+ function emptyGate(specFolder) {
812
+ return { version: GATE_VERSION, spec: specFolder, armed: false, armedAt: null, phase: null, log: [] }
813
+ }
814
+
815
+ /**
816
+ * Read the gate sidecar. Never throws, and reports `corrupt` rather than
817
+ * hiding it — the same three states `readNotes` answers in, for the same
818
+ * reason. Here the third state is load-bearing in the other direction: a gate
819
+ * we cannot parse must never READ AS ARMED, because that would refuse a commit
820
+ * on the strength of a file nobody can interpret.
821
+ */
822
+ function readGate(outPath, specFolder) {
823
+ let raw
824
+ try {
825
+ raw = fs.readFileSync(reviewGatePath(outPath), 'utf8')
826
+ } catch {
827
+ // Absent is the ordinary state — a project that never ends a phase through
828
+ // the review path has no gate file at all.
829
+ return { gate: emptyGate(specFolder), corrupt: false, present: false }
830
+ }
831
+ try {
832
+ const parsed = JSON.parse(raw)
833
+ return {
834
+ gate: {
835
+ version: parsed.version,
836
+ spec: parsed.spec || specFolder,
837
+ armed: parsed.armed === true,
838
+ armedAt: parsed.armedAt || null,
839
+ phase: parsed.phase === undefined ? null : parsed.phase,
840
+ log: Array.isArray(parsed.log) ? parsed.log : [],
841
+ },
842
+ corrupt: false,
843
+ present: true,
844
+ }
845
+ } catch {
846
+ return { gate: emptyGate(specFolder), corrupt: true, present: true }
847
+ }
848
+ }
849
+
850
+ function writeGate(outPath, gate) {
851
+ const p = reviewGatePath(outPath)
852
+ fs.mkdirSync(path.dirname(p), { recursive: true })
853
+ fs.writeFileSync(p, JSON.stringify(gate, null, 2) + '\n')
854
+ return p
855
+ }
856
+
857
+ /**
858
+ * Arm the gate. Pure.
859
+ *
860
+ * IDEMPOTENT ON PURPOSE. Re-rendering a page for a phase already awaiting a
861
+ * verdict must not move `armedAt` — the timestamp answers "how long has this
862
+ * been waiting", and a render is not an event that resets that. A gate armed
863
+ * for a DIFFERENT phase is re-armed, because that is a new obligation.
864
+ */
865
+ function armGate(gate, { at, phase = null }) {
866
+ if (gate.armed && gate.phase === phase) return gate
867
+ return { ...gate, armed: true, armedAt: at, phase }
868
+ }
869
+
870
+ /**
871
+ * Disarm it, and say how. Pure.
872
+ *
873
+ * The log is append-only and nothing reads it back to decide anything — it is
874
+ * the record that a decision was taken, which is the whole value of a skip
875
+ * over a silence. Disarming an already-clear gate logs nothing: there was no
876
+ * obligation, so there is no outcome to record.
877
+ */
878
+ function disarmGate(gate, { at, by, reason = null }) {
879
+ if (!gate.armed) return { gate, logged: false }
880
+ const log = Array.isArray(gate.log) ? gate.log.slice() : []
881
+ log.push({ by, at, phase: gate.phase === undefined ? null : gate.phase, reason })
882
+ return { gate: { ...gate, armed: false, armedAt: null, phase: null, log }, logged: true }
883
+ }
884
+
885
+ /**
886
+ * What the gate says, in three states. Pure.
887
+ *
888
+ * `armed` is the only one that refuses, and it is reached only by a POSITIVE
889
+ * signal: a sidecar that is present, parseable, and says so
890
+ * (`.claude/rules/negative-checks.md` rule 1). Everything else routes to the
891
+ * harmless branch (rule 4) under its own name:
892
+ *
893
+ * - `clear` — read it, nothing is owed.
894
+ * - `unknown` — could not read it, or the project turned the gate off. Nothing
895
+ * is claimed and nothing refuses.
896
+ *
897
+ * WHAT WOULD FOOL THIS: a gate armed for a phase whose work has since been
898
+ * committed by hand still reads armed, so the refusal outlives the thing it
899
+ * was guarding. That is deliberate — the exit is one `skip` with a reason,
900
+ * which is precisely the decision this exists to put on the record — and it
901
+ * fails toward asking rather than toward letting a phase through unread.
902
+ */
903
+ /**
904
+ * What the page is told about the gate. Pure. `null` when there is nothing to
905
+ * say, so a project that never armed one renders byte-identically to how it
906
+ * did before any of this existed — the same rule `phases` and `context` follow.
907
+ *
908
+ * The last SKIP travels with it, because that is the page's answer to the one
909
+ * question an untouched-looking review raises: was this read and moved past, or
910
+ * never read at all? A verdict already had an answer there; a skip did not.
911
+ */
912
+ function gateForPage(gate) {
913
+ if (!gate) return null
914
+ const log = Array.isArray(gate.log) ? gate.log : []
915
+ const skips = log.filter((e) => e && e.by === 'skip')
916
+ const lastSkip = skips.length ? skips[skips.length - 1] : null
917
+ if (!gate.armed && !lastSkip) return null
918
+ return {
919
+ armed: gate.armed === true,
920
+ armedAt: gate.armedAt || null,
921
+ phase: gate.phase === undefined ? null : gate.phase,
922
+ ...(lastSkip ? { lastSkip: { at: lastSkip.at || null, reason: lastSkip.reason || null } } : {}),
923
+ }
924
+ }
925
+
926
+ function gateState({ gate, corrupt, present, required }) {
927
+ if (required === false) return { state: 'unknown', reason: 'review.required is false', gate }
928
+ if (corrupt) return { state: 'unknown', reason: 'the gate sidecar is not readable JSON', gate }
929
+ if (!present) return { state: 'clear', reason: 'no gate recorded', gate }
930
+ if (gate.version !== GATE_VERSION) {
931
+ return {
932
+ state: 'unknown',
933
+ reason: `gate version ${JSON.stringify(gate.version)} — this engine reads version ${GATE_VERSION}`,
934
+ gate,
935
+ }
936
+ }
937
+ if (!gate.armed) return { state: 'clear', reason: 'nothing is awaiting a verdict', gate }
938
+ return { state: 'armed', reason: 'a phase is awaiting a verdict', gate }
939
+ }
940
+
708
941
  /**
709
942
  * Validate a blob from the page, wholesale.
710
943
  *
@@ -1269,6 +1502,8 @@ module.exports = {
1269
1502
  NOTES_VERSION,
1270
1503
  VERDICTS,
1271
1504
  COMMITTING,
1505
+ BUTTON_SETS,
1506
+ DEFAULT_BUTTON_SET,
1272
1507
  readVerdict,
1273
1508
  DEFAULT_VERDICT,
1274
1509
  DELETED_HASH,
@@ -1289,9 +1524,20 @@ module.exports = {
1289
1524
  mintPendingCode,
1290
1525
  addPending,
1291
1526
  claimPending,
1527
+ passesSince,
1292
1528
  describePending,
1293
1529
  pendingAge,
1294
1530
  PENDING_CODE_LENGTH,
1531
+ GATE_VERSION,
1532
+ DISARMED_BY,
1533
+ reviewGatePath,
1534
+ emptyGate,
1535
+ readGate,
1536
+ writeGate,
1537
+ armGate,
1538
+ disarmGate,
1539
+ gateState,
1540
+ gateForPage,
1295
1541
  mergeNotes,
1296
1542
  applyResolutions,
1297
1543
  applyNotes,
package/src/env/serve.js CHANGED
@@ -8,9 +8,16 @@
8
8
  * moment: true when it was taken, and overwritten by the next render. A served
9
9
  * page cannot be out of date, because there is no artefact between the git
10
10
  * objects and the response. That is the whole reason this exists, and it is why
11
- * nothing here reads or writes `.spec-env/reviews/` — the file path and the
12
- * served path are two answers to the same question, and keeping them
13
- * independent is what stops one quietly becoming the other's cache.
11
+ * no HTML is read from or written to `.spec-env/reviews/` — the file path and
12
+ * the served path are two answers to the same question, and keeping the
13
+ * artefacts independent is what stops one quietly becoming the other's cache.
14
+ *
15
+ * THE SIDECARS ARE A DIFFERENT MATTER, and this once over-applied the rule
16
+ * above to them. Notes, the gate and the pending store are the REVIEW'S state,
17
+ * not the page's: they belong to whoever is reading, and a reader on a phone is
18
+ * reading this page. So they are read here (and, for a pass arriving, written)
19
+ * — which is what makes a refresh keep your accepts and the history line say
20
+ * what happened. What is never read here is a rendered page.
14
21
  *
15
22
  * Dependency-free, in the shape of `proxy.js`: pure functions for routing and
16
23
  * the index, an injectable render callback, and a `require.main` entry point so
@@ -40,6 +47,8 @@ const {
40
47
  readPending,
41
48
  writePending,
42
49
  addPending,
50
+ readNotes,
51
+ readGate,
43
52
  } = require('./review.js')
44
53
 
45
54
  /**
@@ -248,7 +257,20 @@ function renderSpecPage(dir, config, spec, { branch = false } = {}) {
248
257
  mode = 'branch'
249
258
  }
250
259
 
251
- let data = collectReview({ spec, git, mode, ref, base: baseName, now })
260
+ // THE REVIEW STATE IS THE READER'S, not the artefact's. It was once left out
261
+ // here on the reasoning that this path writes no file — but a reader on a
262
+ // phone is reading THIS page, and without the sidecar their own accepts
263
+ // vanish on every refresh and the history line never appears at all. The two
264
+ // surfaces answered differently about the same review, which is worse than
265
+ // either answer. Read-only: nothing on this path writes the sidecar.
266
+ const out = reviewOutPath(dir, spec.folder, null)
267
+ const notes = readNotes(out, spec.folder).notes
268
+ const gateRead = readGate(out, spec.folder)
269
+ // A corrupt gate contributes nothing rather than failing the render. The page
270
+ // is a convenience and the gate is not what it is for.
271
+ const gate = gateRead.corrupt ? null : gateRead.gate
272
+
273
+ let data = collectReview({ spec, git, mode, ref, base: baseName, now, notes, gate })
252
274
 
253
275
  if (!branch && data.totals.files === 0) {
254
276
  const fallbackBase = base()
@@ -261,6 +283,8 @@ function renderSpecPage(dir, config, spec, { branch = false } = {}) {
261
283
  ref: mergeBase,
262
284
  base: fallbackBase,
263
285
  now,
286
+ notes,
287
+ gate,
264
288
  fellBack: true,
265
289
  })
266
290
  if (wider.totals.files > 0) {
package/src/init.js CHANGED
@@ -5,6 +5,7 @@ const path = require('path')
5
5
  const crypto = require('crypto')
6
6
 
7
7
  const { ensureWorktreeDirTrusted } = require('./env/trust.js')
8
+ const { ensureReviewGateHook } = require('./env/hooks.js')
8
9
  const { repoInfo, expandTokens } = require('./env/resolve.js')
9
10
 
10
11
  const ASSETS = path.join(__dirname, '..', 'assets')
@@ -38,6 +39,29 @@ function listCommands() {
38
39
  }
39
40
  }
40
41
 
42
+ // Hook scripts shipped as `assets/hooks/*.cjs` (or `*.mjs`), installed to
43
+ // `.claude/hooks/`. Discovered from the bundled tree like everything else, so a
44
+ // distribution installs precisely what it ships and a hook can be retired by
45
+ // deleting it.
46
+ //
47
+ // A BARE `.js` IS DELIBERATELY NOT DISCOVERED. These files are copied into the
48
+ // TARGET project, where that project's `package.json` — the one file skitterspec
49
+ // does not control — decides how node parses a `.js`. Shipping CommonJS as `.js`
50
+ // crashed the review-gate hook in every `"type": "module"` project, on every
51
+ // Bash tool call. The extension is the only thing that settles it at the file,
52
+ // so both accepted forms pin it; `env-review-hook.test.js` asserts the rule over
53
+ // the whole directory rather than over this filter.
54
+ function listHooks() {
55
+ try {
56
+ return fs
57
+ .readdirSync(path.join(ASSETS, 'hooks'))
58
+ .filter((f) => f.endsWith('.cjs') || f.endsWith('.mjs'))
59
+ .sort()
60
+ } catch {
61
+ return [] // a distribution may ship no hooks
62
+ }
63
+ }
64
+
41
65
  function listRules() {
42
66
  return fs
43
67
  .readdirSync(path.join(ASSETS, 'rules'))
@@ -61,6 +85,8 @@ const COMMANDS = listCommands()
61
85
 
62
86
  const RULES = listRules()
63
87
 
88
+ const HOOKS = listHooks()
89
+
64
90
  const SPEC_FOLDERS = ['.core', 'backlog', 'in-progress', 'complete', 'cancelled']
65
91
 
66
92
  // Opt-in config templates, scaffolded into specs/.core/ (the base ships the
@@ -209,6 +235,7 @@ function managedTargets(dir) {
209
235
  for (const name of COMMANDS)
210
236
  add(path.join('commands', name), path.join(dir, '.claude', 'commands', name), renderCommand)
211
237
  for (const name of RULES) add(path.join('rules', name), path.join(dir, '.claude', 'rules', name))
238
+ for (const name of HOOKS) add(path.join('hooks', name), path.join(dir, '.claude', 'hooks', name))
212
239
  for (const asset of CORE_FILES) add(asset, path.join(dir, 'specs', '.core', path.basename(asset)))
213
240
  return out
214
241
  }
@@ -372,6 +399,46 @@ function installRule(dir, opts) {
372
399
  }
373
400
  }
374
401
 
402
+ // Register the review-gate hook in the project's committed settings, so a
403
+ // phase that owes a verdict is enforced one level below the skills. Best-effort
404
+ // in exactly the way `trustWorktreeRoot` is: a settings file we cannot parse is
405
+ // reported and left alone, never rewritten, and never fatal — the hook is an
406
+ // extra layer, and the engine and `/spec-next` hold the gate without it.
407
+ function registerReviewGateHook(dir) {
408
+ const label = '.claude/settings.json (review-gate hook)'
409
+ let res
410
+ try {
411
+ res = ensureReviewGateHook(dir)
412
+ } catch {
413
+ report.warnings.push('could not write .claude/settings.json — review-gate hook not registered')
414
+ return
415
+ }
416
+ if (res.reason === 'malformed') {
417
+ report.warnings.push(
418
+ '.claude/settings.json is not valid JSON — did not register the review-gate hook',
419
+ )
420
+ } else if (res.reason === 'created') {
421
+ report.created.push(label)
422
+ } else if (res.reason === 'added') {
423
+ report.updated.push(label)
424
+ } else if (res.reason === 'migrated') {
425
+ // A WRITE, so it may not fall through to `skipped`. It was doing exactly
426
+ // that: rewriting the registered path and then reporting "already
427
+ // registered" — a run that acts and says it did not, which is the shape of
428
+ // the bug this whole change exists to fix.
429
+ report.updated.push('.claude/settings.json (review-gate hook repointed at the renamed script)')
430
+ } else {
431
+ report.skipped.push('.claude/settings.json (review-gate hook already registered)')
432
+ }
433
+ }
434
+
435
+ function installHooks(dir, opts) {
436
+ for (const name of HOOKS) {
437
+ copyAsset(dir, path.join('hooks', name), path.join(dir, '.claude', 'hooks', name), opts)
438
+ }
439
+ registerReviewGateHook(dir)
440
+ }
441
+
375
442
  function installFolders(dir) {
376
443
  for (const folder of SPEC_FOLDERS) {
377
444
  const abs = path.join(dir, 'specs', folder)
@@ -676,6 +743,15 @@ function resync(dir, { force = false, claudeMd = true, diff = false } = {}) {
676
743
  installFolders(dir)
677
744
  removeRetiredFiles(dir)
678
745
  pruneRetiredManaged(dir, manifest)
746
+ // The hook SCRIPT arrives above, as one more managed target; registering it is
747
+ // a separate write to a file we do not manage, so it has to be asked for here.
748
+ // It is easy to read this as duplication of `installHooks()` and delete it —
749
+ // it is not. `installHooks()` is unreachable from this path, and the two
750
+ // halves being in different functions is exactly how they drifted apart once:
751
+ // `update` copied `.claude/hooks/` and registered nothing, for every upgrading
752
+ // project, while reporting a file created. `init-review-gate-hook.test.js`
753
+ // asserts over the entry points rather than over this call.
754
+ registerReviewGateHook(dir)
679
755
  if (claudeMd) installClaudeMd(dir, { mode: 'update' })
680
756
  flushManifest(dir)
681
757
  printReport(dir, 'resync', { diff })
@@ -737,6 +813,7 @@ function reset(dir, { claudeMd = true } = {}) {
737
813
  installSkills(dir, { force: true })
738
814
  installCommands(dir, { force: true })
739
815
  installRule(dir, { force: true })
816
+ installHooks(dir, { force: true })
740
817
  installFolders(dir)
741
818
  removeRetiredFiles(dir)
742
819
  installCore(dir, { force: true })
@@ -836,6 +913,7 @@ async function init({ dir, force, claudeMd, mode, isolation, workspaceMode, gati
836
913
  installSkills(dir, { force })
837
914
  installCommands(dir, { force })
838
915
  installRule(dir, { force })
916
+ installHooks(dir, { force })
839
917
  installFolders(dir)
840
918
  removeRetiredFiles(dir)
841
919
  installCore(dir, { force })