@skitterbyte/skitterspec-jira 0.0.0-stage → 1.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.
Files changed (109) hide show
  1. package/MIGRATION.md +1089 -0
  2. package/README.md +42 -2
  3. package/assets/claude-md-section.md +85 -0
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-connect.md +13 -0
  6. package/assets/commands/spec-live.md +14 -0
  7. package/assets/commands/spec-remote-review.md +37 -0
  8. package/assets/commands/spec-skip.md +24 -0
  9. package/assets/core/SETUP.md +192 -0
  10. package/assets/core/env.config.json.example +56 -0
  11. package/assets/core/env.config.md +554 -0
  12. package/assets/core/gating.config.json.example +4 -0
  13. package/assets/core/gating.config.md +81 -0
  14. package/assets/core/jira.config.json.example +18 -0
  15. package/assets/core/jira.config.md +61 -0
  16. package/assets/hooks/main-guard.cjs +162 -0
  17. package/assets/hooks/review-gate.cjs +146 -0
  18. package/assets/review/page.html +3384 -0
  19. package/assets/rules/commit-trailers.md +96 -0
  20. package/assets/rules/negative-checks.md +69 -0
  21. package/assets/rules/offered-unblocks.md +100 -0
  22. package/assets/rules/spec-planning.md +692 -0
  23. package/assets/rules/spec-reports.md +557 -0
  24. package/assets/skills/no-spec/SKILL.md +246 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +583 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +448 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +605 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +220 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +676 -0
  35. package/assets/skills/spec-push/SKILL.md +306 -0
  36. package/assets/skills/spec-review/SKILL.md +256 -0
  37. package/assets/skills/spec-reviewed/SKILL.md +284 -0
  38. package/assets/skills/spec-start/SKILL.md +496 -0
  39. package/assets/skills/spec-status/SKILL.md +112 -0
  40. package/assets/skills/spec-sync/SKILL.md +218 -0
  41. package/assets/skills/spec-to-main/SKILL.md +139 -0
  42. package/bin/skitterspec-jira.js +61 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5901 -0
  45. package/src/deprecate.js +138 -0
  46. package/src/env/bootstrap.js +188 -0
  47. package/src/env/building.js +143 -0
  48. package/src/env/classify.js +176 -0
  49. package/src/env/commitcmd.js +108 -0
  50. package/src/env/config.js +753 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/hooks.js +226 -0
  53. package/src/env/hotfix.js +385 -0
  54. package/src/env/hotfixland.js +302 -0
  55. package/src/env/integrate.js +126 -0
  56. package/src/env/live.js +504 -0
  57. package/src/env/mainguard.js +299 -0
  58. package/src/env/provision.js +466 -0
  59. package/src/env/proxy.js +192 -0
  60. package/src/env/prune.js +119 -0
  61. package/src/env/registry.js +159 -0
  62. package/src/env/render.js +17 -0
  63. package/src/env/resolve.js +716 -0
  64. package/src/env/review.js +2577 -0
  65. package/src/env/reviewers/coderabbit.js +228 -0
  66. package/src/env/reviewers.js +517 -0
  67. package/src/env/serve.js +1044 -0
  68. package/src/env/supervise.js +157 -0
  69. package/src/env/teardown.js +248 -0
  70. package/src/env/trust.js +87 -0
  71. package/src/gating.js +155 -0
  72. package/src/init.js +1150 -0
  73. package/src/lines-diff.js +114 -0
  74. package/src/prompts.js +129 -0
  75. package/src/vendor/jira/adf.js +414 -0
  76. package/src/vendor/jira/api.js +295 -0
  77. package/src/vendor/jira/cli-sync.js +1256 -0
  78. package/src/vendor/jira/commands.js +44 -0
  79. package/src/vendor/jira/config.js +296 -0
  80. package/src/vendor/jira/credentials.js +108 -0
  81. package/src/vendor/jira/dialect.js +45 -0
  82. package/src/vendor/jira/mcp.js +129 -0
  83. package/src/vendor/provider-kit/index.js +41 -0
  84. package/src/vendor/provider-kit/src/adapter.js +23 -0
  85. package/src/vendor/provider-kit/src/apply.js +197 -0
  86. package/src/vendor/provider-kit/src/args.js +68 -0
  87. package/src/vendor/provider-kit/src/config.js +198 -0
  88. package/src/vendor/provider-kit/src/credentials.js +404 -0
  89. package/src/vendor/provider-kit/src/doctor.js +35 -0
  90. package/src/vendor/provider-kit/src/http.js +44 -0
  91. package/src/vendor/provider-kit/src/identity.js +93 -0
  92. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  93. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  94. package/src/vendor/provider-kit/src/prompt.js +50 -0
  95. package/src/vendor/provider-kit/src/released.js +226 -0
  96. package/src/vendor/provider-kit/src/specs.js +166 -0
  97. package/src/vendor/provider-kit/src/transport.js +22 -0
  98. package/src/vendor/sync-core/index.js +92 -0
  99. package/src/vendor/sync-core/src/base.js +81 -0
  100. package/src/vendor/sync-core/src/compare.js +306 -0
  101. package/src/vendor/sync-core/src/dialect.js +51 -0
  102. package/src/vendor/sync-core/src/fields.js +36 -0
  103. package/src/vendor/sync-core/src/legacy.js +100 -0
  104. package/src/vendor/sync-core/src/normalize.js +989 -0
  105. package/src/vendor/sync-core/src/push.js +85 -0
  106. package/src/vendor/sync-core/src/retarget.js +278 -0
  107. package/src/vendor/sync-core/src/task-block.js +308 -0
  108. package/src/vendor/sync-core/src/verify.js +67 -0
  109. package/src/vendor/sync-core/src/write.js +191 -0
@@ -0,0 +1,2577 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Collect a spec's diff and emit a self-contained review page.
5
+ *
6
+ * Everything here reads the spec's worktree with `git -C <worktreePath>` and
7
+ * never changes directory or spawns anything inside it — the whole point of the
8
+ * feature is that you review a worktree's work from wherever your shell already
9
+ * is (usually the primary checkout, often a phone). The collector is pure apart
10
+ * from the injected `git` runner; the output path and the clock come from the
11
+ * caller so the page is deterministic under test.
12
+ *
13
+ * The diff data NEVER passes through a model: git writes the patches, this
14
+ * module splices them into the page's JSON island as text. That is what makes a
15
+ * 266KB patch cost nothing to produce.
16
+ */
17
+
18
+ const fs = require('node:fs')
19
+ const path = require('node:path')
20
+ const crypto = require('node:crypto')
21
+ const { readPhases, readOverview } = require('./resolve.js')
22
+ const { execFileSync } = require('node:child_process')
23
+
24
+ // `-U` large enough that a file's patch IS the file. Reviewing a changed line
25
+ // needs the code around it, and this costs a git flag rather than a second read;
26
+ // the viewer collapses the unchanged runs back down.
27
+ const WHOLE_FILE_CONTEXT = 100000
28
+
29
+ // Past this, whole-file context is bloating the page rather than informing it —
30
+ // regenerate that one file at -U3 and say so. Deliberately a constant and NOT a
31
+ // config key: nobody is going to tune this, and an untuned key is a surface to
32
+ // document, validate and test for no gain.
33
+ const PATCH_LIMIT_BYTES = 400 * 1024
34
+
35
+ // Where the page lands by default. `.spec-env/` is gitignored by the installer,
36
+ // so a review is local, free and leaves no trace in the branch under review.
37
+ const REVIEW_DIR = path.join('.spec-env', 'reviews')
38
+
39
+ // Bookkeeping the viewer should collapse: the spec folders themselves and the
40
+ // push snapshot that lives among them. The ENGINE decides this, because it knows
41
+ // the spec's own paths — a viewer guessing from regexes would get it wrong the
42
+ // first time someone's product code lived under a folder called `specs`.
43
+ function isNoise(relPath) {
44
+ return relPath.startsWith('specs/') || relPath.startsWith('.spec-env/')
45
+ }
46
+
47
+ /**
48
+ * Is this path one of `folder`'s own spec documents, in any bucket?
49
+ *
50
+ * Every bucket, for the same reason `classifyDirtyTree` checks every bucket: a
51
+ * tree mid-`git mv` is dirty in two of them at once and both halves are the
52
+ * same spec's.
53
+ *
54
+ * It exists because `isNoise` above is exactly wrong for the `docs` mode. That
55
+ * rule — everything under `specs/` is bookkeeping — is right for a phase's code
56
+ * diff, where the spec's own checkbox edits are not what anyone came to read.
57
+ * On a page whose SUBJECT is the spec, applying it folds away every file and
58
+ * renders a page with nothing open on it.
59
+ */
60
+ function isSpecDocOf(relPath, folder) {
61
+ const parts = String(relPath).split('/')
62
+ return parts.length > 3 && parts[0] === 'specs' && parts[2] === folder
63
+ }
64
+
65
+ /**
66
+ * A git runner bound to one checkout, returning stdout **untrimmed**.
67
+ *
68
+ * The untrimmed part is load-bearing and is why this does not reuse the CLI's
69
+ * `gitReader`: `git status --porcelain` puts a SPACE in the first column for an
70
+ * unstaged change, so trimming the whole output shifts every path by one
71
+ * character. (Found the hard way in the prototype.)
72
+ *
73
+ * A non-zero exit whose stdout is still useful is returned rather than thrown —
74
+ * `git diff --no-index` exits 1 precisely when the files differ, which is the
75
+ * case we call it for.
76
+ */
77
+ function rawGitReader(worktreePath) {
78
+ return (argv) => {
79
+ try {
80
+ return execFileSync('git', ['-C', worktreePath, ...argv], {
81
+ stdio: ['ignore', 'pipe', 'ignore'],
82
+ encoding: 'utf8',
83
+ maxBuffer: 256 * 1024 * 1024,
84
+ })
85
+ } catch (err) {
86
+ if (err && typeof err.stdout === 'string') return err.stdout
87
+ if (err && err.stdout) return err.stdout.toString()
88
+ return null
89
+ }
90
+ }
91
+ }
92
+
93
+ // Split output into lines WITHOUT trimming the lines themselves (see above).
94
+ // Only the trailing newline git always emits is dropped.
95
+ function lines(out) {
96
+ if (!out) return []
97
+ return out.replace(/\n$/, '').split('\n').filter((l) => l !== '')
98
+ }
99
+
100
+ // `--numstat` emits one row per file; we ask per-file, so take the FIRST line
101
+ // and split that — splitting the whole output merges a second file's counts into
102
+ // the first (the prototype's other bug). A binary file reports `-` for both.
103
+ function parseNumstat(out) {
104
+ const first = lines(out)[0]
105
+ if (!first) return { additions: 0, deletions: 0, binary: false }
106
+ const [a, d] = first.split('\t')
107
+ if (a === '-' || d === '-') return { additions: 0, deletions: 0, binary: true }
108
+ return { additions: Number(a) || 0, deletions: Number(d) || 0, binary: false }
109
+ }
110
+
111
+ // Map a `--name-status` code to the status the viewer shows.
112
+ function statusFromCode(code) {
113
+ const c = code[0]
114
+ if (c === 'A') return 'new'
115
+ if (c === 'D') return 'deleted'
116
+ if (c === 'R') return 'renamed'
117
+ if (c === 'C') return 'copied'
118
+ return 'modified'
119
+ }
120
+
121
+ /**
122
+ * The tracked files that changed between `ref` and the working tree, in git's
123
+ * own order. `R`/`C` rows carry two paths (old, new) — the NEW path is the one
124
+ * to diff and display, and the old one is kept so the viewer can say where it
125
+ * came from.
126
+ */
127
+ function trackedFiles(git, ref) {
128
+ // Rename detection is git's default (diff.renames since 2.9) — left implicit
129
+ // rather than forced, so a project that turns it off gets what it configured.
130
+ const out = git(['diff', ref, '--name-status'])
131
+ const rows = []
132
+ for (const line of lines(out)) {
133
+ const parts = line.split('\t')
134
+ const code = parts[0]
135
+ if (!code) continue
136
+ if ((code[0] === 'R' || code[0] === 'C') && parts.length >= 3) {
137
+ rows.push({ path: parts[2], from: parts[1], status: statusFromCode(code) })
138
+ } else if (parts.length >= 2) {
139
+ rows.push({ path: parts[1], from: null, status: statusFromCode(code) })
140
+ }
141
+ }
142
+ return rows
143
+ }
144
+
145
+ /**
146
+ * Untracked files, which `git diff` cannot see at all — a new test file would
147
+ * otherwise be invisible, and that is the single most review-worthy thing a
148
+ * phase produces. `-uall` lists them individually rather than collapsing a new
149
+ * directory to one `dir/` row we would then have to walk ourselves.
150
+ */
151
+ function untrackedFiles(git) {
152
+ const out = git(['status', '--porcelain', '-uall'])
153
+ const rows = []
154
+ for (const line of lines(out)) {
155
+ // Columns are fixed: XY then a space then the path. NEVER trim the line.
156
+ if (line.slice(0, 2) !== '??') continue
157
+ const p = line.slice(3)
158
+ if (p) rows.push({ path: p, from: null, status: 'new' })
159
+ }
160
+ return rows
161
+ }
162
+
163
+ // The patch for one file, whole-file by default and falling back to -U3 when
164
+ // that is absurdly large. `untracked` files come in via --no-index against
165
+ // /dev/null, which is the only way git will diff a file it does not track.
166
+ function patchFor(git, ref, file, untracked) {
167
+ const gen = (u) =>
168
+ untracked
169
+ ? git(['diff', '--no-index', `--unified=${u}`, '--', '/dev/null', file.path])
170
+ : git(['diff', ref, `--unified=${u}`, '--', file.path])
171
+
172
+ let patch = gen(WHOLE_FILE_CONTEXT) || ''
173
+ let whole = true
174
+ if (Buffer.byteLength(patch, 'utf8') > PATCH_LIMIT_BYTES) {
175
+ patch = gen(3) || ''
176
+ whole = false
177
+ }
178
+ return { patch, whole }
179
+ }
180
+
181
+ function numstatFor(git, ref, file, untracked) {
182
+ const out = untracked
183
+ ? git(['diff', '--no-index', '--numstat', '--', '/dev/null', file.path])
184
+ : git(['diff', ref, '--numstat', '--', file.path])
185
+ return parseNumstat(out)
186
+ }
187
+
188
+ /**
189
+ * Where is the person reading this?
190
+ *
191
+ * `local` — at the machine that holds the page, so a `file://` URL opens.
192
+ * `remote` — somewhere else, so it does not.
193
+ * `unknown` — CANNOT TELL, and that is a real answer rather than a soft `local`.
194
+ *
195
+ * **This decides two things: the WORDING, and WHAT THE SERVER BINDS TO.** It
196
+ * does not decide *whether* to serve — `review.serve` does — and it never
197
+ * decides to publish. Being wrong therefore stays cheap in both directions: a
198
+ * wrong `local` binds loopback, which is reachable from the machine holding the
199
+ * page and says so; a wrong `remote` binds every interface, which is the
200
+ * existing behaviour and is announced on the render.
201
+ *
202
+ * IT DID DECIDE WHETHER TO SERVE, ONCE, and this comment claimed otherwise
203
+ * throughout — the sentence above used to read "nothing in the engine serves on
204
+ * the strength of it" while `cli.js` served only for a `remote` reader. The bill
205
+ * was a `file://` link on a local machine, and a `file://` page has no server to
206
+ * POST to, so the verdict buttons on it had nowhere to go: the loop the page
207
+ * exists to close was absent from the sessions easiest to use. If you are here
208
+ * wondering whether to make detection load-bearing again, that is what it cost
209
+ * the last time, and `env-serve-start-proof` now fails if you do.
210
+ *
211
+ * `env` is passed in, never read from `process` here, so a test states the world
212
+ * it is testing instead of inheriting the machine the suite happens to run on.
213
+ *
214
+ * WHAT WOULD FOOL THIS: the bridge variable is an undocumented harness internal
215
+ * and may be renamed or dropped, so its ABSENCE proves nothing — which is the
216
+ * whole reason `unknown` exists and the default is not `local`.
217
+ */
218
+ function detectReader(env = {}) {
219
+ // SSH first: a standard convention, and the strongest available signal. If the
220
+ // shell arrived over the network, the page's path is on a machine the reader
221
+ // is not looking at.
222
+ if (env.SSH_CONNECTION || env.SSH_TTY) return { reader: 'remote', why: 'ssh' }
223
+
224
+ // The operator is driving this session from somewhere else — the case that
225
+ // produced the original dead link, read on a phone.
226
+ if (env.CLAUDE_CODE_BRIDGE_SESSION_ID) return { reader: 'remote', why: 'bridge session' }
227
+
228
+ // CLAUDE_CODE_ENTRYPOINT IS DELIBERATELY NOT CONSULTED. It describes the
229
+ // PROCESS, not the reader, and reports `cli` for a bridged session — it said
230
+ // exactly that for the session this was written from, where the reader was on
231
+ // a phone. Using it would produce a confident, wrong `local`.
232
+ //
233
+ // A TTY CHECK IS ALSO USELESS, and is named so nobody reaches for it: stdin is
234
+ // never a tty under Claude Code, so it discriminates nothing at all.
235
+ return { reader: 'unknown', why: null }
236
+ }
237
+
238
+ /**
239
+ * The reader, config first. An explicit `local`/`remote` is BELIEVED without
240
+ * sniffing: the operator knows where they are reading, and no signal outranks
241
+ * being told.
242
+ */
243
+ function resolveReader(config, env = {}) {
244
+ const setting = (config && config.review && config.review.reader) || 'detect'
245
+ if (setting === 'local' || setting === 'remote') return { reader: setting, why: 'configured' }
246
+ return detectReader(env)
247
+ }
248
+
249
+ /**
250
+ * Collect everything the page needs.
251
+ *
252
+ * `mode` is `'working'` (uncommitted work vs HEAD — the default, "what did this
253
+ * phase just do") or `'branch'` (everything since the base branch — "what does
254
+ * this whole spec do"). Both resolve to a single ref diffed against the working
255
+ * tree, so committed and uncommitted work are collected by one code path.
256
+ *
257
+ * `fellBack` records that `branch` was reached because the working tree was
258
+ * clean, not because the caller asked for it — see the fallback in
259
+ * `specEnvReview` (`cli.js`).
260
+ *
261
+ * `buttons` is the button set the page renders — see `BUTTON_SETS`. It is the
262
+ * caller's declaration about the work, not a reading of the gate.
263
+ */
264
+ function collectReview({ spec, git, mode = 'working', ref, base = null, now, notes = null, gate = null, fellBack = false, buttons = null, only = null, treePath = null, live = null, tiers = null }) {
265
+ // WHICH TREE THE SPEC'S OWN DOCUMENTS ARE READ FROM. Every render but one
266
+ // reads the spec's worktree, and for those the two are the same path — so
267
+ // `treePath` left null keeps the existing behaviour exactly. The `docs` mode
268
+ // has no worktree to read, which is the whole reason it exists.
269
+ const root = treePath || spec.worktreePath
270
+
271
+ // AN EXPLICIT FILE SET, or every changed file. `only` is a whitelist of
272
+ // repo-relative paths, and it exists because the `docs` mode reads a tree
273
+ // several sessions write into: rendering everything uncommitted there would
274
+ // put another spec's documents on this page and then commit them under this
275
+ // page's verdict. Null — every other caller — filters nothing.
276
+ const wanted = only ? new Set(only) : null
277
+ const keep = (f) => !wanted || wanted.has(f.path)
278
+
279
+ // WHAT COUNTS AS BOOKKEEPING, and it inverts for the `docs` mode. Everywhere
280
+ // else the spec's own documents are the bookkeeping beside the code; on a
281
+ // docs page they are the code, and the companions the project declared (a
282
+ // tracker snapshot, say) are what belongs folded away.
283
+ const noiseOf = (p) => (mode === 'docs' ? !isSpecDocOf(p, spec.folder) : isNoise(p))
284
+
285
+ // THE SURFACES BLOCK, built here so the page never has to know which tiers
286
+ // exist or which of them can be turned on. `null` when the caller passed
287
+ // neither a live state nor a stack — an absent key, not an empty one.
288
+ const surfaces = surfacesFor({ live, tiers, buttons })
289
+
290
+ const files = []
291
+ for (const f of trackedFiles(git, ref)) {
292
+ if (!keep(f)) continue
293
+ const { patch, whole } = patchFor(git, ref, f, false)
294
+ const { additions, deletions, binary } = numstatFor(git, ref, f, false)
295
+ files.push({ ...f, additions, deletions, binary, whole, noise: noiseOf(f.path), patch })
296
+ }
297
+ for (const f of untrackedFiles(git)) {
298
+ if (!keep(f)) continue
299
+ const { patch, whole } = patchFor(git, ref, f, true)
300
+ const { additions, deletions, binary } = numstatFor(git, ref, f, true)
301
+ files.push({ ...f, additions, deletions, binary, whole, noise: noiseOf(f.path), patch })
302
+ }
303
+
304
+ // NOTHING TO BE BOOKKEEPING BESIDE. The flag is relative — a spec's own
305
+ // checkbox edits are not what anyone came to read WHEN THERE IS A DIFF TO
306
+ // READ — and applying it with no relative left folds the whole change away,
307
+ // rendering a page whose subject is invisible until the reader happens to
308
+ // click a file in the tree. A change that is entirely bookkeeping IS the
309
+ // change, so it opens.
310
+ //
311
+ // WHAT WOULD FOOL A LOOSER VERSION OF THIS: one source file in the diff. The
312
+ // test is EVERY file, so the moment there is something else to read the
313
+ // margin notes go back to being margin notes — which is the flag's whole
314
+ // point and has its own stays-silent test.
315
+ if (files.length && files.every((f) => f.noise)) {
316
+ for (const f of files) f.noise = false
317
+ }
318
+
319
+ // Content hashes and the stored review state, folded on before the totals so
320
+ // the page and `--json` see one shape.
321
+ const store = notes || emptyNotes(spec.folder)
322
+ const { totals: noteTotals, unanchored } = applyNotes(files, store, fileHashes(git, files))
323
+
324
+ // The last honoured verdict, for the page to show as history. Only the last:
325
+ // the log is an audit trail and the page has one question to answer with it —
326
+ // why does this look untouched? — which the most recent entry answers.
327
+ const decisions = Array.isArray(store.decisions) ? store.decisions : []
328
+ const lastDecision = decisions.length ? decisions[decisions.length - 1] : null
329
+
330
+ // Every bucket, because a page is rendered for specs in `in-progress/` and for
331
+ // finished ones in `complete/` — and the finished one is the case this exists
332
+ // for. `null` when it cannot tell, and the page leaves its button alone.
333
+ const specDir = root ? findSpecDirIn(root, spec.folder) : null
334
+ const phases = specDir ? readPhases(specDir) : null
335
+ // The PR description this page never had: why the change exists, what it
336
+ // touches, and what this phase set out to do.
337
+ const context = readContextIn(specDir, phases)
338
+
339
+ const totals = files.reduce(
340
+ (acc, f) => ({
341
+ files: acc.files + 1,
342
+ additions: acc.additions + f.additions,
343
+ deletions: acc.deletions + f.deletions,
344
+ }),
345
+ { files: 0, additions: 0, deletions: 0 },
346
+ )
347
+
348
+ return {
349
+ spec: spec.folder,
350
+ title: spec.folder,
351
+ branch: spec.branch,
352
+ // The tree the diff was read from. Identical to the worktree for every
353
+ // mode but `docs`, where there is no worktree and this is the checkout the
354
+ // documents actually live in — saying `worktree` there would name a path
355
+ // that does not exist.
356
+ worktree: root,
357
+ mode,
358
+ base,
359
+ ref,
360
+ // True only when `branch` was reached by the clean-tree fallback rather
361
+ // than by `--branch`. The page says so, because "everything since main" and
362
+ // "everything since main, because there was nothing uncommitted" are
363
+ // different answers to "what am I looking at".
364
+ fellBack,
365
+ generatedAt: now,
366
+ // Is there a phase left for `/spec-next` to build? The page needs it to
367
+ // know whether offering `Commit & Continue` means anything — and it is read
368
+ // from the SPEC'S OWN WORKTREE, where its phase statuses are current. On the
369
+ // base branch an in-flight spec still reads as it did before it started.
370
+ //
371
+ // Absent stays absent: a spec whose phases cannot be read adds no key, so
372
+ // the page renders byte-identically to how it did before any of this.
373
+ ...(phases ? { phases } : {}),
374
+ // Absent stays absent, exactly as `phases` does: a spec this cannot read
375
+ // adds no key and the page renders its header-less self.
376
+ ...(context ? { context } : {}),
377
+ // Same rule again: a gate that was never armed and never skipped adds no
378
+ // key at all.
379
+ ...(gateForPage(gate) ? { gate: gateForPage(gate) } : {}),
380
+ // THE PATHS A COMMITTING VERDICT HERE MUST COMMIT, carried on the payload
381
+ // so the skill that routes on the verdict never recomputes them. It matters
382
+ // that they travel with the page rather than being asked for again: a
383
+ // checkout is shared, so the set of this spec's uncommitted documents can
384
+ // differ between the render and the verdict — and the reader's conclusion is
385
+ // about what the page showed them.
386
+ //
387
+ // Absent for every other mode, which is what keeps their payloads identical.
388
+ ...(only ? { docs: { paths: only } } : {}),
389
+ // WHERE ELSE THIS REVIEW CAN BE REACHED, and whether it is also running.
390
+ //
391
+ // The page had no idea the other tiers existed — it is opened AT one URL
392
+ // and knows nothing about the rest — so an enable press needs somewhere to
393
+ // live, and that is this block. It carries only what the reader can act on:
394
+ // the live state, each tier that is OFF with the action that turns it on,
395
+ // and the `network` URL when it is on, which is the one address a reader
396
+ // wants that is not the one they are standing on.
397
+ //
398
+ // ABSENT STAYS ABSENT, exactly as `phases` and `gate` do: a caller that
399
+ // passes neither renders the payload it rendered before this existed.
400
+ ...(surfaces ? { surfaces } : {}),
401
+ // THE DEFAULT ADDS NO KEY, so a caller that did not ask for a button set —
402
+ // and a caller that asked for the default by name — renders the payload it
403
+ // rendered before this existed. Opting in is the only thing that shows.
404
+ ...(buttons && buttons !== DEFAULT_BUTTON_SET ? { buttons } : {}),
405
+ // WHICH ENGINE DREW THIS PAGE. The render is always current — the git reads
406
+ // happen per request — so a page rendered by a stale process looks entirely
407
+ // right: the counts move, `generatedAt` moves, the diff is correct. Only the
408
+ // renderer is old, and nothing on the page said so. This is the line that
409
+ // makes the question answerable by reading the artefact.
410
+ engine: ENGINE_VERSION,
411
+ totals,
412
+ files,
413
+ notes: {
414
+ version: store.version || NOTES_VERSION,
415
+ updatedAt: store.updatedAt || null,
416
+ totals: noteTotals,
417
+ unanchored,
418
+ // Absent stays absent, for the same reason `readNotes` leaves it off: a
419
+ // review that has never reached a verdict must render byte-identically to
420
+ // how it did before any of this existed.
421
+ ...(lastDecision ? { lastDecision } : {}),
422
+ },
423
+ review: null,
424
+ }
425
+ }
426
+
427
+
428
+ /* ==========================================================================
429
+ * Notes — the review round-trip
430
+ *
431
+ * The page is read somewhere else (often a phone), so what you conclude while
432
+ * reading has to travel back as DATA: accepts, comments, and later the agent's
433
+ * resolutions. It arrives as a clipboard blob, is merged into a sidecar beside
434
+ * the page, and is read back at the next render.
435
+ *
436
+ * Everything here is engine-owned and versioned. The blob is UNTRUSTED input —
437
+ * it has been through a clipboard and a chat window — so it is validated
438
+ * wholesale before a byte is written.
439
+ * ========================================================================== */
440
+
441
+ // Read once, from this package — it is this file that draws the page, so its
442
+ // own version is the honest answer to "what rendered this".
443
+ /**
444
+ * Find a spec's folder under any bucket of one checkout, and read its phases.
445
+ *
446
+ * Kept here rather than reaching for `findSpecFolder`, which searches several
447
+ * roots and carries preference rules this does not want: there is exactly one
448
+ * tree to look in — the spec's own worktree — and looking anywhere else would
449
+ * answer about a branch that is not the one being reviewed.
450
+ */
451
+ function findSpecDirIn(worktreePath, folder) {
452
+ for (const bucket of ['in-progress', 'backlog', 'complete', 'cancelled']) {
453
+ const dir = path.join(worktreePath, 'specs', bucket, folder)
454
+ if (fs.existsSync(dir)) return dir
455
+ }
456
+ return null
457
+ }
458
+
459
+ /**
460
+ * What the change is FOR, for the top of the page — composed from the spec's
461
+ * own files, never written.
462
+ *
463
+ * That is the same rule the diff follows: nothing here passes through the
464
+ * model, so the header is free however large the review. The moment it were
465
+ * generated it would start costing tokens and start going stale.
466
+ *
467
+ * `null` when there is nothing to say — and a spec with an unreadable overview
468
+ * is not a broken spec. The page rendered without this yesterday.
469
+ */
470
+ function readContextIn(dir, phases) {
471
+ const overview = dir ? readOverview(dir) : null
472
+ const phase = phases && phases.live ? phases.live : null
473
+ if (!overview && !phase) return null
474
+ return { ...(overview || {}), ...(phase ? { phase } : {}) }
475
+ }
476
+
477
+ const ENGINE_VERSION = (() => {
478
+ try {
479
+ return require('../../package.json').version || null
480
+ } catch {
481
+ return null
482
+ }
483
+ })()
484
+
485
+ const NOTES_VERSION = 1
486
+
487
+ /**
488
+ * The four verdicts a review pass can carry, and what an absent one means.
489
+ *
490
+ * A VERDICT IS CHOSEN BY A PERSON, ONCE, PER REVIEW — it is not derived from
491
+ * how many boxes are ticked. That distinction is the whole point: counting
492
+ * marks stays forbidden (see `applyNotes`, which still gates nothing), and this
493
+ * is the deliberate, single place a review gets to say what it concluded.
494
+ *
495
+ * THE VERDICT NAMES THE ACTION. It was `approve` once, and an approval that
496
+ * only recorded itself is the one thing on a review page that does not describe
497
+ * what happens — a review is the guard in front of an action, so the word is
498
+ * the action: `commit`, `commit-continue`, `commit-start`, `changes`,
499
+ * `discuss`.
500
+ *
501
+ * `commit-start` is the authoring verdict: commit the spec that was just
502
+ * written, then put it in flight. It is its own word rather than a
503
+ * context-dependent reading of `commit-continue` for exactly the reason above —
504
+ * a verdict that means `/spec-start` on one page and `/spec-next` on another
505
+ * names neither, and the outcome log would record the same word for two
506
+ * different actions with no way to tell them apart afterwards.
507
+ *
508
+ * `discuss` is the default because it is the behaviour that existed before any
509
+ * verdict did. So a blob from an older page, or one a reader sent without
510
+ * choosing, keeps doing exactly what it always did.
511
+ *
512
+ * `commit-land` is the `/no-spec` verdict: commit this work, fast-forward the
513
+ * base branch to it, and tear the worktree down. It is its own word for exactly
514
+ * the reason `commit-start` is — reusing plain `commit` would make one verdict
515
+ * mean "commit and stop" on a phase page and "commit, land and destroy the
516
+ * worktree" on a `/no-spec` page, which is the context-dependent reading this
517
+ * vocabulary exists to prevent. The outcome log would then record the same word
518
+ * for two very differently destructive actions.
519
+ *
520
+ * `continue` is the mid-run verdict: *I have read it, carry on*. It names an
521
+ * action, which is what separates it from the `none` verdict that was removed —
522
+ * `none` recorded itself and did nothing, while this one resumes the run. What
523
+ * it does NOT do is commit, so it is deliberately absent from `COMMITTING`
524
+ * below and is therefore structurally incapable of clearing an armed gate: a
525
+ * phase that ended still owes a committing verdict or a recorded skip.
526
+ */
527
+ const VERDICTS = [
528
+ 'commit',
529
+ 'commit-continue',
530
+ 'commit-start',
531
+ 'commit-land',
532
+ 'continue',
533
+ 'changes',
534
+ 'discuss',
535
+ ]
536
+ const DEFAULT_VERDICT = 'discuss'
537
+
538
+ /**
539
+ * Actions the page can send INSTEAD of a verdict.
540
+ *
541
+ * AN ACTION IS NOT A VERDICT, and keeping the two vocabularies apart is the
542
+ * whole point rather than tidiness. A verdict is a conclusion about the work,
543
+ * consumed once by the thing it asked for. An action changes what is *running*
544
+ * — or which tiers are permitted — and then puts the reader back on the same
545
+ * page with the same options, so a run answering one has concluded nothing.
546
+ *
547
+ * THERE IS NO `live-off`, and its absence is the decision. Putting *this* change
548
+ * live is about the diff on screen; handing the whole instance back to `main` is
549
+ * a workspace decision with nothing to do with this review, and a control for it
550
+ * on a review page invites the reader to make it while thinking about something
551
+ * else. The `live:` line names `/spec-live main` instead, which is what a person
552
+ * types.
553
+ *
554
+ * Three consequences follow from that, and all three are structural rather than
555
+ * remembered:
556
+ *
557
+ * - It is absent from `VERDICTS`, so nothing that routes on a verdict can see
558
+ * it.
559
+ * - It is absent from `COMMITTING`, so it is **incapable** of clearing an armed
560
+ * gate: a phase that ended still owes a verdict after the reader has looked
561
+ * at it running. The commit that a `live-on` performs first is the mechanical
562
+ * precondition for `live take` (which refuses a dirty worktree), not the
563
+ * reader's answer.
564
+ * - A blob may carry a verdict or an action, never both. Two answers in one
565
+ * pass would need a rule about which wins, and any such rule would sometimes
566
+ * act on the half the reader did not mean.
567
+ *
568
+ * `allow-network`/`allow-remote` turn a review TIER on, and they exist here
569
+ * because the page can now act — which is the premise
570
+ * `feat-three-review-links` decision 5 rejected a page toggle on, having
571
+ * recorded it as open to reopening. Two things replace the limit it rested on.
572
+ * **Only a reader who can already reach the page can press one**, so widening
573
+ * loopback→network can only be pressed from the machine itself, where the
574
+ * command was available anyway, and the serve token still decides who reaches
575
+ * the page at all. And `allow-remote` **permits publishing without
576
+ * publishing** — the permanent claude.ai page still takes an explicit ask.
577
+ *
578
+ * There is no disable direction, and `tierAction` is where that is enforced:
579
+ * turning `network` off from a page reached over the network kills the page
580
+ * doing the turning.
581
+ */
582
+ const ACTIONS = ['live-on', 'allow-network', 'allow-remote']
583
+
584
+ /**
585
+ * The action a tier that is OFF offers, or null.
586
+ *
587
+ * ENABLE-ONLY, and that is a decision rather than an omission: turning
588
+ * `network` off from a page you reached over the network kills the page doing
589
+ * the turning, and the reader gains nothing they could not get by typing the
590
+ * command. `local` has no setting at all — it is the machine the page is on.
591
+ */
592
+ function tierAction(tier) {
593
+ if (!tier || !tier.off) return null
594
+ if (tier.tier === 'network') return 'allow-network'
595
+ if (tier.tier === 'remote') return 'allow-remote'
596
+ return null
597
+ }
598
+
599
+ /**
600
+ * What the page shows above the verdicts: where this review can be reached, and
601
+ * whether it is also running. Pure.
602
+ *
603
+ * ONLY WHAT THE READER CAN ACT ON. An `on` tier the reader is already standing
604
+ * on is not information — they are reading it — so the only tier URL carried is
605
+ * `network`'s, which is the one address someone on a laptop wants for their
606
+ * phone. Everything else here is a state plus the one press that changes it.
607
+ *
608
+ * `null` RATHER THAN AN EMPTY BLOCK when there is nothing to say: an
609
+ * `unavailable` live state and no stack means a caller that never asked, and
610
+ * the payload must then be byte-identical to what it was before this key
611
+ * existed.
612
+ */
613
+ function surfacesFor({ live, tiers, buttons = null }) {
614
+ // A MID-PHASE PAGE CANNOT PUT ANYTHING LIVE, so it is not offered the press.
615
+ //
616
+ // `live take` refuses a dirty worktree, so the action commits the phase
617
+ // first — and `/spec-diff` §2b is explicit that half a phase is not
618
+ // committed to look at it running: that splits one phase across two commits
619
+ // and leaves a mess nobody asked for. So mid-run the press could only ever be
620
+ // declined, and a control that always refuses teaches the reader that the
621
+ // buttons on this page are decorative — the same failure
622
+ // `.claude/rules/spec-reports.md` records for the offer nothing was watching.
623
+ //
624
+ // The row stays, with the command in place of the button: the reader still
625
+ // learns the surface exists and what to type, which is exactly the trade the
626
+ // strip already makes on a `file://` page.
627
+ //
628
+ // ONLY `live-on`. `allow-network` and `allow-remote` write a config file
629
+ // and commit nothing, so they are as valid mid-phase as at the end of one.
630
+ const canCommit = buttons !== 'midrun'
631
+ const rows = []
632
+
633
+ // `unavailable` contributes nothing — the cannot-tell state, routed to
634
+ // silence exactly as `liveStateLine` routes it (`env/live.js`).
635
+ if (live && live.state && live.state !== 'unavailable') {
636
+ rows.push({
637
+ kind: 'live',
638
+ state: live.state,
639
+ url: live.url || null,
640
+ reason: live.reason || null,
641
+ // ONE DIRECTION ONLY. `off` offers the press; `on` and `held` offer none —
642
+ // `on` because handing the instance back to `main` is not this review's
643
+ // business, and `held` because the way out is another spec's to take and a
644
+ // button here would either park someone else's work or do nothing.
645
+ action: live.state === 'off' && canCommit ? 'live-on' : null,
646
+ // The command for the direction the page does not offer, so the reader is
647
+ // never left knowing what they want and not what to type. Mid-run that is
648
+ // `/spec-live` itself, since the press is withheld above.
649
+ command:
650
+ live.state === 'on' ? '/spec-live main' : live.state === 'off' && !canCommit ? '/spec-live' : null,
651
+ })
652
+ }
653
+
654
+ for (const t of tiers || []) {
655
+ const action = tierAction(t)
656
+ // ONLY WHAT THE READER CAN ACT ON. An `on` tier contributes nothing: the
657
+ // reader is standing on one of them, and the terminal render is where they
658
+ // got the link — printing it again here would be two places naming one page,
659
+ // which is the split `.claude/rules/spec-reports.md` records as a failure.
660
+ if (!action) continue
661
+ rows.push({
662
+ kind: 'tier',
663
+ tier: t.tier,
664
+ state: 'off',
665
+ url: null,
666
+ // SAID BEFORE THE PRESS, not after it. Unlike the live action, which
667
+ // touches no tracked file, `allow` edits `specs/.core/env.config.json` in
668
+ // the primary checkout — changing behaviour for everyone who pulls and
669
+ // leaving that tree dirty. A setting change of that reach must not land
670
+ // because someone tapped a button labelled only `Allow remote`.
671
+ // `remote` is per spec now (bug-remote-review-toggle): a gitignored
672
+ // sidecar, so the base branch is untouched and no other spec may publish.
673
+ note:
674
+ t.note ||
675
+ (t.tier === 'remote'
676
+ ? 'this spec only — nothing committed'
677
+ : 'writes env.config.json — committed, and shared'),
678
+ // The same thing from a terminal, for the reader who is nowhere near the
679
+ // page. Only `remote` has a command of its own: `network` is ON by
680
+ // default, so turning it off is a rare deliberate act and a slash command
681
+ // per tier is clutter for the one nobody touches.
682
+ command: t.tier === 'remote' ? '/spec-remote-review' : null,
683
+ action,
684
+ })
685
+ }
686
+
687
+ return rows.length ? rows : null
688
+ }
689
+
690
+ /**
691
+ * The three review tiers, in a FIXED ORDER, each either a URL or the reason it
692
+ * is not one. Pure: it takes what the caller already resolved.
693
+ *
694
+ * Fixed order matters more than it looks. A reader who has learnt which line
695
+ * their phone opens should not have to re-read the labels every render, and a
696
+ * stack that reordered itself by availability would make them.
697
+ *
698
+ * WHY A STACK AT ALL, when the rule said exactly one link: the engine cannot
699
+ * know where the reader is sitting, and every attempt to guess failed the
700
+ * moment they moved. `local` and `network` are two doors into ONE room — the
701
+ * page POSTs to `location.pathname`, so both reach the same server and the same
702
+ * pending store, and one wait covers both. `remote` is a second store, which is
703
+ * why it carries the line saying so.
704
+ */
705
+ function reviewTierStack({ served, fileUrl, publishedUrl, config }) {
706
+ const tiers = []
707
+ const loopbackUrl = served && served.loopbackUrl ? served.loopbackUrl : null
708
+
709
+ tiers.push(
710
+ loopbackUrl
711
+ ? { tier: 'local', url: loopbackUrl }
712
+ : { tier: 'local', url: fileUrl, note: 'a file:// page cannot send a verdict' },
713
+ )
714
+
715
+ if (!config.review.allowNetwork) {
716
+ tiers.push({ tier: 'network', off: true, enable: 'skitterspec spec-env review allow network' })
717
+ } else if (served && !served.loopback && served.url) {
718
+ tiers.push({ tier: 'network', url: served.url, alternates: served.alternates || [] })
719
+ } else {
720
+ // Permitted, but there is no address to offer — a machine with no network,
721
+ // or a server that could not take one. Not a problem to report as an error.
722
+ tiers.push({ tier: 'network', unavailable: true, note: 'no network address on this machine' })
723
+ }
724
+
725
+ if (!config.review.allowRemote) {
726
+ // THE COMMAND A PERSON TYPES. `/spec-remote-review` toggles, so the same
727
+ // line works whichever way the tier currently is — and nobody has to
728
+ // reconstruct `spec-env review allow remote` from a render they are reading
729
+ // on a phone.
730
+ tiers.push({ tier: 'remote', off: true, enable: '/spec-remote-review' })
731
+ } else if (publishedUrl) {
732
+ tiers.push({ tier: 'remote', url: publishedUrl, note: 'a verdict here needs /spec-reviewed' })
733
+ } else {
734
+ tiers.push({ tier: 'remote', unavailable: true, note: 'publishing is an ask — nothing published yet' })
735
+ }
736
+
737
+ return tiers
738
+ }
739
+
740
+ // One tier as a line, padded so the labels form a column the eye can run down.
741
+ function reviewTierLine(t) {
742
+ const label = ` ${t.tier}:`.padEnd(11)
743
+ if (t.url) return `${label}${t.url}${t.note ? ` (${t.note})` : ''}`
744
+ if (t.off) return `${label}off — turn on with: ${t.enable}`
745
+ return `${label}— (${t.note})`
746
+ }
747
+
748
+
749
+ // The verdicts that COMMIT, and are therefore blocked by an open comment. One
750
+ // list, so a fifth verdict cannot become a way around the single refusal this
751
+ // engine makes — adding a committing verdict means adding it here, and the
752
+ // block follows for free.
753
+ //
754
+ // `continue` IS DELIBERATELY NOT HERE, and that omission is the whole of
755
+ // decision 2: waiting is what any offer does, while arming asserts an
756
+ // obligation that outlives the turn. A mid-run reader saying "carry on" has
757
+ // answered the offer in front of them and nothing else, so the gate a finished
758
+ // phase armed must survive it untouched.
759
+ const COMMITTING = ['commit', 'commit-continue', 'commit-start', 'commit-land']
760
+
761
+ /**
762
+ * Which set of buttons a rendered page shows.
763
+ *
764
+ * DECLARED BY THE CALLER, NEVER DERIVED FROM THE GATE. Deriving it — mid-run
765
+ * iff the gate is unarmed — is tidier and wrong: a project running
766
+ * `review.required: false` never arms at all, so every one of its pages would
767
+ * lose the committing buttons and the reader could never commit from the page.
768
+ * The caller knows whether the work it just rendered is finished; the gate only
769
+ * knows whether this project opted into gating.
770
+ *
771
+ * `committing` is the default, so a caller that says nothing keeps today's page
772
+ * exactly — the key is left off the payload entirely rather than written out as
773
+ * the default, so an unchanged caller renders an unchanged page.
774
+ *
775
+ * `authoring` is the set for a spec that has just been written: its committing
776
+ * pair is `commit-start` and `commit` — put it in flight now, or keep it for
777
+ * later. `commit-continue` is absent because there is no phase in flight to
778
+ * continue, and `continue` is absent because the run has nothing left to resume.
779
+ *
780
+ * `refresh` is the set for a spec that was re-validated rather than written:
781
+ * `commit`, `changes`, `discuss`, and NO start verdict. A refreshed spec may
782
+ * already be in progress, so offering to put it in flight would be wrong for
783
+ * half this set's inputs — and wrong in the expensive direction, since it would
784
+ * offer to provision a worktree for a spec that already has one.
785
+ *
786
+ * `nospec` is the set for work with no spec document: its committing pair is
787
+ * `commit-land` and `commit` — finish it now, or commit and leave the branch
788
+ * standing. `commit-continue` is absent because there is no next phase, and
789
+ * `commit-start` because there is nothing to put in flight.
790
+ *
791
+ * REUSING `refresh` HERE WAS THE OBVIOUS MOVE AND IT IS WRONG. The three
792
+ * remaining buttons are identical, so the temptation is to let `/no-spec` take
793
+ * that set and treat plain `commit` as "commit, land, tear down". That makes one
794
+ * verdict word mean two different things depending on which skill rendered the
795
+ * page — precisely what having separate words for `commit-start` and
796
+ * `commit-continue` exists to avoid, and worse here, because one of the two
797
+ * readings destroys a worktree.
798
+ *
799
+ * `fix` is the set for a SINGLE-PASS fix — a bug or hotfix spec whose whole
800
+ * repair is one `## Fix` list and no phase files. It is `committing` minus
801
+ * `commit-continue`, because there is no next phase for `/spec-next` to build:
802
+ * the page used to offer that verb anyway, the press committed correctly, and
803
+ * `/spec-next` then refused with nothing to do.
804
+ *
805
+ * THE PAGE ALREADY HAS A GUARD FOR THIS AND IT CANNOT SEE THESE SPECS. It dims
806
+ * the button on `data.phases.hasNextPhase === false`, and `readPhases` returns
807
+ * `null` for a folder with no phase files — deliberately, because that is also
808
+ * a legacy layout whose phases live inline in the overview. So the caller says
809
+ * it instead, which is what the `buttons` parameter is for.
810
+ *
811
+ * REUSING `refresh` WAS THE OBVIOUS MOVE HERE TOO, and is wrong for the reason
812
+ * above one line up rather than the one above the paragraph: the three buttons
813
+ * are identical, but `refresh` means a re-validated spec DOCUMENT and this is a
814
+ * render of code. The word is stored in the render record and read back by the
815
+ * serve daemon, so one name covering two renders would be ambiguous exactly
816
+ * where it is used to decide something.
817
+ */
818
+ const BUTTON_SETS = ['committing', 'midrun', 'authoring', 'refresh', 'nospec', 'fix']
819
+ const DEFAULT_BUTTON_SET = 'committing'
820
+
821
+ /**
822
+ * What an older sidecar's `approve` means now. Pure.
823
+ *
824
+ * TOLERANCE, NOT MIGRATION. These files are gitignored, so there is no fleet to
825
+ * migrate and no script anyone would remember to run — the rename is absorbed
826
+ * at the point of reading, where it cannot be skipped. Anything else is passed
827
+ * through untouched, including a value this engine does not know: `readVerdict`
828
+ * answers what a stored word means, and refusing an unknown one is
829
+ * `validateNotesBlob`'s job, not this function's.
830
+ */
831
+ function readVerdict(stored) {
832
+ return stored === 'approve' ? 'commit' : stored
833
+ }
834
+
835
+ // A file that is gone has no content to hash, and an accept still has to mean
836
+ // something about it. A sentinel is comparable and obviously not a blob sha.
837
+ const DELETED_HASH = '(deleted)'
838
+
839
+ // `hash-object` takes every path on one command line, so chunk it rather than
840
+ // discovering ARG_MAX on the one spec that touched 400 files.
841
+ const HASH_BATCH = 100
842
+
843
+ /**
844
+ * The git blob hash of each file's CURRENT working-tree content.
845
+ *
846
+ * THE IDENTITY AN ACCEPT IS KEYED TO, and deliberately not the patch. A patch
847
+ * is a function of the ref as much as the file: commit the phase and `HEAD`
848
+ * moves, so every patch changes while no file did — and every accept would
849
+ * lapse at the exact moment the work was finished. `--branch` does the same in
850
+ * reverse. Content keying survives both, because it describes the thing you
851
+ * actually read.
852
+ *
853
+ * A hash that cannot be computed is `null`, never a guess: it compares unequal
854
+ * to every recorded accept, so the cannot-tell case renders as lapsed rather
855
+ * than as approval (see `.claude/rules/negative-checks.md`).
856
+ */
857
+ function fileHashes(git, files) {
858
+ const out = new Map()
859
+ const live = []
860
+ for (const f of files) {
861
+ if (f.status === 'deleted') out.set(f.path, DELETED_HASH)
862
+ else live.push(f.path)
863
+ }
864
+ for (let i = 0; i < live.length; i += HASH_BATCH) {
865
+ const batch = live.slice(i, i + HASH_BATCH)
866
+ const got = lines(git(['hash-object', '--', ...batch]))
867
+ if (got.length === batch.length) {
868
+ batch.forEach((p, n) => out.set(p, got[n]))
869
+ continue
870
+ }
871
+ // One unhashable path fails the whole batch, so fall back per file rather
872
+ // than losing every other file's hash with it.
873
+ for (const p of batch) out.set(p, lines(git(['hash-object', '--', p]))[0] || null)
874
+ }
875
+ return out
876
+ }
877
+
878
+ // The sidecar sits beside the page and the `.url` file, under gitignored
879
+ // `.spec-env/` — a review leaves no trace in the branch it reviews.
880
+ function reviewNotesPath(outPath) {
881
+ return outPath.replace(/\.html$/, '') + '.notes.json'
882
+ }
883
+
884
+ function emptyNotes(specFolder) {
885
+ return { version: NOTES_VERSION, spec: specFolder, updatedAt: null, files: {}, comments: [] }
886
+ }
887
+
888
+ /**
889
+ * Read the sidecar. Never throws, and reports `corrupt` rather than hiding it.
890
+ *
891
+ * A file we cannot parse is the third state: it is not "no notes". Rendering
892
+ * carries on without them (the page is a convenience), but a MERGE must refuse,
893
+ * because writing over an unreadable file is how someone's whole review pass
894
+ * disappears. The caller decides which of those it is.
895
+ */
896
+ function readNotes(outPath, specFolder) {
897
+ let raw
898
+ try {
899
+ raw = fs.readFileSync(reviewNotesPath(outPath), 'utf8')
900
+ } catch {
901
+ // Absent is the ordinary state — most reviews never write one.
902
+ return { notes: emptyNotes(specFolder), corrupt: false, present: false }
903
+ }
904
+ try {
905
+ const parsed = JSON.parse(raw)
906
+ return {
907
+ notes: {
908
+ version: parsed.version,
909
+ spec: parsed.spec || specFolder,
910
+ updatedAt: parsed.updatedAt || null,
911
+ files: parsed.files && typeof parsed.files === 'object' ? parsed.files : {},
912
+ comments: Array.isArray(parsed.comments) ? parsed.comments : [],
913
+ // Absent stays absent. A sidecar written before verdicts existed, or by
914
+ // a review that never reached one, must not gain an empty `decisions`
915
+ // key just by being read — the round-trip has to be byte-stable for
916
+ // everyone who is not using this.
917
+ //
918
+ // A logged `approve` is read as `commit` HERE, at the read, rather than
919
+ // by a migration nobody would run: these files are gitignored, so the
920
+ // rename has to be absorbed where it cannot be skipped.
921
+ ...(Array.isArray(parsed.decisions)
922
+ ? { decisions: parsed.decisions.map((d) => ({ ...d, verdict: readVerdict(d.verdict) })) }
923
+ : {}),
924
+ },
925
+ corrupt: false,
926
+ present: true,
927
+ }
928
+ } catch {
929
+ return { notes: emptyNotes(specFolder), corrupt: true, present: true }
930
+ }
931
+ }
932
+
933
+ function writeNotes(outPath, notes) {
934
+ const p = reviewNotesPath(outPath)
935
+ fs.mkdirSync(path.dirname(p), { recursive: true })
936
+ fs.writeFileSync(p, JSON.stringify(notes, null, 2) + '\n')
937
+ return p
938
+ }
939
+
940
+ /* ==========================================================================
941
+ * Pending passes — a review that arrived over the wire
942
+ *
943
+ * A page that is SERVED can hand its pass straight back rather than going
944
+ * through a clipboard and a chat window. That is a write path reachable by
945
+ * anyone who can reach the page, so what it writes is deliberately not the
946
+ * sidecar: a POST lands here, in a HOLDING AREA, and nothing reaches the
947
+ * review until a person reads its code out. The code is what makes a stranger's
948
+ * pass inert and says WHICH pass when several are in flight.
949
+ *
950
+ * On disk rather than in the server's memory, and that is a constraint from
951
+ * `feat-review-serve-version` rather than a preference: that feature restarts a
952
+ * stale server automatically, and a restart that dropped the pass you just sent
953
+ * would be a new way to lose work. It also means `--claim` needs no running
954
+ * server at all.
955
+ * ========================================================================== */
956
+
957
+ const PENDING_VERSION = 1
958
+
959
+ // Beside the page and the notes sidecar, under gitignored `.spec-env/`. The
960
+ // suffix is named once so the cross-spec scan recognises a store by the same
961
+ // spelling that writes one.
962
+ const PENDING_SUFFIX = '.pending.json'
963
+
964
+ function reviewPendingPath(outPath) {
965
+ return outPath.replace(/\.html$/, '') + PENDING_SUFFIX
966
+ }
967
+
968
+ function emptyPending(specFolder) {
969
+ return { version: PENDING_VERSION, spec: specFolder, passes: [] }
970
+ }
971
+
972
+ /**
973
+ * Read the holding area. Never throws; reports `corrupt` rather than hiding it.
974
+ *
975
+ * Same three states as `readNotes`, for the same reason: a file we cannot parse
976
+ * is not "no pending passes", and claiming against it must refuse rather than
977
+ * silently find nothing.
978
+ */
979
+ function readPending(outPath, specFolder) {
980
+ let raw
981
+ try {
982
+ raw = fs.readFileSync(reviewPendingPath(outPath), 'utf8')
983
+ } catch {
984
+ return { pending: emptyPending(specFolder), corrupt: false, present: false }
985
+ }
986
+ try {
987
+ const parsed = JSON.parse(raw)
988
+ return {
989
+ pending: {
990
+ version: parsed.version || PENDING_VERSION,
991
+ spec: parsed.spec || specFolder,
992
+ passes: Array.isArray(parsed.passes) ? parsed.passes : [],
993
+ },
994
+ corrupt: false,
995
+ present: true,
996
+ }
997
+ } catch {
998
+ return { pending: emptyPending(specFolder), corrupt: true, present: true }
999
+ }
1000
+ }
1001
+
1002
+ function writePending(outPath, pending) {
1003
+ const p = reviewPendingPath(outPath)
1004
+ fs.mkdirSync(path.dirname(p), { recursive: true })
1005
+ fs.writeFileSync(p, JSON.stringify(pending, null, 2) + '\n')
1006
+ return p
1007
+ }
1008
+
1009
+ const PENDING_CODE_LENGTH = 6
1010
+
1011
+ /**
1012
+ * A code for one pending pass. Unique among those currently pending.
1013
+ *
1014
+ * NOT A SECRET, and it does not need to be: it is printed on the page by
1015
+ * design, and what it authorises is a person reading it out. What it must not
1016
+ * do is COLLIDE — two pending passes sharing a code is how the wrong one gets
1017
+ * applied — so it is drawn against the set rather than drawn and hoped for.
1018
+ *
1019
+ * WHAT WOULD FOOL THIS: nothing about uniqueness, but note it is drawn from the
1020
+ * passes it is GIVEN. A caller that mints against a stale read could still
1021
+ * collide, which is why the mint and the write happen together in `addPending`.
1022
+ */
1023
+ function mintPendingCode(pending, random = () => crypto.randomInt(0, 10 ** PENDING_CODE_LENGTH)) {
1024
+ const taken = new Set((pending.passes || []).map((p) => p.code))
1025
+ // Bounded rather than `while (true)`: with a million codes and a handful
1026
+ // pending this cannot realistically spin, and a bound means a bug here fails
1027
+ // loudly instead of hanging the server.
1028
+ for (let i = 0; i < 1000; i++) {
1029
+ const code = String(random()).padStart(PENDING_CODE_LENGTH, '0')
1030
+ if (!taken.has(code)) return code
1031
+ }
1032
+ throw new Error('could not mint a unique pending code')
1033
+ }
1034
+
1035
+ /**
1036
+ * Add a pass to the holding area, returning the new store and its code. Pure.
1037
+ *
1038
+ * SUPERSEDES WITHIN A RENDER. A second pass sent from the same page render
1039
+ * replaces the first unclaimed one from that render, so the code on the screen
1040
+ * is always the pass on the screen — press Approve, change your mind, press
1041
+ * Request changes, and there is one pass waiting, not two. Passes from a
1042
+ * DIFFERENT render stand alongside it: those are two people, or two sittings,
1043
+ * and neither supersedes the other.
1044
+ */
1045
+ function addPending(pending, { blob, at, render }, mint = mintPendingCode) {
1046
+ const kept = (pending.passes || []).filter((p) => p.render !== render)
1047
+ const next = { ...pending, passes: kept }
1048
+ const code = mint(next)
1049
+ next.passes = [...kept, { code, at, render, blob }]
1050
+ return { pending: next, code }
1051
+ }
1052
+
1053
+ /**
1054
+ * What became of ONE pass, by its code. Reads; writes nothing, refuses nothing.
1055
+ *
1056
+ * The served page POSTs, is handed six digits, and then has to end on one of
1057
+ * two sentences — Claude picked this up, or here is the command that picks it
1058
+ * up. It cannot know which at send time, so it asks, and this answers.
1059
+ *
1060
+ * Three states, and the third is the point:
1061
+ *
1062
+ * - `waiting` — the code IS in the holding area. Nobody has claimed it.
1063
+ * - `claimed` — the decision log NAMES the code. Somebody did.
1064
+ * - `unknown` — neither could be established.
1065
+ *
1066
+ * BOTH ANSWERS REST ON SOMETHING PRESENT, which is what stops this being the
1067
+ * fourth row in the table at the top of `.claude/rules/negative-checks.md`. The
1068
+ * tempting cheap version reads `claimed` off the code being ABSENT from the
1069
+ * holding area — and absence there has four causes, of which exactly one is a
1070
+ * claim: `--drop` removes a pass and writes no decision, a store written under
1071
+ * a different `--out` is a store this never opens, and a mistyped folder finds
1072
+ * an empty one. Three of those four would report a pass as picked up while it
1073
+ * sits in a file nobody is reading, which is this spec's own bug told back to
1074
+ * the reader with confidence.
1075
+ *
1076
+ * So `unknown` answers everything that is not a positive signal, and the caller
1077
+ * turns it back into the command. That direction is deliberate: a command
1078
+ * nobody needed to run costs a glance, and a pass nobody claims costs the
1079
+ * review.
1080
+ *
1081
+ * A code that is not six digits never reaches disk. It cannot be one this
1082
+ * engine minted, so there is nothing to look up, and answering it from the
1083
+ * filesystem would make a reachable endpoint into a path-shaped probe.
1084
+ */
1085
+ function passState(outPath, specFolder, code) {
1086
+ const wanted = typeof code === 'string' ? code.trim() : ''
1087
+ if (!new RegExp(`^\\d{${PENDING_CODE_LENGTH}}$`).test(wanted)) return { state: 'unknown' }
1088
+
1089
+ const held = readPending(outPath, specFolder)
1090
+ // A store we could not parse is not an empty one. It holds somebody's passes
1091
+ // and we simply cannot see them, so neither answer is available.
1092
+ if (held.corrupt) return { state: 'unknown' }
1093
+ if ((held.pending.passes || []).some((p) => p && p.code === wanted)) return { state: 'waiting' }
1094
+
1095
+ const stored = readNotes(outPath, specFolder)
1096
+ if (stored.corrupt) return { state: 'unknown' }
1097
+ const decisions = Array.isArray(stored.notes.decisions) ? stored.notes.decisions : []
1098
+ if (decisions.some((d) => d && d.code === wanted)) return { state: 'claimed' }
1099
+
1100
+ // Gone from the holding area and unnamed in the log. It may have been
1101
+ // dropped, it may predate this field, the store may not be the one the page
1102
+ // was served from. Say so.
1103
+ return { state: 'unknown' }
1104
+ }
1105
+
1106
+ /**
1107
+ * What is waiting, as the render should describe it. Pure.
1108
+ *
1109
+ * THE BLOB IS DELIBERATELY NOT HERE. A decision about a waiting pass needs its
1110
+ * code, its verdict and its age; the notes are what a CLAIM is for. Returning
1111
+ * them would put an unclaimed stranger's text into the context of whoever is
1112
+ * reading the render — which is the one thing the holding area exists to defer.
1113
+ *
1114
+ * Oldest first, and stable: two renders in a row must name the passes in the
1115
+ * same order, or a reader cannot trust the list they just read against the one
1116
+ * they are about to be offered. Ties break on the code, which is unique among
1117
+ * pending, so the order is total rather than merely usually-stable.
1118
+ */
1119
+ function describePending(pending) {
1120
+ return (pending.passes || [])
1121
+ .map((p) => ({
1122
+ code: p.code,
1123
+ // Read through the rename too: a pass POSTed by an older page says
1124
+ // `approve`, and the operator must be offered the word that describes
1125
+ // what claiming it would do.
1126
+ verdict: readVerdict((p.blob && p.blob.verdict) || null) || null,
1127
+ // AN ACTION IS REPORTED AS AN ACTION. A pass carrying one has no verdict,
1128
+ // and listing it as `no verdict` would describe the instruction it does
1129
+ // carry as an absence — which is the reading that makes a reader claim it
1130
+ // expecting a conclusion.
1131
+ action: (p.blob && p.blob.action) || null,
1132
+ at: p.at || null,
1133
+ }))
1134
+ .sort((a, b) => String(a.at).localeCompare(String(b.at)) || a.code.localeCompare(b.code))
1135
+ }
1136
+
1137
+ /**
1138
+ * How long ago, in words a person reads at a glance. Pure.
1139
+ *
1140
+ * AGE IS THE TELL. A pass sent three days ago is not a review anyone in this
1141
+ * conversation just pressed, and that is exactly how a stranger's pass gives
1142
+ * itself away — so it is reported beside the code rather than left in a
1143
+ * timestamp nobody parses.
1144
+ *
1145
+ * Unknown stays unknown: a pass with no `at` says so rather than being rendered
1146
+ * as "just now", which is the reading that would make it look like yours.
1147
+ */
1148
+ function pendingAge(at, now) {
1149
+ const then = Date.parse(at)
1150
+ const ms = Date.parse(now) - then
1151
+ if (!Number.isFinite(then) || !Number.isFinite(ms) || ms < 0) return 'unknown age'
1152
+ const mins = Math.floor(ms / 60000)
1153
+ if (mins < 1) return 'just now'
1154
+ if (mins < 60) return `${mins} min ago`
1155
+ const hours = Math.floor(mins / 60)
1156
+ if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`
1157
+ const days = Math.floor(hours / 24)
1158
+ return `${days} day${days === 1 ? '' : 's'} ago`
1159
+ }
1160
+
1161
+ /**
1162
+ * Take a pass out of the holding area by its code. Pure.
1163
+ *
1164
+ * THE ONE REFUSAL HERE, and it never falls back. A code that matches nothing
1165
+ * returns no pass — not "the only one", not "the most recent". Both of those
1166
+ * are the same mistake: they would let a pass nobody read out reach the review,
1167
+ * which is the entire thing the code exists to prevent
1168
+ * (`.claude/rules/negative-checks.md` rule 4 — the unknown case does nothing).
1169
+ *
1170
+ * Claiming CONSUMES: the entry is gone from the returned store, so the same
1171
+ * code cannot be claimed twice and an old code cannot resurrect an old pass.
1172
+ */
1173
+ function claimPending(pending, code) {
1174
+ const passes = pending.passes || []
1175
+ const at = passes.findIndex((p) => p.code === code)
1176
+ if (at === -1) return { pass: null, pending, count: passes.length }
1177
+ const pass = passes[at]
1178
+ const rest = passes.slice(0, at).concat(passes.slice(at + 1))
1179
+ return { pass, pending: { ...pending, passes: rest }, count: rest.length }
1180
+ }
1181
+
1182
+ /**
1183
+ * Which pass arrived inside a WAIT WINDOW. Pure — it selects, never claims.
1184
+ *
1185
+ * THE WINDOW IS THE SCOPE, and it is what replaces "a person typed the
1186
+ * command" when a watch wakes the session instead. A skill that asked to be
1187
+ * woken when the store changed knows one thing nothing else does: the moment
1188
+ * it started waiting. A pass sent after that, while it was waiting, is the
1189
+ * pass it was waiting for — and a pass that was already there is a stranger's,
1190
+ * or an older sitting's, and must not be swept up by a wait that was not about
1191
+ * it.
1192
+ *
1193
+ * Three answers, and only one of them acts:
1194
+ *
1195
+ * - exactly one in the window → that is the pass, named by its code.
1196
+ * - none → nothing to claim. Ordinary: the watch can fire on a write that was
1197
+ * not a pass at all.
1198
+ * - more than one → REFUSE and name the count, never pick. Two people, or two
1199
+ * sittings, landed in one window; choosing between them is exactly the guess
1200
+ * `claimPending` refuses to make, and the operator has the codes.
1201
+ *
1202
+ * A pass whose `at` cannot be parsed is never in the window. That is the
1203
+ * cannot-tell case routed to inaction (`.claude/rules/negative-checks.md` rule
1204
+ * 4): including it would auto-claim a pass whose age — the one tell a stranger
1205
+ * has — could not be established.
1206
+ */
1207
+ function passesSince(pending, since) {
1208
+ const from = Date.parse(since)
1209
+ if (!Number.isFinite(from)) return { codes: [], usable: false }
1210
+ const codes = (pending.passes || [])
1211
+ .filter((p) => {
1212
+ const at = Date.parse(p.at)
1213
+ return Number.isFinite(at) && at >= from
1214
+ })
1215
+ .map((p) => p.code)
1216
+ return { codes, usable: true }
1217
+ }
1218
+
1219
+ /* ==========================================================================
1220
+ * The wait — one implementation, because improvised ones cannot be tested
1221
+ *
1222
+ * The skills used to say "watch the pending store and end your turn" and stop
1223
+ * there, so every run wrote its own watcher in shell. Three failed in two days,
1224
+ * each reaching the operator as "I pressed the button and nothing happened" —
1225
+ * and the worst of them was `until [ -f "$P" ] && [ "$x" \\> "$y" ]`, valid bash
1226
+ * and a syntax error in zsh, which spun for five minutes writing to a stderr
1227
+ * nobody reads.
1228
+ *
1229
+ * What made that expensive was not the typo. It is that SILENCE WAS THE SUCCESS
1230
+ * SIGNAL: from outside, a watcher that can never fire and one patiently working
1231
+ * look exactly alike. So the comparison lives here, in one place, where a test
1232
+ * can hand it a store that gains a pass mid-flight and watch it return.
1233
+ * ========================================================================== */
1234
+
1235
+ const WAIT_POLL_MS = 400
1236
+
1237
+ // How often a running wait says it is still running. Five minutes, against a
1238
+ // 400ms poll: this is not part of the poll at all, it is proof of life for
1239
+ // whatever is supervising the process.
1240
+ const WAIT_HEARTBEAT_MS = 5 * 60 * 1000
1241
+
1242
+ /**
1243
+ * Block until exactly one pass arrives inside a window. Impure only in that it
1244
+ * reads the store and sleeps; it writes nothing and claims nothing.
1245
+ *
1246
+ * Four outcomes, and they are distinct because the caller acts differently on
1247
+ * each:
1248
+ *
1249
+ * - `{ state: 'arrived', code }` — one pass, inside the window. Claim it.
1250
+ * - `{ state: 'ambiguous', count }` — more than one. NAMES NO CODE, because
1251
+ * choosing between two is exactly the guess `claimPending` refuses to make.
1252
+ * - `{ state: 'timeout' }` — only when a timeout was asked for.
1253
+ * - `{ state: 'unusable' }` — the window could not be parsed, so there is
1254
+ * nothing to wait inside. Returns AT ONCE rather than blocking forever on a
1255
+ * comparison that can never be satisfied — which is the failure this whole
1256
+ * function exists to stop.
1257
+ *
1258
+ * NO TIMEOUT UNLESS ASKED. `timeoutMs` omitted means wait as long as the
1259
+ * process lives, and that is the default the skills use: any fixed number is a
1260
+ * guess about how long someone reads, and a reader who walks away from a diff
1261
+ * is the normal case rather than the edge one. An hour was picked once and a
1262
+ * lunch break beat it.
1263
+ *
1264
+ * IT SAYS IT IS STILL RUNNING, on `onHeartbeat`. A backgrounded wait produced
1265
+ * no output between starting and finding a pass, and over a long idle it was
1266
+ * killed — three sessions lost their wait over one lunch break. Whether the
1267
+ * trigger is process silence or session idleness was never established, so the
1268
+ * cheap half is done here: the process stops being silent. The other half is
1269
+ * the caller re-arming, which covers it either way.
1270
+ *
1271
+ * The cadence is INDEPENDENT of the poll. Polling stays at 400ms because that
1272
+ * is how fast a press should be noticed; the heartbeat is minutes apart because
1273
+ * it is addressed to a supervisor, not to a reader.
1274
+ *
1275
+ * WHAT WOULD FOOL A LOOSER VERSION: treating an absent, empty, or older-only
1276
+ * store as a reason to return. None of those is a pass — they are the ordinary
1277
+ * state of a review nobody has answered yet — and a wait that ended on one
1278
+ * would report "no pass" as an outcome (`.claude/rules/negative-checks.md`
1279
+ * rule 3 has a test for each).
1280
+ */
1281
+ async function waitForPass(
1282
+ readStore,
1283
+ since,
1284
+ {
1285
+ timeoutMs = null,
1286
+ pollMs = WAIT_POLL_MS,
1287
+ sleep,
1288
+ onHeartbeat = null,
1289
+ heartbeatMs = WAIT_HEARTBEAT_MS,
1290
+ now = () => Date.now(),
1291
+ } = {},
1292
+ ) {
1293
+ if (!Number.isFinite(Date.parse(since))) return { state: 'unusable' }
1294
+ const nap = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
1295
+ const startedAt = now()
1296
+ const deadline = Number.isFinite(timeoutMs) && timeoutMs !== null ? startedAt + timeoutMs : null
1297
+ // Counted from the start rather than scheduled, so a slow poll cannot make
1298
+ // the heartbeat drift and a fast one cannot make it fire twice.
1299
+ let beats = 0
1300
+
1301
+ for (;;) {
1302
+ // A store that will not parse is not an empty one, and it is not a pass
1303
+ // either — keep waiting rather than reading someone's unreadable passes as
1304
+ // an answer. `readPending` already reports `corrupt` rather than throwing.
1305
+ const read = readStore()
1306
+ if (read && !read.corrupt) {
1307
+ const window = passesSince(read.pending, since)
1308
+ if (window.usable) {
1309
+ if (window.codes.length === 1) return { state: 'arrived', code: window.codes[0] }
1310
+ if (window.codes.length > 1) return { state: 'ambiguous', count: window.codes.length }
1311
+ }
1312
+ }
1313
+ if (deadline !== null && now() >= deadline) return { state: 'timeout' }
1314
+
1315
+ // SAID ON THE WAY ROUND, never on the way out. A line printed when the wait
1316
+ // ends would only ever appear for the waits that already worked, which is
1317
+ // the half that never needed proof.
1318
+ if (onHeartbeat && heartbeatMs > 0) {
1319
+ const elapsed = now() - startedAt
1320
+ const due = Math.floor(elapsed / heartbeatMs)
1321
+ if (due > beats) {
1322
+ beats = due
1323
+ onHeartbeat({ elapsedMs: elapsed, since })
1324
+ }
1325
+ }
1326
+
1327
+ // Never overshoot the deadline by a whole poll interval.
1328
+ await nap(deadline === null ? pollMs : Math.max(0, Math.min(pollMs, deadline - now())))
1329
+ }
1330
+ }
1331
+
1332
+ /**
1333
+ * Every waiting pass, across every spec. Reads; writes, claims and refuses
1334
+ * nothing.
1335
+ *
1336
+ * A wait that never fired, a session cleared, a terminal closed overnight —
1337
+ * none of those is recoverable by any watcher, however good. This is what finds
1338
+ * the pass afterwards, so recovery stops depending on the operator happening to
1339
+ * suspect something.
1340
+ *
1341
+ * IT READS THE SIDECAR DIRECTORY, NEVER THE PROVISIONED LIST, and that is the
1342
+ * load-bearing decision rather than an implementation detail. `specEnvStatus`
1343
+ * walks specs that have a **worktree**; the ten passes that motivated this all
1344
+ * belonged to specs that had been completed and torn down. Scoping the scan to
1345
+ * provisioned specs would therefore make it blind to precisely the case that
1346
+ * produced it. A `<spec>.pending.json` is here because a pass was received —
1347
+ * a positive signal (`.claude/rules/negative-checks.md` rule 1), and one that
1348
+ * outlives the spec's worktree, its branch and its folder.
1349
+ *
1350
+ * WHAT WOULD FOOL THIS: a store that will not parse. It holds someone's passes
1351
+ * and reading it as empty would report "nothing waiting", which is the one
1352
+ * answer that is certainly wrong — so it comes back named in `unreadable`
1353
+ * rather than counted as zero (rule 4).
1354
+ *
1355
+ * Oldest first and stable, ties broken on the code, exactly as
1356
+ * `describePending` orders one spec's own.
1357
+ */
1358
+ function waitingPasses(reviewsDirPath) {
1359
+ let entries
1360
+ try {
1361
+ entries = fs.readdirSync(reviewsDirPath)
1362
+ } catch {
1363
+ // No directory means no review has ever been rendered here. That is the
1364
+ // ordinary state of a fresh repo, not a failure to report.
1365
+ return { passes: [], unreadable: [] }
1366
+ }
1367
+
1368
+ const passes = []
1369
+ const unreadable = []
1370
+ for (const name of entries.sort()) {
1371
+ if (!name.endsWith(PENDING_SUFFIX)) continue
1372
+ const folder = name.slice(0, -PENDING_SUFFIX.length)
1373
+ // The engine's own reader, so "corrupt" means here what it means everywhere.
1374
+ const read = readPending(path.join(reviewsDirPath, `${folder}.html`), folder)
1375
+ if (read.corrupt) {
1376
+ unreadable.push(folder)
1377
+ continue
1378
+ }
1379
+ for (const pass of read.pending.passes || []) {
1380
+ if (pass && pass.code) {
1381
+ passes.push({
1382
+ spec: folder,
1383
+ code: pass.code,
1384
+ verdict: readVerdict(pass.blob && pass.blob.verdict),
1385
+ action: (pass.blob && pass.blob.action) || null,
1386
+ at: pass.at || null,
1387
+ })
1388
+ }
1389
+ }
1390
+ }
1391
+ passes.sort((a, b) => {
1392
+ const at = String(a.at || '').localeCompare(String(b.at || ''))
1393
+ return at !== 0 ? at : a.code.localeCompare(b.code)
1394
+ })
1395
+ return { passes, unreadable }
1396
+ }
1397
+
1398
+ /* ==========================================================================
1399
+ * The gate — a standing obligation to review, not a message in flight
1400
+ *
1401
+ * A pending pass is something someone SENT. The gate is something the repo
1402
+ * OWES: a phase ended, its page was rendered, and nobody has said what they
1403
+ * concluded yet. Kept in its own sidecar for exactly that reason — claiming a
1404
+ * pass consumes the pass, while only a COMMITTING verdict or a recorded skip
1405
+ * consumes the gate, and one file holding both states would have to encode
1406
+ * that difference anyway.
1407
+ *
1408
+ * It is the one thing in this engine that refuses. The marks still gate
1409
+ * nothing and nothing counts them — what this asserts is narrower: a phase
1410
+ * that ended is not finished until a person said something about it.
1411
+ * ========================================================================== */
1412
+
1413
+ const GATE_VERSION = 1
1414
+
1415
+ /**
1416
+ * The reason a BYPASS records, and it is deliberately a fixed string.
1417
+ *
1418
+ * A skip normally carries what the operator typed. This one is chosen from a
1419
+ * picker with nothing typed, so the record has to say that for itself — and a
1420
+ * string nobody would write by hand is what keeps a tapped bypass tellable from
1421
+ * a considered one when both appear in the same log.
1422
+ *
1423
+ * It stays `by: 'skip'` rather than minting a third `DISARMED_BY` verb: the
1424
+ * sidecar's vocabulary is versioned, and a distinct reason already buys the only
1425
+ * thing a third verb would have.
1426
+ */
1427
+ const GATE_BYPASS_REASON = 'none: chose to commit without reading the diff'
1428
+
1429
+ /**
1430
+ * The way out of an armed gate, declared beside the refusal rather than left
1431
+ * for a caller to reconstruct.
1432
+ *
1433
+ * A BYPASS, NOT A SATISFY: the phase still owes a verdict and nobody has read
1434
+ * it, so this steps past a guard that is still unsatisfied — which is exactly
1435
+ * why it is recorded and a `satisfy` is not.
1436
+ *
1437
+ * WHAT THIS IS NOT: a `--force`. One was asked for and rejected, because a
1438
+ * reasonless lift is indistinguishable from nobody having looked. The exits
1439
+ * this gate has are unchanged — a committing verdict, or a recorded skip — and
1440
+ * this is the second of those two at an address a person will actually reach.
1441
+ */
1442
+ const GATE_BYPASS_OFFER = Object.freeze({
1443
+ kind: 'bypass',
1444
+ label: 'Commit without reading the diff — recorded as such',
1445
+ command: `skitterspec spec-env review skip "${GATE_BYPASS_REASON}"`,
1446
+ })
1447
+
1448
+ // How a disarm happened. `verdict` is a review that reached a committing
1449
+ // conclusion; `skip` is the operator saying, on the record, that they are
1450
+ // moving on without one.
1451
+ const DISARMED_BY = ['verdict', 'skip']
1452
+
1453
+ function reviewGatePath(outPath) {
1454
+ return outPath.replace(/\.html$/, '') + '.gate.json'
1455
+ }
1456
+
1457
+ function emptyGate(specFolder) {
1458
+ return { version: GATE_VERSION, spec: specFolder, armed: false, armedAt: null, phase: null, offeredAt: null, permit: null, log: [] }
1459
+ }
1460
+
1461
+ /**
1462
+ * Read the gate sidecar. Never throws, and reports `corrupt` rather than
1463
+ * hiding it — the same three states `readNotes` answers in, for the same
1464
+ * reason. Here the third state is load-bearing in the other direction: a gate
1465
+ * we cannot parse must never READ AS ARMED, because that would refuse a commit
1466
+ * on the strength of a file nobody can interpret.
1467
+ */
1468
+ function readGate(outPath, specFolder) {
1469
+ let raw
1470
+ try {
1471
+ raw = fs.readFileSync(reviewGatePath(outPath), 'utf8')
1472
+ } catch {
1473
+ // Absent is the ordinary state — a project that never ends a phase through
1474
+ // the review path has no gate file at all.
1475
+ return { gate: emptyGate(specFolder), corrupt: false, present: false }
1476
+ }
1477
+ try {
1478
+ const parsed = JSON.parse(raw)
1479
+ return {
1480
+ gate: {
1481
+ version: parsed.version,
1482
+ spec: parsed.spec || specFolder,
1483
+ armed: parsed.armed === true,
1484
+ armedAt: parsed.armedAt || null,
1485
+ phase: parsed.phase === undefined ? null : parsed.phase,
1486
+ // A sidecar written before this field existed reads as never-offered,
1487
+ // which is the correct and harmless default. These files are gitignored,
1488
+ // so there is no fleet to migrate and `GATE_VERSION` does not move.
1489
+ offeredAt: parsed.offeredAt || null,
1490
+ // Same rule as `offeredAt`: a sidecar written before this existed reads
1491
+ // as no permit, which is the state that refuses. Anything that is not
1492
+ // an object is read as absent rather than trusted — this file is the
1493
+ // one thing standing between an armed gate and a commit.
1494
+ permit: parsed.permit && typeof parsed.permit === 'object' ? parsed.permit : null,
1495
+ log: Array.isArray(parsed.log) ? parsed.log : [],
1496
+ },
1497
+ corrupt: false,
1498
+ present: true,
1499
+ }
1500
+ } catch {
1501
+ return { gate: emptyGate(specFolder), corrupt: true, present: true }
1502
+ }
1503
+ }
1504
+
1505
+ function writeGate(outPath, gate) {
1506
+ const p = reviewGatePath(outPath)
1507
+ fs.mkdirSync(path.dirname(p), { recursive: true })
1508
+ fs.writeFileSync(p, JSON.stringify(gate, null, 2) + '\n')
1509
+ return p
1510
+ }
1511
+
1512
+ /* ==========================================================================
1513
+ * The render record — what the caller DECLARED, for the re-render to read
1514
+ * ========================================================================== */
1515
+
1516
+ // Same stem as the other sidecars, so one spec's set still reads as one set.
1517
+ function renderRecordPath(outPath) {
1518
+ return outPath.replace(/\.html$/, '') + '.render.json'
1519
+ }
1520
+
1521
+ /**
1522
+ * The button set the last CLI render of this spec declared, or null.
1523
+ *
1524
+ * WHY THIS EXISTS AT ALL. `--buttons` is declared by the caller — `BUTTON_SETS`
1525
+ * above says why it is never derived — and the review daemon is not that
1526
+ * caller. It re-renders a spec's page per request from the tree, so without a
1527
+ * record it can only guess, and what it guessed was the default: every
1528
+ * `/no-spec` page served over http offered `Commit & Start`.
1529
+ *
1530
+ * WHAT WOULD FOOL A CONSUMER THAT TRUSTED THIS OUTRIGHT: it is a file on disk
1531
+ * and the tree moves underneath it. A spec rendered as `--docs` and then put in
1532
+ * flight has a day-old record saying `authoring` and a worktree saying
1533
+ * otherwise. So `buttonsForView` in `serve.js` takes only NARROWING words from
1534
+ * here and reads the family off the tree — this function's job is to report
1535
+ * what was written, not to decide whether it still holds.
1536
+ *
1537
+ * Three states, like `readNotes` and `readGate`: absent is the ordinary case,
1538
+ * and corrupt is reported rather than hidden so neither reads as a declaration.
1539
+ */
1540
+ function readRenderRecord(outPath, specFolder) {
1541
+ let raw
1542
+ try {
1543
+ raw = fs.readFileSync(renderRecordPath(outPath), 'utf8')
1544
+ } catch {
1545
+ // Absent is ordinary: most pages are opened off the index without the CLI
1546
+ // ever having rendered that spec.
1547
+ return { buttons: null, corrupt: false, present: false }
1548
+ }
1549
+ try {
1550
+ const parsed = JSON.parse(raw)
1551
+ const buttons = typeof parsed.buttons === 'string' && parsed.buttons ? parsed.buttons : null
1552
+ // The folder is checked, not trusted: a record found under this spec's stem
1553
+ // that names another spec is not this spec's declaration.
1554
+ if (parsed.spec && specFolder && parsed.spec !== specFolder) {
1555
+ return { buttons: null, corrupt: false, present: true }
1556
+ }
1557
+ return { buttons, corrupt: false, present: true }
1558
+ } catch {
1559
+ return { buttons: null, corrupt: true, present: true }
1560
+ }
1561
+ }
1562
+
1563
+ function writeRenderRecord(outPath, record) {
1564
+ const p = renderRecordPath(outPath)
1565
+ fs.mkdirSync(path.dirname(p), { recursive: true })
1566
+ fs.writeFileSync(p, JSON.stringify(record, null, 2) + '\n')
1567
+ return p
1568
+ }
1569
+
1570
+ /**
1571
+ * Arm the gate. Pure.
1572
+ *
1573
+ * IDEMPOTENT ON PURPOSE. Re-rendering a page for a phase already awaiting a
1574
+ * verdict must not move `armedAt` — the timestamp answers "how long has this
1575
+ * been waiting", and a render is not an event that resets that. A gate armed
1576
+ * for a DIFFERENT phase is re-armed, because that is a new obligation.
1577
+ */
1578
+ function armGate(gate, { at, phase = null }) {
1579
+ // Idempotent within a phase — a re-render must not restart the clock, and it
1580
+ // must not hand back an offer this arming has already spent.
1581
+ if (gate.armed && gate.phase === phase) return gate
1582
+ // A NEW ARMING GETS A NEW OFFER. Each phase that ends is its own obligation,
1583
+ // so the once-only rule is scoped to the arming rather than to the file.
1584
+ //
1585
+ // AND A NEW OBLIGATION STARTS WITH NO WAY PAST IT. A permit is granted
1586
+ // against one arming — the commit one pressed `live-on` depends on — so
1587
+ // carrying it into the next phase would hand that phase a hole nobody asked
1588
+ // for. It is bound to a HEAD as well, so this is belt and braces; both are
1589
+ // cheap and only one of them has to hold.
1590
+ return { ...gate, armed: true, armedAt: at, phase, offeredAt: null, permit: null }
1591
+ }
1592
+
1593
+ /**
1594
+ * Record that the bypass was offered for this arming. Pure.
1595
+ *
1596
+ * SPENT BY BEING MADE, not by being taken. A reader who declines still saw it,
1597
+ * and re-offering on the next refused commit is how a bypass becomes the default
1598
+ * rather than a deliberate step.
1599
+ *
1600
+ * NOT DONE AS A SIDE EFFECT OF READING THE GATE, deliberately. `/spec-next`
1601
+ * asks `review gate --json` on every single run, so a read that spent the
1602
+ * offer would burn it with no picker ever raised — and the operator would never
1603
+ * be offered the thing this exists to offer them.
1604
+ *
1605
+ * Marking an unarmed gate changes nothing: there is no obligation, so there is
1606
+ * nothing to have offered a way out of.
1607
+ */
1608
+ function markGateOffered(gate, { at }) {
1609
+ if (!gate.armed || gate.offeredAt) return { gate, marked: false }
1610
+ return { gate: { ...gate, offeredAt: at }, marked: true }
1611
+ }
1612
+
1613
+ /**
1614
+ * The actions whose handling REQUIRES a commit in the spec's own worktree.
1615
+ *
1616
+ * One, and the list exists so the answer is looked up rather than remembered.
1617
+ * `allow-network` and `allow-remote` write a config key and commit nothing, so
1618
+ * neither has a precondition to permit — and a permit granted for an action
1619
+ * that never commits is a hole with no commit to close it.
1620
+ */
1621
+ const PERMITTED_ACTIONS = ['live-on']
1622
+
1623
+ /**
1624
+ * Record that ONE commit is a claimed action's mechanical precondition. Pure.
1625
+ *
1626
+ * WHY THIS EXISTS. The page offers `live-on` only on a non-midrun render, which
1627
+ * is what a finished phase produces — and a finished phase arms the gate. The
1628
+ * action is deliberately not a verdict, so it clears nothing; but `live take`
1629
+ * refuses a dirty worktree, so handling the press means committing the phase
1630
+ * first. That commit is a `git commit` in the spec's own worktree, which is
1631
+ * exactly what the armed gate denies. Without this the button could never
1632
+ * succeed in the only state it was ever offered in.
1633
+ *
1634
+ * WHAT IT IS NOT: a verdict, a skip, or a lift. The gate stays `armed`, so
1635
+ * `/spec-next` still refuses the next phase and the obligation is still
1636
+ * discharged by a committing verdict or a recorded skip and by nothing else.
1637
+ * This widens one commit, not the guard.
1638
+ *
1639
+ * THREE POSITIVE SIGNALS, all required (`.claude/rules/negative-checks.md`
1640
+ * rule 1), and every cannot-tell grants nothing:
1641
+ *
1642
+ * - The gate is ARMED. Nothing is refusing otherwise, so there is nothing to
1643
+ * permit past — and a permit written onto a clear gate would sit there
1644
+ * waiting for the next phase to arm.
1645
+ * - The action is one that COMMITS. See `PERMITTED_ACTIONS` above.
1646
+ * - The worktree is DIRTY, and a `head` was readable. A clean tree has no
1647
+ * precondition, so granting one would leave a permit lying around to cover
1648
+ * some later commit made for an entirely different reason.
1649
+ *
1650
+ * BOUND TO A HEAD, which is what makes it exactly one commit rather than a
1651
+ * standing permission. `permitHonoured` compares the worktree's HEAD against
1652
+ * the one recorded here, so the permit dies the instant its commit lands — and
1653
+ * a commit that FAILED (red tests, a rejected message) leaves HEAD alone, so
1654
+ * the retry is still covered. Spending it on the check instead would have made
1655
+ * that retry a refusal.
1656
+ */
1657
+ function grantPermit(gate, { at, action, head, dirty }) {
1658
+ if (!gate || !gate.armed) return { gate, granted: false }
1659
+ if (!PERMITTED_ACTIONS.includes(action)) return { gate, granted: false }
1660
+ if (dirty !== true || !head) return { gate, granted: false }
1661
+ return {
1662
+ gate: { ...gate, permit: { for: action, at, head, phase: gate.phase === undefined ? null : gate.phase } },
1663
+ granted: true,
1664
+ }
1665
+ }
1666
+
1667
+ /**
1668
+ * Does this gate's permit cover a commit happening at `head`? Pure.
1669
+ *
1670
+ * WHAT WOULD FOOL THIS: nothing here checks WHERE the commit is running. That
1671
+ * is deliberate — the caller has already established that it is inside this
1672
+ * spec's own worktree, which is the same positive signal `--for-command` wants
1673
+ * before it refuses at all, and duplicating it here would be a second copy free
1674
+ * to drift from the first.
1675
+ *
1676
+ * A permit for an action not in `PERMITTED_ACTIONS` is ignored rather than
1677
+ * trusted. The sidecar is gitignored and hand-editable, and an older engine
1678
+ * meeting a newer word should refuse, not guess.
1679
+ */
1680
+ function permitHonoured(gate, { head }) {
1681
+ if (!gate || !gate.armed) return false
1682
+ const permit = gate.permit
1683
+ if (!permit || typeof permit !== 'object') return false
1684
+ if (!PERMITTED_ACTIONS.includes(permit.for)) return false
1685
+ if (!permit.head || !head) return false
1686
+ return permit.head === head
1687
+ }
1688
+
1689
+ /**
1690
+ * Disarm it, and say how. Pure.
1691
+ *
1692
+ * The log is append-only and nothing reads it back to decide anything — it is
1693
+ * the record that a decision was taken, which is the whole value of a skip
1694
+ * over a silence. Disarming an already-clear gate logs nothing: there was no
1695
+ * obligation, so there is no outcome to record.
1696
+ */
1697
+ function disarmGate(gate, { at, by, reason = null }) {
1698
+ if (!gate.armed) return { gate, logged: false }
1699
+ const log = Array.isArray(gate.log) ? gate.log.slice() : []
1700
+ log.push({ by, at, phase: gate.phase === undefined ? null : gate.phase, reason })
1701
+ // The permit goes with the obligation it was a way through. Nothing refuses
1702
+ // once the gate is clear, so a surviving one could only ever apply to the
1703
+ // next phase's arming — which is not what anybody pressed.
1704
+ return { gate: { ...gate, armed: false, armedAt: null, phase: null, permit: null, log }, logged: true }
1705
+ }
1706
+
1707
+ /**
1708
+ * What the gate says, in three states. Pure.
1709
+ *
1710
+ * `armed` is the only one that refuses, and it is reached only by a POSITIVE
1711
+ * signal: a sidecar that is present, parseable, and says so
1712
+ * (`.claude/rules/negative-checks.md` rule 1). Everything else routes to the
1713
+ * harmless branch (rule 4) under its own name:
1714
+ *
1715
+ * - `clear` — read it, nothing is owed.
1716
+ * - `unknown` — could not read it, or the project turned the gate off. Nothing
1717
+ * is claimed and nothing refuses.
1718
+ *
1719
+ * WHAT WOULD FOOL THIS: a gate armed for a phase whose work has since been
1720
+ * committed by hand still reads armed, so the refusal outlives the thing it
1721
+ * was guarding. That is deliberate — the exit is one `skip` with a reason,
1722
+ * which is precisely the decision this exists to put on the record — and it
1723
+ * fails toward asking rather than toward letting a phase through unread.
1724
+ */
1725
+ /**
1726
+ * What the page is told about the gate. Pure. `null` when there is nothing to
1727
+ * say, so a project that never armed one renders byte-identically to how it
1728
+ * did before any of this existed — the same rule `phases` and `context` follow.
1729
+ *
1730
+ * The last SKIP travels with it, because that is the page's answer to the one
1731
+ * question an untouched-looking review raises: was this read and moved past, or
1732
+ * never read at all? A verdict already had an answer there; a skip did not.
1733
+ */
1734
+ function gateForPage(gate) {
1735
+ if (!gate) return null
1736
+ const log = Array.isArray(gate.log) ? gate.log : []
1737
+ const skips = log.filter((e) => e && e.by === 'skip')
1738
+ const lastSkip = skips.length ? skips[skips.length - 1] : null
1739
+ if (!gate.armed && !lastSkip) return null
1740
+ return {
1741
+ armed: gate.armed === true,
1742
+ armedAt: gate.armedAt || null,
1743
+ phase: gate.phase === undefined ? null : gate.phase,
1744
+ ...(lastSkip ? { lastSkip: { at: lastSkip.at || null, reason: lastSkip.reason || null } } : {}),
1745
+ }
1746
+ }
1747
+
1748
+ function gateState({ gate, corrupt, present, required }) {
1749
+ if (required === false) return { state: 'unknown', reason: 'review.required is false', gate }
1750
+ if (corrupt) return { state: 'unknown', reason: 'the gate sidecar is not readable JSON', gate }
1751
+ if (!present) return { state: 'clear', reason: 'no gate recorded', gate }
1752
+ if (gate.version !== GATE_VERSION) {
1753
+ return {
1754
+ state: 'unknown',
1755
+ reason: `gate version ${JSON.stringify(gate.version)} — this engine reads version ${GATE_VERSION}`,
1756
+ gate,
1757
+ }
1758
+ }
1759
+ if (!gate.armed) return { state: 'clear', reason: 'nothing is awaiting a verdict', gate }
1760
+ // The refusal is unchanged by the offer having been spent — what is withheld
1761
+ // is the way out, never the guard. A second refused commit still refuses, and
1762
+ // still names its exits in the text; the operator simply is not asked again.
1763
+ return {
1764
+ state: 'armed',
1765
+ reason: 'a phase is awaiting a verdict',
1766
+ gate,
1767
+ ...(gate.offeredAt ? {} : { offer: GATE_BYPASS_OFFER }),
1768
+ }
1769
+ }
1770
+
1771
+ /**
1772
+ * Validate a blob from the page, wholesale.
1773
+ *
1774
+ * It refuses on the FIRST problem and writes nothing, because a half-merged
1775
+ * sidecar is worse than a rejected paste: you would have to work out which
1776
+ * half landed. Every message names the offending entry so the answer is in the
1777
+ * refusal rather than in a second investigation.
1778
+ *
1779
+ * Unknown keys are IGNORED rather than rejected — an older engine meeting a
1780
+ * newer page should drop what it does not understand, not refuse the accepts it
1781
+ * does.
1782
+ */
1783
+ function validateNotesBlob(blob, specFolder) {
1784
+ const fail = (m) => {
1785
+ throw new Error(`notes blob: ${m}`)
1786
+ }
1787
+ if (!blob || typeof blob !== 'object' || Array.isArray(blob)) fail('not a JSON object')
1788
+ if (blob.version !== NOTES_VERSION) {
1789
+ fail(`version ${JSON.stringify(blob.version)} — this engine reads version ${NOTES_VERSION}`)
1790
+ }
1791
+ // Pasting the wrong spec's review is the one mistake that would look entirely
1792
+ // successful, so the blob names its spec and the engine checks it.
1793
+ if (blob.spec && specFolder && blob.spec !== specFolder) {
1794
+ fail(`written for ${blob.spec}, but this is ${specFolder}`)
1795
+ }
1796
+ const arr = (key) => {
1797
+ const v = blob[key]
1798
+ if (v === undefined || v === null) return []
1799
+ if (!Array.isArray(v)) fail(`${key} must be an array`)
1800
+ return v
1801
+ }
1802
+ const accepted = arr('accepted')
1803
+ for (const a of accepted) {
1804
+ if (!a || typeof a !== 'object' || Array.isArray(a)) fail('every accepted entry must be an object')
1805
+ if (typeof a.path !== 'string' || !a.path) fail('an accepted entry has no path')
1806
+ if (typeof a.hash !== 'string' || !a.hash) fail(`accepted entry ${a.path} has no hash`)
1807
+ }
1808
+ const unaccepted = arr('unaccepted')
1809
+ for (const p of unaccepted) {
1810
+ if (typeof p !== 'string' || !p) fail('every unaccepted entry must be a path')
1811
+ }
1812
+ const comments = arr('comments')
1813
+ for (const c of comments) {
1814
+ if (!c || typeof c !== 'object' || Array.isArray(c)) fail('every comment must be an object')
1815
+ if (typeof c.id !== 'string' || !c.id) fail('a comment has no id')
1816
+ if (typeof c.file !== 'string' || !c.file) fail(`comment ${c.id} has no file`)
1817
+ if (typeof c.note !== 'string' || !c.note.trim()) fail(`comment ${c.id} has no note`)
1818
+ if (c.line !== undefined && c.line !== null && !Number.isInteger(c.line)) {
1819
+ fail(`comment ${c.id} has a non-integer line`)
1820
+ }
1821
+ }
1822
+ // A KNOWN KEY WITH AN UNKNOWN VALUE IS REFUSED BY NAME, which is not the
1823
+ // unknown-key rule above wearing a hat. Dropping `verdict: "aprove"` the way
1824
+ // we drop a key we have never heard of would read as `discuss` — a review
1825
+ // that silently did nothing, reported as if it had. Absent is a different
1826
+ // thing from wrong, and only absent is allowed through.
1827
+ let verdict = null
1828
+ if (blob.verdict !== undefined && blob.verdict !== null) {
1829
+ // Read through the rename BEFORE checking: a page that has not been
1830
+ // reloaded still sends `approve`, and refusing it would turn a stale tab
1831
+ // into a rejected review rather than a committed one.
1832
+ const named = typeof blob.verdict === 'string' ? readVerdict(blob.verdict) : blob.verdict
1833
+ if (typeof named !== 'string' || !VERDICTS.includes(named)) {
1834
+ fail(`verdict ${JSON.stringify(blob.verdict)} is not one of ${VERDICTS.join(', ')}`)
1835
+ }
1836
+ verdict = named
1837
+ }
1838
+ // AN ACTION, refused by name for the same reason a misspelt verdict is: a
1839
+ // dropped `action: "live-onn"` would read as a pass carrying no instruction
1840
+ // at all, reported as if the press had been honoured.
1841
+ let action = null
1842
+ if (blob.action !== undefined && blob.action !== null) {
1843
+ if (typeof blob.action !== 'string' || !ACTIONS.includes(blob.action)) {
1844
+ fail(`action ${JSON.stringify(blob.action)} is not one of ${ACTIONS.join(', ')}`)
1845
+ }
1846
+ action = blob.action
1847
+ }
1848
+ if (action && verdict) {
1849
+ fail(`carries both a verdict (${verdict}) and an action (${action}) — send one`)
1850
+ }
1851
+ return { accepted, unaccepted, comments, verdict, action }
1852
+ }
1853
+
1854
+ /**
1855
+ * Decide what a verdict actually does, given the notes it arrives with. Pure.
1856
+ *
1857
+ * THE ONE ACCUSING CHECK HERE: an approval is refused while any comment is
1858
+ * unresolved. It reads a PRESENCE — an open comment, sitting in the sidecar —
1859
+ * rather than an absence, so there is no lookup that could have been too narrow
1860
+ * to see the thing (`.claude/rules/negative-checks.md` rule 1). The comments
1861
+ * counted are whatever is in `notes` at the moment of the call, which is after
1862
+ * the blob has been merged and after any `--resolve` has landed, so a note
1863
+ * raised and answered in the same run does not block.
1864
+ *
1865
+ * WHAT WOULD FOOL THIS: nothing about the count, but the intent behind it is
1866
+ * narrow. UNACCEPTED FILES ARE NOT COUNTED AND MUST NEVER BE. A comment is a
1867
+ * request you made; an unticked file is merely something you said nothing
1868
+ * about, and requiring every file ticked would be the counting gate this whole
1869
+ * feature was designed not to become.
1870
+ *
1871
+ * A refused approval routes to `discuss` — report and stop — rather than
1872
+ * staying `approve` with a flag beside it. Three states, and the one we cannot
1873
+ * honour goes to the harmless branch (rule 4): a caller that reads `effective`
1874
+ * and ignores `honoured` then talks instead of committing, which is the
1875
+ * failure we can afford.
1876
+ */
1877
+ function judgeVerdict(verdict, notes) {
1878
+ const read = readVerdict(verdict) || null
1879
+ // A WORD THIS ENGINE DOES NOT KNOW IS NOT HONOURED AS ONE. Every door into
1880
+ // here validates first — `validateNotesBlob` refuses an unknown verdict by
1881
+ // name and refuses an action in the verdict slot, and `--verdict` checks the
1882
+ // list — so nothing reaches this today. It used to pass such a word straight
1883
+ // through with `honoured: true`, which routes cannot-tell to the branch that
1884
+ // acts (`.claude/rules/negative-checks.md` rule 4 inverted). Closing it costs
1885
+ // nothing and means a fifth door added later cannot reopen it.
1886
+ const sent = read && VERDICTS.includes(read) ? read : null
1887
+ const asked = sent || DEFAULT_VERDICT
1888
+ const open = (notes.comments || []).filter((c) => !c.resolved)
1889
+ const openFiles = [...new Set(open.map((c) => c.file))]
1890
+ if (!COMMITTING.includes(asked) || open.length === 0) {
1891
+ return { sent, effective: asked, honoured: true, reason: null, openCount: open.length, openFiles }
1892
+ }
1893
+ return {
1894
+ sent,
1895
+ effective: DEFAULT_VERDICT,
1896
+ honoured: false,
1897
+ reason:
1898
+ `${open.length} comment${open.length === 1 ? ' is' : 's are'} unresolved ` +
1899
+ `(${openFiles.join(', ')})`,
1900
+ openCount: open.length,
1901
+ openFiles,
1902
+ }
1903
+ }
1904
+
1905
+ /**
1906
+ * Append one line to the outcome log. Pure.
1907
+ *
1908
+ * HISTORY, NOT STATE. Nothing reads `decisions` to decide anything — a verdict
1909
+ * is consumed by the thing it asked for (a commit, or the work) and is never
1910
+ * stored as a pending instruction, so an approval cannot go stale and later
1911
+ * commit something nobody read. What is kept is the account of what was
1912
+ * decided, when, and eventually what it produced.
1913
+ */
1914
+ function appendDecision(notes, { verdict, at, note = null, code = null }) {
1915
+ const decisions = Array.isArray(notes.decisions) ? notes.decisions.slice() : []
1916
+ // `code` NAMES THE PASS this decision came out of, and it is the only present
1917
+ // thing anyone can assert to conclude that a pass was picked up. Four
1918
+ // different things make a code vanish from the holding area and only one of
1919
+ // them is a claim — `--drop` is another, a moved store and a mistyped folder
1920
+ // are two more — so `passState` reads this, and never reads an absence.
1921
+ //
1922
+ // `null` for every other path in: a pasted blob and a `--verdict` word carry
1923
+ // no code, and inventing one would put a pass in the record that never
1924
+ // existed.
1925
+ decisions.push({ verdict, at, note: note === undefined ? null : note, code: code || null })
1926
+ return { ...notes, updatedAt: at, decisions }
1927
+ }
1928
+
1929
+ /**
1930
+ * Write what a decision PRODUCED onto the last entry in the log. Pure.
1931
+ *
1932
+ * The verdict is logged when it is honoured, which is before the thing it asked
1933
+ * for has happened — so the sha, and which path produced it, can only be added
1934
+ * afterwards. That is this function.
1935
+ *
1936
+ * WHAT WOULD FOOL THIS: it annotates the LAST entry, whatever that is. If a
1937
+ * second verdict is logged between the approval and its commit, the outcome
1938
+ * lands on the wrong one. Reaching that takes two review passes interleaved
1939
+ * against one sidecar, and the cost is a misfiled line in a history nothing
1940
+ * reads to decide anything — so it is accepted rather than guarded, and named
1941
+ * here so a later reader does not have to rediscover it.
1942
+ *
1943
+ * An empty log is left exactly as it was: there is no decision to describe, and
1944
+ * inventing one would put an outcome in the record with no decision behind it.
1945
+ */
1946
+ function annotateLastDecision(notes, note) {
1947
+ const decisions = Array.isArray(notes.decisions) ? notes.decisions.slice() : []
1948
+ if (!decisions.length) return { notes, annotated: false }
1949
+ decisions[decisions.length - 1] = { ...decisions[decisions.length - 1], note }
1950
+ return { notes: { ...notes, decisions }, annotated: true }
1951
+ }
1952
+
1953
+ /**
1954
+ * Merge a validated blob into the stored notes. Pure.
1955
+ *
1956
+ * MERGE, NEVER REPLACE. The page only knows the render it was built from, so a
1957
+ * replace would drop the agent's resolutions and let a tab left open overnight
1958
+ * roll back everything recorded since. Anything the blob does not mention is
1959
+ * carried through untouched — which is also why un-accepting is an EXPLICIT
1960
+ * `unaccepted` list rather than absence from `accepted`.
1961
+ *
1962
+ * Comments merge by id, and the page mints ids from the render timestamp, so
1963
+ * pasting the same blob twice is idempotent instead of doubling every note.
1964
+ */
1965
+ function mergeNotes(existing, blob, now) {
1966
+ const notes = {
1967
+ version: NOTES_VERSION,
1968
+ spec: existing.spec,
1969
+ updatedAt: now,
1970
+ files: { ...existing.files },
1971
+ comments: existing.comments.slice(),
1972
+ }
1973
+ // The log is history and the merge rebuilds the object from scratch, so it
1974
+ // has to be carried across explicitly or every paste would erase it.
1975
+ if (Array.isArray(existing.decisions)) notes.decisions = existing.decisions.slice()
1976
+ for (const a of blob.accepted) notes.files[a.path] = { acceptedHash: a.hash, acceptedAt: now }
1977
+ for (const p of blob.unaccepted) delete notes.files[p]
1978
+
1979
+ const indexById = new Map(notes.comments.map((c, i) => [c.id, i]))
1980
+ for (const c of blob.comments) {
1981
+ const entry = {
1982
+ id: c.id,
1983
+ file: c.file,
1984
+ line: c.line === undefined ? null : c.line,
1985
+ lineText: c.lineText === undefined ? null : c.lineText,
1986
+ check: c.check === undefined ? null : c.check,
1987
+ note: c.note,
1988
+ raisedAt: now,
1989
+ resolved: null,
1990
+ }
1991
+ const at = indexById.get(c.id)
1992
+ if (at === undefined) {
1993
+ indexById.set(c.id, notes.comments.length)
1994
+ notes.comments.push(entry)
1995
+ continue
1996
+ }
1997
+ // A re-paste refreshes the text but keeps the history: when it was first
1998
+ // raised, and any resolution already written against it.
1999
+ const prev = notes.comments[at]
2000
+ notes.comments[at] = { ...entry, raisedAt: prev.raisedAt, resolved: prev.resolved }
2001
+ }
2002
+ return notes
2003
+ }
2004
+
2005
+ /**
2006
+ * Validate a resolutions file — the agent's half of the round-trip.
2007
+ *
2008
+ * Separate from `validateNotesBlob` because the shapes and the authors differ:
2009
+ * a blob comes from a page through a clipboard, and this comes from whatever
2010
+ * just finished working the comments. Same rule though — refuse wholesale, and
2011
+ * name what is wrong.
2012
+ */
2013
+ function validateResolutions(raw) {
2014
+ const fail = (m) => {
2015
+ throw new Error(`resolutions: ${m}`)
2016
+ }
2017
+ const list = Array.isArray(raw) ? raw : raw && Array.isArray(raw.resolved) ? raw.resolved : null
2018
+ if (!list) fail('expected an array of { id, note }')
2019
+ for (const r of list) {
2020
+ if (!r || typeof r !== 'object' || Array.isArray(r)) fail('every entry must be an object')
2021
+ if (typeof r.id !== 'string' || !r.id) fail('an entry has no id')
2022
+ // "Resolved" with nothing said is not a resolution — the note is the half
2023
+ // that lets the next read verify the fix instead of trusting it.
2024
+ if (typeof r.note !== 'string' || !r.note.trim()) fail(`entry ${r.id} has no note`)
2025
+ }
2026
+ return list
2027
+ }
2028
+
2029
+ /**
2030
+ * Attach resolutions to the comments they name. Pure.
2031
+ *
2032
+ * An id matching nothing is REPORTED AND SKIPPED, never invented and never
2033
+ * fatal. A resolution naming a comment that does not exist is a mistake worth
2034
+ * surfacing — but failing the whole call would throw away the work that was
2035
+ * genuinely done on the ids that did match.
2036
+ *
2037
+ * Re-resolving overwrites rather than stacking: the newest account of what was
2038
+ * done is the one that matches the code.
2039
+ */
2040
+ function applyResolutions(notes, resolutions, now) {
2041
+ const byId = new Map(notes.comments.map((c) => [c.id, c]))
2042
+ const unknown = []
2043
+ let applied = 0
2044
+ for (const r of resolutions) {
2045
+ const c = byId.get(r.id)
2046
+ if (!c) {
2047
+ unknown.push(r.id)
2048
+ continue
2049
+ }
2050
+ c.resolved = { at: now, note: r.note }
2051
+ applied++
2052
+ }
2053
+ return { notes: { ...notes, updatedAt: now }, applied, unknown }
2054
+ }
2055
+
2056
+ /**
2057
+ * Fold the stored notes onto the collected files.
2058
+ *
2059
+ * `accepted` is `true` only when the recorded hash matches what is there now;
2060
+ * a difference is `'lapsed'` and is SAID rather than silently forgotten, so a
2061
+ * file you vouched for two phases ago cannot quietly pass as still-read.
2062
+ */
2063
+ function applyNotes(files, notes, hashes) {
2064
+ const byFile = new Map()
2065
+ for (const c of notes.comments) {
2066
+ if (!byFile.has(c.file)) byFile.set(c.file, [])
2067
+ byFile.get(c.file).push(c)
2068
+ }
2069
+ const totals = { accepted: 0, lapsed: 0, unresolved: 0, resolved: 0 }
2070
+ for (const f of files) {
2071
+ const hash = hashes.has(f.path) ? hashes.get(f.path) : null
2072
+ f.hash = hash === undefined ? null : hash
2073
+ const rec = notes.files[f.path]
2074
+ if (!rec || !rec.acceptedHash) {
2075
+ f.accepted = false
2076
+ f.acceptedAt = null
2077
+ } else if (f.hash && rec.acceptedHash === f.hash) {
2078
+ f.accepted = true
2079
+ f.acceptedAt = rec.acceptedAt || null
2080
+ totals.accepted++
2081
+ } else {
2082
+ f.accepted = 'lapsed'
2083
+ f.acceptedAt = rec.acceptedAt || null
2084
+ totals.lapsed++
2085
+ }
2086
+ f.comments = byFile.get(f.path) || []
2087
+ }
2088
+ for (const c of notes.comments) {
2089
+ if (c.resolved) totals.resolved++
2090
+ else totals.unresolved++
2091
+ }
2092
+ // A comment on a file that is no longer in the diff (reverted, or landed in
2093
+ // an earlier phase) would otherwise vanish from the page along with its file.
2094
+ const shown = new Set(files.map((f) => f.path))
2095
+ const unanchored = notes.comments.filter((c) => !shown.has(c.file))
2096
+ return { totals, unanchored }
2097
+ }
2098
+
2099
+
2100
+ /**
2101
+ * The page template — a SHIPPED ASSET, not generated markup.
2102
+ *
2103
+ * `assets/` is in every distribution's `files`, and the build copies non-`.md`
2104
+ * assets across verbatim, so this resolves identically from the source package
2105
+ * (`packages/common/src/env` → `packages/common/assets`) and from a built
2106
+ * distribution (`src/env` → `<pkg>/assets`).
2107
+ *
2108
+ * The engine SPLICES into it and never authors markup: the model's prose arrives
2109
+ * as JSON and the diff arrives as text, so neither has to survive a round-trip
2110
+ * through generated HTML.
2111
+ */
2112
+ const TEMPLATE_PATH = path.join(__dirname, '..', '..', 'assets', 'review', 'page.html')
2113
+ const DATA_PLACEHOLDER = '__REVIEW_DATA__'
2114
+ const REVIEW_PLACEHOLDER = '__REVIEW_BLOCK__'
2115
+ const TITLE_PLACEHOLDER = '__REVIEW_TITLE__'
2116
+ const PLACEHOLDER_RE = /__REVIEW_(?:TITLE|BLOCK|DATA)__/g
2117
+
2118
+ function loadTemplate() {
2119
+ return fs.readFileSync(TEMPLATE_PATH, 'utf8')
2120
+ }
2121
+
2122
+ // Escape the one sequence that could end the data island early. A patch
2123
+ // containing `</script>` is not hypothetical — this feature reviews its own
2124
+ // source, and the template above contains one.
2125
+ function escapeIsland(json) {
2126
+ return json.replace(/<\//g, '<\\/')
2127
+ }
2128
+
2129
+ function escapeHtml(s) {
2130
+ return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
2131
+ }
2132
+
2133
+ /**
2134
+ * Splice the collected data (and, later, the written review) into the template.
2135
+ * The data goes in as JSON, never as generated markup — that is the property the
2136
+ * whole design rests on.
2137
+ */
2138
+ /**
2139
+ * The fragment form of the template: what an artifact host can accept.
2140
+ *
2141
+ * A published page is wrapped in the host's own `<!doctype>`/`<head>`/`<body>`
2142
+ * skeleton, so handing it a complete document nests two of them. The fragment
2143
+ * carries the `<title>` (hosts read it for the tab name), every `<style>` and
2144
+ * `<script>` block out of `<head>`, then the body contents — and no wrapper.
2145
+ *
2146
+ * IT SPLITS THE TEMPLATE, NEVER THE RENDERED PAGE, and that is the whole
2147
+ * correctness argument. The template contains exactly one of each boundary tag
2148
+ * and nothing but placeholders where content goes. A rendered page contains the
2149
+ * diff, and this feature reviews its own source — so `<!doctype html>`,
2150
+ * `<html>` and `<body>` appear inside it as ordinary patch text. A regex over
2151
+ * the rendered page finds those and cuts in the wrong place; the first hand
2152
+ * publish of `feat-review-offer-lands` hit exactly that, with four
2153
+ * wrapper-looking tags in the output that were all patch content.
2154
+ */
2155
+ function fragmentTemplate(template = null) {
2156
+ const t = template || loadTemplate()
2157
+
2158
+ const headStart = t.indexOf('<head>')
2159
+ const headEnd = t.indexOf('</head>')
2160
+ const bodyOpen = t.indexOf('<body')
2161
+ const bodyStart = t.indexOf('>', bodyOpen) + 1
2162
+ const bodyEnd = t.lastIndexOf('</body>')
2163
+ if (headStart < 0 || headEnd < 0 || bodyOpen < 0 || bodyEnd < 0) {
2164
+ throw new Error('review template has no <head>/<body> to split — cannot build a fragment')
2165
+ }
2166
+
2167
+ const head = t.slice(headStart, headEnd)
2168
+ const title = /<title>[\s\S]*?<\/title>/.exec(head)
2169
+ const carried = head.match(/<(style|script)\b[\s\S]*?<\/\1>/g) || []
2170
+
2171
+ return [title ? title[0] : '', ...carried, t.slice(bodyStart, bodyEnd).trim()]
2172
+ .filter(Boolean)
2173
+ .join('\n')
2174
+ }
2175
+
2176
+ /**
2177
+ * The same data, spliced into the fragment instead of the whole document. One
2178
+ * pass over the placeholders, for the same reason `renderReviewPage` uses one.
2179
+ */
2180
+ function renderReviewFragment(data, { template = null, reviewHtml = '' } = {}) {
2181
+ const values = {
2182
+ [TITLE_PLACEHOLDER]: escapeHtml(data.title),
2183
+ [REVIEW_PLACEHOLDER]: reviewHtml,
2184
+ [DATA_PLACEHOLDER]: escapeIsland(JSON.stringify(data)),
2185
+ }
2186
+ return fragmentTemplate(template).replace(PLACEHOLDER_RE, (m) => values[m])
2187
+ }
2188
+
2189
+ function renderReviewPage(data, { template = null, reviewHtml = '' } = {}) {
2190
+ const values = {
2191
+ [TITLE_PLACEHOLDER]: escapeHtml(data.title),
2192
+ [REVIEW_PLACEHOLDER]: reviewHtml,
2193
+ [DATA_PLACEHOLDER]: escapeIsland(JSON.stringify(data)),
2194
+ }
2195
+ // ONE pass, so nothing spliced in is ever rescanned. This is not theoretical:
2196
+ // the page reviews its own source, so the data island legitimately CONTAINS
2197
+ // all three placeholder strings, and sequential replaces would splice a whole
2198
+ // JSON blob into the middle of a patch — or into a review note that happened
2199
+ // to quote a placeholder name.
2200
+ return (template || loadTemplate()).replace(PLACEHOLDER_RE, (m) => values[m])
2201
+ }
2202
+
2203
+ /**
2204
+ * Render the written review into HTML.
2205
+ *
2206
+ * THE ENGINE RENDERS; THE MODEL JUDGES. The review arrives as JSON — a short
2207
+ * read plus severity-tagged checks — and this turns it into markup. The two
2208
+ * rejected alternatives: the model emitting HTML (more tokens, and one unclosed
2209
+ * tag breaks the page), and the engine parsing markdown (a markdown renderer to
2210
+ * ship and maintain for one surface).
2211
+ *
2212
+ * Every field is escaped. The review is model-authored text arriving through a
2213
+ * file, which is exactly the input that should never be trusted as markup.
2214
+ */
2215
+ const CHECK_LEVELS = ['flag', 'confirm', 'good']
2216
+
2217
+ function renderReviewBlock(review) {
2218
+ if (!review || typeof review !== 'object') return ''
2219
+ const parts = ['<section class="review">']
2220
+ if (review.summary) {
2221
+ parts.push(`<p class="review-summary">${escapeHtml(review.summary)}</p>`)
2222
+ }
2223
+ parts.push(...renderReviewerStrip(review.reviewers))
2224
+ const checks = Array.isArray(review.checks) ? review.checks : []
2225
+ if (checks.length) {
2226
+ parts.push('<ul class="checks">')
2227
+ checks.forEach((c, i) => {
2228
+ // An unknown level is shown as `confirm` rather than dropped: losing a
2229
+ // reviewer's note because it was tagged oddly is worse than showing it
2230
+ // under a neutral heading.
2231
+ const level = CHECK_LEVELS.includes(c.level) ? c.level : 'confirm'
2232
+ // `file:line` where a line is known, and the whole thing is a button so a
2233
+ // finding can be read against the code it is about. A check with no line
2234
+ // renders exactly as it always did — the written review supplies none, and
2235
+ // its output must not change because machine findings now exist.
2236
+ const hasLine = Number.isInteger(c.line) && c.line > 0
2237
+ const label = c.file ? escapeHtml(c.file) + (hasLine ? `:${c.line}` : '') : ''
2238
+ const file = c.file
2239
+ ? `<button type="button" class="check-file" data-goto="${escapeHtml(c.file)}"` +
2240
+ `${hasLine ? ` data-goto-line="${c.line}"` : ''}>${label}</button>`
2241
+ : ''
2242
+ // WHO SAID IT. Absent for a written review, which is what it has always
2243
+ // been; present the moment two authors can appear on one page, because a
2244
+ // reader weighing a finding needs to know whether a person read the code
2245
+ // or a tool matched a pattern.
2246
+ const src = c.source ? `<span class="check-src">${escapeHtml(c.source)}</span>` : ''
2247
+ // The id is positional and therefore stable for THIS render, which is all
2248
+ // a reply needs: it travels back in the same blob the page was built from.
2249
+ // `data-file` is what lets an answer land on the file the check is about.
2250
+ const id = `k${i}`
2251
+ const fileAttr = c.file ? ` data-file="${escapeHtml(c.file)}"` : ''
2252
+ const lineAttr = hasLine ? ` data-line="${c.line}"` : ''
2253
+ parts.push(
2254
+ `<li class="check ${level}" data-check="${id}"${fileAttr}${lineAttr}>` +
2255
+ `<span class="check-level">${level}</span>` +
2256
+ `${src}${file}<span class="check-note">${escapeHtml(c.note || '')}</span></li>`,
2257
+ )
2258
+ })
2259
+ parts.push('</ul>')
2260
+ }
2261
+ parts.push('</section>')
2262
+ return parts.join('\n')
2263
+ }
2264
+
2265
+ /**
2266
+ * One line per configured reviewer, saying what its run ended as.
2267
+ *
2268
+ * PRESENT EVEN WHEN EVERY REVIEWER FOUND NOTHING, which is the whole point and
2269
+ * the one place `.claude/rules/negative-checks.md` inverts here. Silence is
2270
+ * normally the safe branch; on this page it is not, because a reviewer that was
2271
+ * rate-limited renders identically to one that read the diff and approved of it
2272
+ * — and the reader is about to decide whether to commit. So the strip reports
2273
+ * the RUN, which is a positive signal, rather than accusing the code.
2274
+ *
2275
+ * Absent entirely when no reviewer is configured: every project that exists
2276
+ * today is in that state, and a strip saying "no reviewers" would be an absence
2277
+ * reported at everyone.
2278
+ */
2279
+ function renderReviewerStrip(outcomes) {
2280
+ const list = Array.isArray(outcomes) ? outcomes : []
2281
+ if (!list.length) return []
2282
+ const parts = ['<ul class="reviewers">']
2283
+ for (const o of list) {
2284
+ const state = typeof o.state === 'string' ? o.state : 'failed'
2285
+ parts.push(
2286
+ `<li class="reviewer ${escapeHtml(state)}">` +
2287
+ `<span class="reviewer-name">${escapeHtml(o.name || 'reviewer')}</span>` +
2288
+ `<span class="reviewer-said">${escapeHtml(reviewerSaid(o))}</span></li>`,
2289
+ )
2290
+ }
2291
+ parts.push('</ul>')
2292
+ return parts
2293
+ }
2294
+
2295
+ /**
2296
+ * What one reviewer's outcome reads as. Pure.
2297
+ *
2298
+ * A STATE THIS BUILD DOES NOT KNOW IS SHOWN, not dropped — the same rule the
2299
+ * check level follows. An unrecognised state means a newer engine wrote the
2300
+ * cache, and losing the line entirely would read as a reviewer that was never
2301
+ * configured.
2302
+ */
2303
+ function reviewerSaid(o) {
2304
+ const n = Number.isInteger(o.count) ? o.count : 0
2305
+ const detail = o.detail ? ` — ${o.detail}` : ''
2306
+ switch (o.state) {
2307
+ case 'findings':
2308
+ return `${n} finding${n === 1 ? '' : 's'}${detail}`
2309
+ case 'cached':
2310
+ return n ? `${n} finding${n === 1 ? '' : 's'} · cached` : 'clean · cached'
2311
+ case 'clean':
2312
+ return 'clean'
2313
+ case 'missing':
2314
+ return `did not run${o.detail ? ` — ${o.detail}` : ''}`
2315
+ case 'timeout':
2316
+ return 'did not run — timed out'
2317
+ case 'failed':
2318
+ return `did not run${o.detail ? ` — ${o.detail}` : ''}`
2319
+ default:
2320
+ return `${o.state}${detail}`
2321
+ }
2322
+ }
2323
+
2324
+ /**
2325
+ * Where a spec's published URL is remembered, and what it currently says.
2326
+ *
2327
+ * The engine READS this file and never writes it, and it does not know what the
2328
+ * string means — it cannot publish, and nothing here can. It is named here so
2329
+ * the skill that does publish never has to construct a path, which is the only
2330
+ * way the two halves stay in step.
2331
+ */
2332
+ function reviewUrlPath(outPath) {
2333
+ return outPath.replace(/\.html$/, '') + '.url'
2334
+ }
2335
+
2336
+ /**
2337
+ * What teardown says about a published page. PURE — url in, lines out.
2338
+ *
2339
+ * A published page is the ONE thing teardown cannot reclaim. The worktree goes,
2340
+ * the branch goes, the tracker assignment is released — and the page stays up,
2341
+ * because nothing in this tooling can delete it and nothing should pretend to.
2342
+ * So it is named, with where it can be removed, and that is the whole feature.
2343
+ *
2344
+ * Returns `[]` when the spec was never published, which is most specs. An
2345
+ * absence explained is noise: a teardown that mentions publishing to everyone
2346
+ * who never published is worse than one that says nothing.
2347
+ *
2348
+ * **Deliberately not a command, and never part of `run these:`.** There is no
2349
+ * command to run — removal is a person opening `/artifacts`. Folding it into the
2350
+ * batch would imply skitterspec could do it.
2351
+ */
2352
+ function publishedPageNotice(url) {
2353
+ if (!url) return []
2354
+ return [
2355
+ '',
2356
+ ' published page survives this teardown — skitterspec cannot remove it:',
2357
+ ` ${url}`,
2358
+ ' delete it yourself: /artifacts in the terminal (o opens, c copies), or',
2359
+ ' the gallery at claude.ai/code/artifacts.',
2360
+ ]
2361
+ }
2362
+
2363
+ /**
2364
+ * What to say about a review server at teardown. PURE — takes the facts as
2365
+ * arguments so a test states the world it describes.
2366
+ *
2367
+ * The server the operator never started is the one nobody remembers to stop:
2368
+ * `spec-env review` stands it up on a remote reader, and one path token unlocks
2369
+ * every provisioned spec's diff for as long as it runs. So teardown names it —
2370
+ * but only when there is genuinely nothing left for it to serve.
2371
+ *
2372
+ * `running` must come from a POSITIVE signal (`isAlive`), never from a pidfile
2373
+ * existing: a crashed process leaves one behind, and telling someone to stop a
2374
+ * server that is already gone is an accusation against a healthy teardown.
2375
+ */
2376
+ function reviewServerNotice({ running, othersServed }) {
2377
+ if (!running || othersServed > 0) return []
2378
+ return [
2379
+ '',
2380
+ ' review server — this was the last spec it served:',
2381
+ ' skitterspec spec-env review serve --stop',
2382
+ ]
2383
+ }
2384
+
2385
+ // The publish-ready copy, beside the page it came from. Same stem, so every
2386
+ // sidecar (`.notes.json`, `.url`, `.publish.html`, `.render.json`) reads as one
2387
+ // spec's set.
2388
+ function reviewPublishPath(outPath) {
2389
+ return outPath.replace(/\.html$/, '') + '.publish.html'
2390
+ }
2391
+
2392
+ function readReviewUrl(outPath) {
2393
+ try {
2394
+ return fs.readFileSync(reviewUrlPath(outPath), 'utf8').trim() || null
2395
+ } catch {
2396
+ return null
2397
+ }
2398
+ }
2399
+
2400
+ /**
2401
+ * Whether ONE SPEC may publish, beside its page in the gitignored store.
2402
+ *
2403
+ * WHY PER SPEC. `review.allowRemote` in the committed config permits every
2404
+ * spec in the repo at once, and writing it from a worktree dirtied the base
2405
+ * branch (bug-remote-review-toggle). Publishing sends a diff to a third party,
2406
+ * so what may generate an artifact is decided per worktree; the committed key
2407
+ * stays as the project default beneath it.
2408
+ */
2409
+ function reviewRemotePath(outPath) {
2410
+ return outPath.replace(/\.html$/, '') + '.remote.json'
2411
+ }
2412
+
2413
+ // true / false when the sidecar says so, null for every cannot-tell — absent,
2414
+ // unreadable, a non-boolean — so the committed default stands
2415
+ // (`.claude/rules/negative-checks.md` rule 4: a typo must not permit a publish).
2416
+ function readRemoteAllow(outPath) {
2417
+ try {
2418
+ const v = JSON.parse(fs.readFileSync(reviewRemotePath(outPath), 'utf8')).allowRemote
2419
+ return typeof v === 'boolean' ? v : null
2420
+ } catch {
2421
+ return null
2422
+ }
2423
+ }
2424
+
2425
+ function writeRemoteAllow(outPath, on, at = new Date().toISOString()) {
2426
+ const file = reviewRemotePath(outPath)
2427
+ fs.mkdirSync(path.dirname(file), { recursive: true })
2428
+ fs.writeFileSync(file, JSON.stringify({ allowRemote: on, at }, null, 2) + '\n')
2429
+ return file
2430
+ }
2431
+
2432
+ // The config one spec's render reads: the project's, with this spec's own
2433
+ // remote permission over it. A new object — the caller's config is shared.
2434
+ function withSpecRemote(config, outPath) {
2435
+ const own = readRemoteAllow(outPath)
2436
+ if (own === null) return config
2437
+ return { ...config, review: { ...config.review, allowRemote: own } }
2438
+ }
2439
+
2440
+ // Default output path for a spec's page, under the gitignored `.spec-env/`.
2441
+ function reviewOutPath(dir, specFolder, out) {
2442
+ if (out) return path.resolve(dir, out)
2443
+ return path.join(dir, REVIEW_DIR, `${specFolder}.html`)
2444
+ }
2445
+
2446
+ /**
2447
+ * The page's path as a `file://` URL.
2448
+ *
2449
+ * A bare absolute path is not clickable in ANY terminal; `file://` is linkified
2450
+ * by iTerm2, VS Code's terminal and Terminal.app, so the page is one click from
2451
+ * the line that announces it. Printed ALONGSIDE the path, never instead of it —
2452
+ * the path is what you pass to another command, and the URL is what you click.
2453
+ *
2454
+ * Deliberately not an `open` call: that assumes a GUI, a default browser and
2455
+ * that you are sitting at the machine. The whole point of this page is that it
2456
+ * does not care where you are.
2457
+ */
2458
+ function reviewFileUrl(outPath) {
2459
+ // Encode each segment, then restore the separators — a path can legitimately
2460
+ // contain spaces, `#` or `?`, and all three break a URL left raw.
2461
+ const encoded = path
2462
+ .resolve(outPath)
2463
+ .split(path.sep)
2464
+ .map((seg) => encodeURIComponent(seg))
2465
+ .join('/')
2466
+ return `file://${encoded.startsWith('/') ? '' : '/'}${encoded}`
2467
+ }
2468
+
2469
+ // Write the page, creating its directory. Returns the absolute path written.
2470
+ function writeReviewPage(outPath, html) {
2471
+ fs.mkdirSync(path.dirname(outPath), { recursive: true })
2472
+ fs.writeFileSync(outPath, html)
2473
+ return outPath
2474
+ }
2475
+
2476
+ module.exports = {
2477
+ WHOLE_FILE_CONTEXT,
2478
+ NOTES_VERSION,
2479
+ VERDICTS,
2480
+ ACTIONS,
2481
+ tierAction,
2482
+ surfacesFor,
2483
+ reviewTierStack,
2484
+ reviewTierLine,
2485
+ COMMITTING,
2486
+ BUTTON_SETS,
2487
+ DEFAULT_BUTTON_SET,
2488
+ readVerdict,
2489
+ DEFAULT_VERDICT,
2490
+ DELETED_HASH,
2491
+ fileHashes,
2492
+ reviewNotesPath,
2493
+ emptyNotes,
2494
+ readNotes,
2495
+ writeNotes,
2496
+ validateNotesBlob,
2497
+ validateResolutions,
2498
+ judgeVerdict,
2499
+ appendDecision,
2500
+ annotateLastDecision,
2501
+ reviewPendingPath,
2502
+ PENDING_SUFFIX,
2503
+ emptyPending,
2504
+ readPending,
2505
+ writePending,
2506
+ mintPendingCode,
2507
+ addPending,
2508
+ claimPending,
2509
+ passesSince,
2510
+ waitingPasses,
2511
+ waitForPass,
2512
+ WAIT_HEARTBEAT_MS,
2513
+ WAIT_POLL_MS,
2514
+ passState,
2515
+ describePending,
2516
+ pendingAge,
2517
+ PENDING_CODE_LENGTH,
2518
+ GATE_VERSION,
2519
+ DISARMED_BY,
2520
+ reviewGatePath,
2521
+ emptyGate,
2522
+ readGate,
2523
+ writeGate,
2524
+ renderRecordPath,
2525
+ readRenderRecord,
2526
+ writeRenderRecord,
2527
+ armGate,
2528
+ disarmGate,
2529
+ gateState,
2530
+ markGateOffered,
2531
+ PERMITTED_ACTIONS,
2532
+ grantPermit,
2533
+ permitHonoured,
2534
+ GATE_BYPASS_OFFER,
2535
+ GATE_BYPASS_REASON,
2536
+ gateForPage,
2537
+ mergeNotes,
2538
+ applyResolutions,
2539
+ applyNotes,
2540
+ PATCH_LIMIT_BYTES,
2541
+ REVIEW_DIR,
2542
+ rawGitReader,
2543
+ isNoise,
2544
+ lines,
2545
+ parseNumstat,
2546
+ trackedFiles,
2547
+ untrackedFiles,
2548
+ collectReview,
2549
+ renderReviewPage,
2550
+ reviewOutPath,
2551
+ reviewFileUrl,
2552
+ writeReviewPage,
2553
+ escapeIsland,
2554
+ escapeHtml,
2555
+ renderReviewBlock,
2556
+ renderReviewerStrip,
2557
+ reviewerSaid,
2558
+ reviewUrlPath,
2559
+ reviewPublishPath,
2560
+ readReviewUrl,
2561
+ reviewRemotePath,
2562
+ readRemoteAllow,
2563
+ writeRemoteAllow,
2564
+ withSpecRemote,
2565
+ publishedPageNotice,
2566
+ reviewServerNotice,
2567
+ fragmentTemplate,
2568
+ renderReviewFragment,
2569
+ detectReader,
2570
+ resolveReader,
2571
+ CHECK_LEVELS,
2572
+ loadTemplate,
2573
+ TEMPLATE_PATH,
2574
+ DATA_PLACEHOLDER,
2575
+ REVIEW_PLACEHOLDER,
2576
+ TITLE_PLACEHOLDER,
2577
+ }