@skitterbyte/skitterspec-linear 13.0.0 → 15.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +170 -0
- package/assets/core/env.config.json.example +2 -1
- package/assets/core/env.config.md +17 -3
- package/assets/hooks/review-gate.cjs +141 -0
- package/assets/review/page.html +366 -30
- package/assets/rules/spec-planning.md +65 -13
- package/assets/rules/spec-reports.md +136 -16
- package/assets/skills/spec-bug/SKILL.md +38 -23
- package/assets/skills/spec-diff/SKILL.md +171 -8
- package/assets/skills/spec-hotfix/SKILL.md +31 -20
- package/assets/skills/spec-init/SKILL.md +34 -0
- package/assets/skills/spec-next/SKILL.md +167 -26
- package/assets/skills/spec-reviewed/SKILL.md +33 -16
- package/package.json +1 -1
- package/src/cli.js +326 -4
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +17 -1
- package/src/env/hooks.js +157 -0
- package/src/env/review.js +249 -3
- package/src/env/serve.js +28 -4
- package/src/init.js +78 -0
- package/src/vendor/sync-core/src/retarget.js +1 -1
- package/src/vendor/sync-core/src/task-block.js +2 -2
package/src/env/hooks.js
ADDED
|
@@ -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
|
|
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
|
-
*
|
|
12
|
-
* served path are two answers to the same question, and keeping
|
|
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
|
-
|
|
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 })
|
|
@@ -36,7 +36,7 @@ const BUCKETS = ['backlog', 'in-progress', 'complete', 'cancelled']
|
|
|
36
36
|
// An issue identifier: team key, dash, number. Matched CASE-INSENSITIVELY
|
|
37
37
|
// because `linear_url` carries the identifier lowercased in its path
|
|
38
38
|
// (`…/issue/reu-188/retire-…`). Matching only uppercase left 29 of 33 real URLs
|
|
39
|
-
// in
|
|
39
|
+
// in a live repo pointing at the old key — the same ones the hand-repair
|
|
40
40
|
// missed. A rewrite only happens when the key matches `oldKey`, so an unrelated
|
|
41
41
|
// token like `utf-8` is never in scope.
|
|
42
42
|
const IDENTIFIER_RE = /\b([A-Za-z][A-Za-z0-9]*)-(\d+)\b/g
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
// Task bullets in a phase file are hand-wrapped prose, not single lines:
|
|
4
4
|
//
|
|
5
|
-
// - [x] Add
|
|
6
|
-
//
|
|
5
|
+
// - [x] Add the retry column to the outbox table, modelled on the existing
|
|
6
|
+
// queue: status, attempts, `nextAttemptAt`, …
|
|
7
7
|
//
|
|
8
8
|
// A Linear issue title is single-line, so the two representations differ by
|
|
9
9
|
// wrapping alone. This module is the one place that converts between them:
|