@skitterbyte/skitterspec-jira 0.0.0-stage → 2.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 (113) hide show
  1. package/MIGRATION.md +1266 -0
  2. package/README.md +44 -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 +606 -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 +88 -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 +702 -0
  23. package/assets/rules/spec-reports.md +572 -0
  24. package/assets/skills/no-spec/SKILL.md +249 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +585 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +451 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +607 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +221 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +678 -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 +114 -0
  40. package/assets/skills/spec-sync/SKILL.md +220 -0
  41. package/assets/skills/spec-to-main/SKILL.md +141 -0
  42. package/bin/skitterspec-jira.js +68 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5967 -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 +850 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/glob.js +34 -0
  53. package/src/env/hooks.js +226 -0
  54. package/src/env/hotfix.js +413 -0
  55. package/src/env/hotfixland.js +302 -0
  56. package/src/env/integrate.js +167 -0
  57. package/src/env/live.js +479 -0
  58. package/src/env/mainguard.js +299 -0
  59. package/src/env/provision.js +466 -0
  60. package/src/env/proxy.js +225 -0
  61. package/src/env/prune.js +119 -0
  62. package/src/env/registry.js +159 -0
  63. package/src/env/render.js +17 -0
  64. package/src/env/resolve.js +716 -0
  65. package/src/env/review.js +2622 -0
  66. package/src/env/reviewers/coderabbit.js +228 -0
  67. package/src/env/reviewers.js +517 -0
  68. package/src/env/serve.js +1044 -0
  69. package/src/env/supervise.js +157 -0
  70. package/src/env/teardown.js +248 -0
  71. package/src/env/trust.js +87 -0
  72. package/src/gating.js +155 -0
  73. package/src/init.js +1150 -0
  74. package/src/lines-diff.js +114 -0
  75. package/src/prompts.js +129 -0
  76. package/src/vendor/jira/adf.js +414 -0
  77. package/src/vendor/jira/api.js +295 -0
  78. package/src/vendor/jira/cli-sync.js +1252 -0
  79. package/src/vendor/jira/commands.js +44 -0
  80. package/src/vendor/jira/config.js +286 -0
  81. package/src/vendor/jira/credentials.js +108 -0
  82. package/src/vendor/jira/dialect.js +45 -0
  83. package/src/vendor/jira/mcp.js +129 -0
  84. package/src/vendor/provider-kit/index.js +41 -0
  85. package/src/vendor/provider-kit/src/adapter.js +23 -0
  86. package/src/vendor/provider-kit/src/apply.js +207 -0
  87. package/src/vendor/provider-kit/src/args.js +68 -0
  88. package/src/vendor/provider-kit/src/assignment.js +277 -0
  89. package/src/vendor/provider-kit/src/config.js +251 -0
  90. package/src/vendor/provider-kit/src/credentials.js +404 -0
  91. package/src/vendor/provider-kit/src/doctor.js +35 -0
  92. package/src/vendor/provider-kit/src/http.js +63 -0
  93. package/src/vendor/provider-kit/src/identity.js +93 -0
  94. package/src/vendor/provider-kit/src/list-assignee.js +71 -0
  95. package/src/vendor/provider-kit/src/list-rows.js +35 -0
  96. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  97. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  98. package/src/vendor/provider-kit/src/prompt.js +50 -0
  99. package/src/vendor/provider-kit/src/released.js +219 -0
  100. package/src/vendor/provider-kit/src/specs.js +248 -0
  101. package/src/vendor/provider-kit/src/transport.js +22 -0
  102. package/src/vendor/sync-core/index.js +92 -0
  103. package/src/vendor/sync-core/src/base.js +81 -0
  104. package/src/vendor/sync-core/src/compare.js +338 -0
  105. package/src/vendor/sync-core/src/dialect.js +51 -0
  106. package/src/vendor/sync-core/src/fields.js +36 -0
  107. package/src/vendor/sync-core/src/legacy.js +100 -0
  108. package/src/vendor/sync-core/src/normalize.js +971 -0
  109. package/src/vendor/sync-core/src/push.js +85 -0
  110. package/src/vendor/sync-core/src/retarget.js +278 -0
  111. package/src/vendor/sync-core/src/task-block.js +308 -0
  112. package/src/vendor/sync-core/src/verify.js +67 -0
  113. package/src/vendor/sync-core/src/write.js +191 -0
@@ -0,0 +1,2622 @@
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
+ // LABELLED HERE, not by the renderer: `--json`'s `tiers` is the stack a
719
+ // skill copies, and an unlabelled alternate made the `(tailnet)` suffix a
720
+ // fact only the text render knew — so the one link a reader off the wifi
721
+ // could open reached the JSON indistinguishable from a second LAN address.
722
+ tiers.push({
723
+ tier: 'network',
724
+ url: served.url,
725
+ alternates: (served.alternates || []).map((u) => {
726
+ let host = ''
727
+ try {
728
+ host = new URL(u).hostname
729
+ } catch {}
730
+ return isTailnetAddress(host) ? { url: u, label: 'tailnet' } : { url: u }
731
+ }),
732
+ })
733
+ } else {
734
+ // Permitted, but there is no address to offer — a machine with no network,
735
+ // or a server that could not take one. Not a problem to report as an error.
736
+ tiers.push({ tier: 'network', unavailable: true, note: 'no network address on this machine' })
737
+ }
738
+
739
+ if (!config.review.allowRemote) {
740
+ // THE COMMAND A PERSON TYPES. `/spec-remote-review` toggles, so the same
741
+ // line works whichever way the tier currently is — and nobody has to
742
+ // reconstruct `spec-env review allow remote` from a render they are reading
743
+ // on a phone.
744
+ tiers.push({ tier: 'remote', off: true, enable: '/spec-remote-review' })
745
+ } else if (publishedUrl) {
746
+ tiers.push({ tier: 'remote', url: publishedUrl, note: 'a verdict here needs /spec-reviewed' })
747
+ } else {
748
+ tiers.push({ tier: 'remote', unavailable: true, note: 'publishing is an ask — nothing published yet' })
749
+ }
750
+
751
+ return tiers
752
+ }
753
+
754
+ // One tier as a line, padded so the labels form a column the eye can run down.
755
+ // A tier with alternates renders them as indented `also:` lines of its own —
756
+ // they are PART of the tier, which is what puts them under `network:` rather
757
+ // than dangling after the stack (they used to print below `remote:`, against
758
+ // the comment that placed them).
759
+ function reviewTierLine(t) {
760
+ const label = ` ${t.tier}:`.padEnd(11)
761
+ if (t.url) {
762
+ return [
763
+ `${label}${t.url}${t.note ? ` (${t.note})` : ''}`,
764
+ ...(t.alternates || []).map((a) => ` also: ${a.url}${a.label ? ` (${a.label})` : ''}`),
765
+ ].join('\n')
766
+ }
767
+ if (t.off) return `${label}off — turn on with: ${t.enable}`
768
+ return `${label}— (${t.note})`
769
+ }
770
+
771
+ // 100.64.0.0/10 — the CGNAT range Tailscale assigns from.
772
+ //
773
+ // WHAT WOULD FOOL THIS: a carrier-grade NAT or another overlay network using
774
+ // the same range on a utun adapter is offered too. That costs one dud `also:`
775
+ // line, where filtering it hides the only link a reader off the wifi can open.
776
+ function isTailnetAddress(address) {
777
+ const m = /^100\.(\d+)\./.exec(address)
778
+ return !!m && Number(m[1]) >= 64 && Number(m[1]) <= 127
779
+ }
780
+
781
+ // The suffix on an `also:` line whose host is a tailnet address, so it reads
782
+ // apart from the LAN one — a reader off the wifi needs to know which to tap.
783
+ function tailnetLabel(url) {
784
+ let host = ''
785
+ try {
786
+ host = new URL(url).hostname
787
+ } catch {}
788
+ return isTailnetAddress(host) ? ' (tailnet)' : ''
789
+ }
790
+
791
+
792
+ // The verdicts that COMMIT, and are therefore blocked by an open comment. One
793
+ // list, so a fifth verdict cannot become a way around the single refusal this
794
+ // engine makes — adding a committing verdict means adding it here, and the
795
+ // block follows for free.
796
+ //
797
+ // `continue` IS DELIBERATELY NOT HERE, and that omission is the whole of
798
+ // decision 2: waiting is what any offer does, while arming asserts an
799
+ // obligation that outlives the turn. A mid-run reader saying "carry on" has
800
+ // answered the offer in front of them and nothing else, so the gate a finished
801
+ // phase armed must survive it untouched.
802
+ const COMMITTING = ['commit', 'commit-continue', 'commit-start', 'commit-land']
803
+
804
+ /**
805
+ * Which set of buttons a rendered page shows.
806
+ *
807
+ * DECLARED BY THE CALLER, NEVER DERIVED FROM THE GATE. Deriving it — mid-run
808
+ * iff the gate is unarmed — is tidier and wrong: a project running
809
+ * `review.required: false` never arms at all, so every one of its pages would
810
+ * lose the committing buttons and the reader could never commit from the page.
811
+ * The caller knows whether the work it just rendered is finished; the gate only
812
+ * knows whether this project opted into gating.
813
+ *
814
+ * `committing` is the default, so a caller that says nothing keeps today's page
815
+ * exactly — the key is left off the payload entirely rather than written out as
816
+ * the default, so an unchanged caller renders an unchanged page.
817
+ *
818
+ * `authoring` is the set for a spec that has just been written: its committing
819
+ * pair is `commit-start` and `commit` — put it in flight now, or keep it for
820
+ * later. `commit-continue` is absent because there is no phase in flight to
821
+ * continue, and `continue` is absent because the run has nothing left to resume.
822
+ *
823
+ * `refresh` is the set for a spec that was re-validated rather than written:
824
+ * `commit`, `changes`, `discuss`, and NO start verdict. A refreshed spec may
825
+ * already be in progress, so offering to put it in flight would be wrong for
826
+ * half this set's inputs — and wrong in the expensive direction, since it would
827
+ * offer to provision a worktree for a spec that already has one.
828
+ *
829
+ * `nospec` is the set for work with no spec document: its committing pair is
830
+ * `commit-land` and `commit` — finish it now, or commit and leave the branch
831
+ * standing. `commit-continue` is absent because there is no next phase, and
832
+ * `commit-start` because there is nothing to put in flight.
833
+ *
834
+ * REUSING `refresh` HERE WAS THE OBVIOUS MOVE AND IT IS WRONG. The three
835
+ * remaining buttons are identical, so the temptation is to let `/no-spec` take
836
+ * that set and treat plain `commit` as "commit, land, tear down". That makes one
837
+ * verdict word mean two different things depending on which skill rendered the
838
+ * page — precisely what having separate words for `commit-start` and
839
+ * `commit-continue` exists to avoid, and worse here, because one of the two
840
+ * readings destroys a worktree.
841
+ *
842
+ * `fix` is the set for a SINGLE-PASS fix — a bug or hotfix spec whose whole
843
+ * repair is one `## Fix` list and no phase files. It is `committing` minus
844
+ * `commit-continue`, because there is no next phase for `/spec-next` to build:
845
+ * the page used to offer that verb anyway, the press committed correctly, and
846
+ * `/spec-next` then refused with nothing to do.
847
+ *
848
+ * THE PAGE ALREADY HAS A GUARD FOR THIS AND IT CANNOT SEE THESE SPECS. It dims
849
+ * the button on `data.phases.hasNextPhase === false`, and `readPhases` returns
850
+ * `null` for a folder with no phase files — deliberately, because that is also
851
+ * a legacy layout whose phases live inline in the overview. So the caller says
852
+ * it instead, which is what the `buttons` parameter is for.
853
+ *
854
+ * REUSING `refresh` WAS THE OBVIOUS MOVE HERE TOO, and is wrong for the reason
855
+ * above one line up rather than the one above the paragraph: the three buttons
856
+ * are identical, but `refresh` means a re-validated spec DOCUMENT and this is a
857
+ * render of code. The word is stored in the render record and read back by the
858
+ * serve daemon, so one name covering two renders would be ambiguous exactly
859
+ * where it is used to decide something.
860
+ */
861
+ const BUTTON_SETS = ['committing', 'midrun', 'authoring', 'refresh', 'nospec', 'fix']
862
+ const DEFAULT_BUTTON_SET = 'committing'
863
+
864
+ /**
865
+ * What an older sidecar's `approve` means now. Pure.
866
+ *
867
+ * TOLERANCE, NOT MIGRATION. These files are gitignored, so there is no fleet to
868
+ * migrate and no script anyone would remember to run — the rename is absorbed
869
+ * at the point of reading, where it cannot be skipped. Anything else is passed
870
+ * through untouched, including a value this engine does not know: `readVerdict`
871
+ * answers what a stored word means, and refusing an unknown one is
872
+ * `validateNotesBlob`'s job, not this function's.
873
+ */
874
+ function readVerdict(stored) {
875
+ return stored === 'approve' ? 'commit' : stored
876
+ }
877
+
878
+ // A file that is gone has no content to hash, and an accept still has to mean
879
+ // something about it. A sentinel is comparable and obviously not a blob sha.
880
+ const DELETED_HASH = '(deleted)'
881
+
882
+ // `hash-object` takes every path on one command line, so chunk it rather than
883
+ // discovering ARG_MAX on the one spec that touched 400 files.
884
+ const HASH_BATCH = 100
885
+
886
+ /**
887
+ * The git blob hash of each file's CURRENT working-tree content.
888
+ *
889
+ * THE IDENTITY AN ACCEPT IS KEYED TO, and deliberately not the patch. A patch
890
+ * is a function of the ref as much as the file: commit the phase and `HEAD`
891
+ * moves, so every patch changes while no file did — and every accept would
892
+ * lapse at the exact moment the work was finished. `--branch` does the same in
893
+ * reverse. Content keying survives both, because it describes the thing you
894
+ * actually read.
895
+ *
896
+ * A hash that cannot be computed is `null`, never a guess: it compares unequal
897
+ * to every recorded accept, so the cannot-tell case renders as lapsed rather
898
+ * than as approval (see `.claude/rules/negative-checks.md`).
899
+ */
900
+ function fileHashes(git, files) {
901
+ const out = new Map()
902
+ const live = []
903
+ for (const f of files) {
904
+ if (f.status === 'deleted') out.set(f.path, DELETED_HASH)
905
+ else live.push(f.path)
906
+ }
907
+ for (let i = 0; i < live.length; i += HASH_BATCH) {
908
+ const batch = live.slice(i, i + HASH_BATCH)
909
+ const got = lines(git(['hash-object', '--', ...batch]))
910
+ if (got.length === batch.length) {
911
+ batch.forEach((p, n) => out.set(p, got[n]))
912
+ continue
913
+ }
914
+ // One unhashable path fails the whole batch, so fall back per file rather
915
+ // than losing every other file's hash with it.
916
+ for (const p of batch) out.set(p, lines(git(['hash-object', '--', p]))[0] || null)
917
+ }
918
+ return out
919
+ }
920
+
921
+ // The sidecar sits beside the page and the `.url` file, under gitignored
922
+ // `.spec-env/` — a review leaves no trace in the branch it reviews.
923
+ function reviewNotesPath(outPath) {
924
+ return outPath.replace(/\.html$/, '') + '.notes.json'
925
+ }
926
+
927
+ function emptyNotes(specFolder) {
928
+ return { version: NOTES_VERSION, spec: specFolder, updatedAt: null, files: {}, comments: [] }
929
+ }
930
+
931
+ /**
932
+ * Read the sidecar. Never throws, and reports `corrupt` rather than hiding it.
933
+ *
934
+ * A file we cannot parse is the third state: it is not "no notes". Rendering
935
+ * carries on without them (the page is a convenience), but a MERGE must refuse,
936
+ * because writing over an unreadable file is how someone's whole review pass
937
+ * disappears. The caller decides which of those it is.
938
+ */
939
+ function readNotes(outPath, specFolder) {
940
+ let raw
941
+ try {
942
+ raw = fs.readFileSync(reviewNotesPath(outPath), 'utf8')
943
+ } catch {
944
+ // Absent is the ordinary state — most reviews never write one.
945
+ return { notes: emptyNotes(specFolder), corrupt: false, present: false }
946
+ }
947
+ try {
948
+ const parsed = JSON.parse(raw)
949
+ return {
950
+ notes: {
951
+ version: parsed.version,
952
+ spec: parsed.spec || specFolder,
953
+ updatedAt: parsed.updatedAt || null,
954
+ files: parsed.files && typeof parsed.files === 'object' ? parsed.files : {},
955
+ comments: Array.isArray(parsed.comments) ? parsed.comments : [],
956
+ // Absent stays absent. A sidecar written before verdicts existed, or by
957
+ // a review that never reached one, must not gain an empty `decisions`
958
+ // key just by being read — the round-trip has to be byte-stable for
959
+ // everyone who is not using this.
960
+ //
961
+ // A logged `approve` is read as `commit` HERE, at the read, rather than
962
+ // by a migration nobody would run: these files are gitignored, so the
963
+ // rename has to be absorbed where it cannot be skipped.
964
+ ...(Array.isArray(parsed.decisions)
965
+ ? { decisions: parsed.decisions.map((d) => ({ ...d, verdict: readVerdict(d.verdict) })) }
966
+ : {}),
967
+ },
968
+ corrupt: false,
969
+ present: true,
970
+ }
971
+ } catch {
972
+ return { notes: emptyNotes(specFolder), corrupt: true, present: true }
973
+ }
974
+ }
975
+
976
+ function writeNotes(outPath, notes) {
977
+ const p = reviewNotesPath(outPath)
978
+ fs.mkdirSync(path.dirname(p), { recursive: true })
979
+ fs.writeFileSync(p, JSON.stringify(notes, null, 2) + '\n')
980
+ return p
981
+ }
982
+
983
+ /* ==========================================================================
984
+ * Pending passes — a review that arrived over the wire
985
+ *
986
+ * A page that is SERVED can hand its pass straight back rather than going
987
+ * through a clipboard and a chat window. That is a write path reachable by
988
+ * anyone who can reach the page, so what it writes is deliberately not the
989
+ * sidecar: a POST lands here, in a HOLDING AREA, and nothing reaches the
990
+ * review until a person reads its code out. The code is what makes a stranger's
991
+ * pass inert and says WHICH pass when several are in flight.
992
+ *
993
+ * On disk rather than in the server's memory, and that is a constraint from
994
+ * `feat-review-serve-version` rather than a preference: that feature restarts a
995
+ * stale server automatically, and a restart that dropped the pass you just sent
996
+ * would be a new way to lose work. It also means `--claim` needs no running
997
+ * server at all.
998
+ * ========================================================================== */
999
+
1000
+ const PENDING_VERSION = 1
1001
+
1002
+ // Beside the page and the notes sidecar, under gitignored `.spec-env/`. The
1003
+ // suffix is named once so the cross-spec scan recognises a store by the same
1004
+ // spelling that writes one.
1005
+ const PENDING_SUFFIX = '.pending.json'
1006
+
1007
+ function reviewPendingPath(outPath) {
1008
+ return outPath.replace(/\.html$/, '') + PENDING_SUFFIX
1009
+ }
1010
+
1011
+ function emptyPending(specFolder) {
1012
+ return { version: PENDING_VERSION, spec: specFolder, passes: [] }
1013
+ }
1014
+
1015
+ /**
1016
+ * Read the holding area. Never throws; reports `corrupt` rather than hiding it.
1017
+ *
1018
+ * Same three states as `readNotes`, for the same reason: a file we cannot parse
1019
+ * is not "no pending passes", and claiming against it must refuse rather than
1020
+ * silently find nothing.
1021
+ */
1022
+ function readPending(outPath, specFolder) {
1023
+ let raw
1024
+ try {
1025
+ raw = fs.readFileSync(reviewPendingPath(outPath), 'utf8')
1026
+ } catch {
1027
+ return { pending: emptyPending(specFolder), corrupt: false, present: false }
1028
+ }
1029
+ try {
1030
+ const parsed = JSON.parse(raw)
1031
+ return {
1032
+ pending: {
1033
+ version: parsed.version || PENDING_VERSION,
1034
+ spec: parsed.spec || specFolder,
1035
+ passes: Array.isArray(parsed.passes) ? parsed.passes : [],
1036
+ },
1037
+ corrupt: false,
1038
+ present: true,
1039
+ }
1040
+ } catch {
1041
+ return { pending: emptyPending(specFolder), corrupt: true, present: true }
1042
+ }
1043
+ }
1044
+
1045
+ function writePending(outPath, pending) {
1046
+ const p = reviewPendingPath(outPath)
1047
+ fs.mkdirSync(path.dirname(p), { recursive: true })
1048
+ fs.writeFileSync(p, JSON.stringify(pending, null, 2) + '\n')
1049
+ return p
1050
+ }
1051
+
1052
+ const PENDING_CODE_LENGTH = 6
1053
+
1054
+ /**
1055
+ * A code for one pending pass. Unique among those currently pending.
1056
+ *
1057
+ * NOT A SECRET, and it does not need to be: it is printed on the page by
1058
+ * design, and what it authorises is a person reading it out. What it must not
1059
+ * do is COLLIDE — two pending passes sharing a code is how the wrong one gets
1060
+ * applied — so it is drawn against the set rather than drawn and hoped for.
1061
+ *
1062
+ * WHAT WOULD FOOL THIS: nothing about uniqueness, but note it is drawn from the
1063
+ * passes it is GIVEN. A caller that mints against a stale read could still
1064
+ * collide, which is why the mint and the write happen together in `addPending`.
1065
+ */
1066
+ function mintPendingCode(pending, random = () => crypto.randomInt(0, 10 ** PENDING_CODE_LENGTH)) {
1067
+ const taken = new Set((pending.passes || []).map((p) => p.code))
1068
+ // Bounded rather than `while (true)`: with a million codes and a handful
1069
+ // pending this cannot realistically spin, and a bound means a bug here fails
1070
+ // loudly instead of hanging the server.
1071
+ for (let i = 0; i < 1000; i++) {
1072
+ const code = String(random()).padStart(PENDING_CODE_LENGTH, '0')
1073
+ if (!taken.has(code)) return code
1074
+ }
1075
+ throw new Error('could not mint a unique pending code')
1076
+ }
1077
+
1078
+ /**
1079
+ * Add a pass to the holding area, returning the new store and its code. Pure.
1080
+ *
1081
+ * SUPERSEDES WITHIN A RENDER. A second pass sent from the same page render
1082
+ * replaces the first unclaimed one from that render, so the code on the screen
1083
+ * is always the pass on the screen — press Approve, change your mind, press
1084
+ * Request changes, and there is one pass waiting, not two. Passes from a
1085
+ * DIFFERENT render stand alongside it: those are two people, or two sittings,
1086
+ * and neither supersedes the other.
1087
+ */
1088
+ function addPending(pending, { blob, at, render }, mint = mintPendingCode) {
1089
+ const kept = (pending.passes || []).filter((p) => p.render !== render)
1090
+ const next = { ...pending, passes: kept }
1091
+ const code = mint(next)
1092
+ next.passes = [...kept, { code, at, render, blob }]
1093
+ return { pending: next, code }
1094
+ }
1095
+
1096
+ /**
1097
+ * What became of ONE pass, by its code. Reads; writes nothing, refuses nothing.
1098
+ *
1099
+ * The served page POSTs, is handed six digits, and then has to end on one of
1100
+ * two sentences — Claude picked this up, or here is the command that picks it
1101
+ * up. It cannot know which at send time, so it asks, and this answers.
1102
+ *
1103
+ * Three states, and the third is the point:
1104
+ *
1105
+ * - `waiting` — the code IS in the holding area. Nobody has claimed it.
1106
+ * - `claimed` — the decision log NAMES the code. Somebody did.
1107
+ * - `unknown` — neither could be established.
1108
+ *
1109
+ * BOTH ANSWERS REST ON SOMETHING PRESENT, which is what stops this being the
1110
+ * fourth row in the table at the top of `.claude/rules/negative-checks.md`. The
1111
+ * tempting cheap version reads `claimed` off the code being ABSENT from the
1112
+ * holding area — and absence there has four causes, of which exactly one is a
1113
+ * claim: `--drop` removes a pass and writes no decision, a store written under
1114
+ * a different `--out` is a store this never opens, and a mistyped folder finds
1115
+ * an empty one. Three of those four would report a pass as picked up while it
1116
+ * sits in a file nobody is reading, which is this spec's own bug told back to
1117
+ * the reader with confidence.
1118
+ *
1119
+ * So `unknown` answers everything that is not a positive signal, and the caller
1120
+ * turns it back into the command. That direction is deliberate: a command
1121
+ * nobody needed to run costs a glance, and a pass nobody claims costs the
1122
+ * review.
1123
+ *
1124
+ * A code that is not six digits never reaches disk. It cannot be one this
1125
+ * engine minted, so there is nothing to look up, and answering it from the
1126
+ * filesystem would make a reachable endpoint into a path-shaped probe.
1127
+ */
1128
+ function passState(outPath, specFolder, code) {
1129
+ const wanted = typeof code === 'string' ? code.trim() : ''
1130
+ if (!new RegExp(`^\\d{${PENDING_CODE_LENGTH}}$`).test(wanted)) return { state: 'unknown' }
1131
+
1132
+ const held = readPending(outPath, specFolder)
1133
+ // A store we could not parse is not an empty one. It holds somebody's passes
1134
+ // and we simply cannot see them, so neither answer is available.
1135
+ if (held.corrupt) return { state: 'unknown' }
1136
+ if ((held.pending.passes || []).some((p) => p && p.code === wanted)) return { state: 'waiting' }
1137
+
1138
+ const stored = readNotes(outPath, specFolder)
1139
+ if (stored.corrupt) return { state: 'unknown' }
1140
+ const decisions = Array.isArray(stored.notes.decisions) ? stored.notes.decisions : []
1141
+ if (decisions.some((d) => d && d.code === wanted)) return { state: 'claimed' }
1142
+
1143
+ // Gone from the holding area and unnamed in the log. It may have been
1144
+ // dropped, it may predate this field, the store may not be the one the page
1145
+ // was served from. Say so.
1146
+ return { state: 'unknown' }
1147
+ }
1148
+
1149
+ /**
1150
+ * What is waiting, as the render should describe it. Pure.
1151
+ *
1152
+ * THE BLOB IS DELIBERATELY NOT HERE. A decision about a waiting pass needs its
1153
+ * code, its verdict and its age; the notes are what a CLAIM is for. Returning
1154
+ * them would put an unclaimed stranger's text into the context of whoever is
1155
+ * reading the render — which is the one thing the holding area exists to defer.
1156
+ *
1157
+ * Oldest first, and stable: two renders in a row must name the passes in the
1158
+ * same order, or a reader cannot trust the list they just read against the one
1159
+ * they are about to be offered. Ties break on the code, which is unique among
1160
+ * pending, so the order is total rather than merely usually-stable.
1161
+ */
1162
+ function describePending(pending) {
1163
+ return (pending.passes || [])
1164
+ .map((p) => ({
1165
+ code: p.code,
1166
+ // Read through the rename too: a pass POSTed by an older page says
1167
+ // `approve`, and the operator must be offered the word that describes
1168
+ // what claiming it would do.
1169
+ verdict: readVerdict((p.blob && p.blob.verdict) || null) || null,
1170
+ // AN ACTION IS REPORTED AS AN ACTION. A pass carrying one has no verdict,
1171
+ // and listing it as `no verdict` would describe the instruction it does
1172
+ // carry as an absence — which is the reading that makes a reader claim it
1173
+ // expecting a conclusion.
1174
+ action: (p.blob && p.blob.action) || null,
1175
+ at: p.at || null,
1176
+ }))
1177
+ .sort((a, b) => String(a.at).localeCompare(String(b.at)) || a.code.localeCompare(b.code))
1178
+ }
1179
+
1180
+ /**
1181
+ * How long ago, in words a person reads at a glance. Pure.
1182
+ *
1183
+ * AGE IS THE TELL. A pass sent three days ago is not a review anyone in this
1184
+ * conversation just pressed, and that is exactly how a stranger's pass gives
1185
+ * itself away — so it is reported beside the code rather than left in a
1186
+ * timestamp nobody parses.
1187
+ *
1188
+ * Unknown stays unknown: a pass with no `at` says so rather than being rendered
1189
+ * as "just now", which is the reading that would make it look like yours.
1190
+ */
1191
+ function pendingAge(at, now) {
1192
+ const then = Date.parse(at)
1193
+ const ms = Date.parse(now) - then
1194
+ if (!Number.isFinite(then) || !Number.isFinite(ms) || ms < 0) return 'unknown age'
1195
+ const mins = Math.floor(ms / 60000)
1196
+ if (mins < 1) return 'just now'
1197
+ if (mins < 60) return `${mins} min ago`
1198
+ const hours = Math.floor(mins / 60)
1199
+ if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`
1200
+ const days = Math.floor(hours / 24)
1201
+ return `${days} day${days === 1 ? '' : 's'} ago`
1202
+ }
1203
+
1204
+ /**
1205
+ * Take a pass out of the holding area by its code. Pure.
1206
+ *
1207
+ * THE ONE REFUSAL HERE, and it never falls back. A code that matches nothing
1208
+ * returns no pass — not "the only one", not "the most recent". Both of those
1209
+ * are the same mistake: they would let a pass nobody read out reach the review,
1210
+ * which is the entire thing the code exists to prevent
1211
+ * (`.claude/rules/negative-checks.md` rule 4 — the unknown case does nothing).
1212
+ *
1213
+ * Claiming CONSUMES: the entry is gone from the returned store, so the same
1214
+ * code cannot be claimed twice and an old code cannot resurrect an old pass.
1215
+ */
1216
+ function claimPending(pending, code) {
1217
+ const passes = pending.passes || []
1218
+ const at = passes.findIndex((p) => p.code === code)
1219
+ if (at === -1) return { pass: null, pending, count: passes.length }
1220
+ const pass = passes[at]
1221
+ const rest = passes.slice(0, at).concat(passes.slice(at + 1))
1222
+ return { pass, pending: { ...pending, passes: rest }, count: rest.length }
1223
+ }
1224
+
1225
+ /**
1226
+ * Which pass arrived inside a WAIT WINDOW. Pure — it selects, never claims.
1227
+ *
1228
+ * THE WINDOW IS THE SCOPE, and it is what replaces "a person typed the
1229
+ * command" when a watch wakes the session instead. A skill that asked to be
1230
+ * woken when the store changed knows one thing nothing else does: the moment
1231
+ * it started waiting. A pass sent after that, while it was waiting, is the
1232
+ * pass it was waiting for — and a pass that was already there is a stranger's,
1233
+ * or an older sitting's, and must not be swept up by a wait that was not about
1234
+ * it.
1235
+ *
1236
+ * Three answers, and only one of them acts:
1237
+ *
1238
+ * - exactly one in the window → that is the pass, named by its code.
1239
+ * - none → nothing to claim. Ordinary: the watch can fire on a write that was
1240
+ * not a pass at all.
1241
+ * - more than one → REFUSE and name the count, never pick. Two people, or two
1242
+ * sittings, landed in one window; choosing between them is exactly the guess
1243
+ * `claimPending` refuses to make, and the operator has the codes.
1244
+ *
1245
+ * A pass whose `at` cannot be parsed is never in the window. That is the
1246
+ * cannot-tell case routed to inaction (`.claude/rules/negative-checks.md` rule
1247
+ * 4): including it would auto-claim a pass whose age — the one tell a stranger
1248
+ * has — could not be established.
1249
+ */
1250
+ function passesSince(pending, since) {
1251
+ const from = Date.parse(since)
1252
+ if (!Number.isFinite(from)) return { codes: [], usable: false }
1253
+ const codes = (pending.passes || [])
1254
+ .filter((p) => {
1255
+ const at = Date.parse(p.at)
1256
+ return Number.isFinite(at) && at >= from
1257
+ })
1258
+ .map((p) => p.code)
1259
+ return { codes, usable: true }
1260
+ }
1261
+
1262
+ /* ==========================================================================
1263
+ * The wait — one implementation, because improvised ones cannot be tested
1264
+ *
1265
+ * The skills used to say "watch the pending store and end your turn" and stop
1266
+ * there, so every run wrote its own watcher in shell. Three failed in two days,
1267
+ * each reaching the operator as "I pressed the button and nothing happened" —
1268
+ * and the worst of them was `until [ -f "$P" ] && [ "$x" \\> "$y" ]`, valid bash
1269
+ * and a syntax error in zsh, which spun for five minutes writing to a stderr
1270
+ * nobody reads.
1271
+ *
1272
+ * What made that expensive was not the typo. It is that SILENCE WAS THE SUCCESS
1273
+ * SIGNAL: from outside, a watcher that can never fire and one patiently working
1274
+ * look exactly alike. So the comparison lives here, in one place, where a test
1275
+ * can hand it a store that gains a pass mid-flight and watch it return.
1276
+ * ========================================================================== */
1277
+
1278
+ const WAIT_POLL_MS = 400
1279
+
1280
+ // How often a running wait says it is still running. Five minutes, against a
1281
+ // 400ms poll: this is not part of the poll at all, it is proof of life for
1282
+ // whatever is supervising the process.
1283
+ const WAIT_HEARTBEAT_MS = 5 * 60 * 1000
1284
+
1285
+ /**
1286
+ * Block until exactly one pass arrives inside a window. Impure only in that it
1287
+ * reads the store and sleeps; it writes nothing and claims nothing.
1288
+ *
1289
+ * Four outcomes, and they are distinct because the caller acts differently on
1290
+ * each:
1291
+ *
1292
+ * - `{ state: 'arrived', code }` — one pass, inside the window. Claim it.
1293
+ * - `{ state: 'ambiguous', count }` — more than one. NAMES NO CODE, because
1294
+ * choosing between two is exactly the guess `claimPending` refuses to make.
1295
+ * - `{ state: 'timeout' }` — only when a timeout was asked for.
1296
+ * - `{ state: 'unusable' }` — the window could not be parsed, so there is
1297
+ * nothing to wait inside. Returns AT ONCE rather than blocking forever on a
1298
+ * comparison that can never be satisfied — which is the failure this whole
1299
+ * function exists to stop.
1300
+ *
1301
+ * NO TIMEOUT UNLESS ASKED. `timeoutMs` omitted means wait as long as the
1302
+ * process lives, and that is the default the skills use: any fixed number is a
1303
+ * guess about how long someone reads, and a reader who walks away from a diff
1304
+ * is the normal case rather than the edge one. An hour was picked once and a
1305
+ * lunch break beat it.
1306
+ *
1307
+ * IT SAYS IT IS STILL RUNNING, on `onHeartbeat`. A backgrounded wait produced
1308
+ * no output between starting and finding a pass, and over a long idle it was
1309
+ * killed — three sessions lost their wait over one lunch break. Whether the
1310
+ * trigger is process silence or session idleness was never established, so the
1311
+ * cheap half is done here: the process stops being silent. The other half is
1312
+ * the caller re-arming, which covers it either way.
1313
+ *
1314
+ * The cadence is INDEPENDENT of the poll. Polling stays at 400ms because that
1315
+ * is how fast a press should be noticed; the heartbeat is minutes apart because
1316
+ * it is addressed to a supervisor, not to a reader.
1317
+ *
1318
+ * WHAT WOULD FOOL A LOOSER VERSION: treating an absent, empty, or older-only
1319
+ * store as a reason to return. None of those is a pass — they are the ordinary
1320
+ * state of a review nobody has answered yet — and a wait that ended on one
1321
+ * would report "no pass" as an outcome (`.claude/rules/negative-checks.md`
1322
+ * rule 3 has a test for each).
1323
+ */
1324
+ async function waitForPass(
1325
+ readStore,
1326
+ since,
1327
+ {
1328
+ timeoutMs = null,
1329
+ pollMs = WAIT_POLL_MS,
1330
+ sleep,
1331
+ onHeartbeat = null,
1332
+ heartbeatMs = WAIT_HEARTBEAT_MS,
1333
+ now = () => Date.now(),
1334
+ } = {},
1335
+ ) {
1336
+ if (!Number.isFinite(Date.parse(since))) return { state: 'unusable' }
1337
+ const nap = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
1338
+ const startedAt = now()
1339
+ const deadline = Number.isFinite(timeoutMs) && timeoutMs !== null ? startedAt + timeoutMs : null
1340
+ // Counted from the start rather than scheduled, so a slow poll cannot make
1341
+ // the heartbeat drift and a fast one cannot make it fire twice.
1342
+ let beats = 0
1343
+
1344
+ for (;;) {
1345
+ // A store that will not parse is not an empty one, and it is not a pass
1346
+ // either — keep waiting rather than reading someone's unreadable passes as
1347
+ // an answer. `readPending` already reports `corrupt` rather than throwing.
1348
+ const read = readStore()
1349
+ if (read && !read.corrupt) {
1350
+ const window = passesSince(read.pending, since)
1351
+ if (window.usable) {
1352
+ if (window.codes.length === 1) return { state: 'arrived', code: window.codes[0] }
1353
+ if (window.codes.length > 1) return { state: 'ambiguous', count: window.codes.length }
1354
+ }
1355
+ }
1356
+ if (deadline !== null && now() >= deadline) return { state: 'timeout' }
1357
+
1358
+ // SAID ON THE WAY ROUND, never on the way out. A line printed when the wait
1359
+ // ends would only ever appear for the waits that already worked, which is
1360
+ // the half that never needed proof.
1361
+ if (onHeartbeat && heartbeatMs > 0) {
1362
+ const elapsed = now() - startedAt
1363
+ const due = Math.floor(elapsed / heartbeatMs)
1364
+ if (due > beats) {
1365
+ beats = due
1366
+ onHeartbeat({ elapsedMs: elapsed, since })
1367
+ }
1368
+ }
1369
+
1370
+ // Never overshoot the deadline by a whole poll interval.
1371
+ await nap(deadline === null ? pollMs : Math.max(0, Math.min(pollMs, deadline - now())))
1372
+ }
1373
+ }
1374
+
1375
+ /**
1376
+ * Every waiting pass, across every spec. Reads; writes, claims and refuses
1377
+ * nothing.
1378
+ *
1379
+ * A wait that never fired, a session cleared, a terminal closed overnight —
1380
+ * none of those is recoverable by any watcher, however good. This is what finds
1381
+ * the pass afterwards, so recovery stops depending on the operator happening to
1382
+ * suspect something.
1383
+ *
1384
+ * IT READS THE SIDECAR DIRECTORY, NEVER THE PROVISIONED LIST, and that is the
1385
+ * load-bearing decision rather than an implementation detail. `specEnvStatus`
1386
+ * walks specs that have a **worktree**; the ten passes that motivated this all
1387
+ * belonged to specs that had been completed and torn down. Scoping the scan to
1388
+ * provisioned specs would therefore make it blind to precisely the case that
1389
+ * produced it. A `<spec>.pending.json` is here because a pass was received —
1390
+ * a positive signal (`.claude/rules/negative-checks.md` rule 1), and one that
1391
+ * outlives the spec's worktree, its branch and its folder.
1392
+ *
1393
+ * WHAT WOULD FOOL THIS: a store that will not parse. It holds someone's passes
1394
+ * and reading it as empty would report "nothing waiting", which is the one
1395
+ * answer that is certainly wrong — so it comes back named in `unreadable`
1396
+ * rather than counted as zero (rule 4).
1397
+ *
1398
+ * Oldest first and stable, ties broken on the code, exactly as
1399
+ * `describePending` orders one spec's own.
1400
+ */
1401
+ function waitingPasses(reviewsDirPath) {
1402
+ let entries
1403
+ try {
1404
+ entries = fs.readdirSync(reviewsDirPath)
1405
+ } catch {
1406
+ // No directory means no review has ever been rendered here. That is the
1407
+ // ordinary state of a fresh repo, not a failure to report.
1408
+ return { passes: [], unreadable: [] }
1409
+ }
1410
+
1411
+ const passes = []
1412
+ const unreadable = []
1413
+ for (const name of entries.sort()) {
1414
+ if (!name.endsWith(PENDING_SUFFIX)) continue
1415
+ const folder = name.slice(0, -PENDING_SUFFIX.length)
1416
+ // The engine's own reader, so "corrupt" means here what it means everywhere.
1417
+ const read = readPending(path.join(reviewsDirPath, `${folder}.html`), folder)
1418
+ if (read.corrupt) {
1419
+ unreadable.push(folder)
1420
+ continue
1421
+ }
1422
+ for (const pass of read.pending.passes || []) {
1423
+ if (pass && pass.code) {
1424
+ passes.push({
1425
+ spec: folder,
1426
+ code: pass.code,
1427
+ verdict: readVerdict(pass.blob && pass.blob.verdict),
1428
+ action: (pass.blob && pass.blob.action) || null,
1429
+ at: pass.at || null,
1430
+ })
1431
+ }
1432
+ }
1433
+ }
1434
+ passes.sort((a, b) => {
1435
+ const at = String(a.at || '').localeCompare(String(b.at || ''))
1436
+ return at !== 0 ? at : a.code.localeCompare(b.code)
1437
+ })
1438
+ return { passes, unreadable }
1439
+ }
1440
+
1441
+ /* ==========================================================================
1442
+ * The gate — a standing obligation to review, not a message in flight
1443
+ *
1444
+ * A pending pass is something someone SENT. The gate is something the repo
1445
+ * OWES: a phase ended, its page was rendered, and nobody has said what they
1446
+ * concluded yet. Kept in its own sidecar for exactly that reason — claiming a
1447
+ * pass consumes the pass, while only a COMMITTING verdict or a recorded skip
1448
+ * consumes the gate, and one file holding both states would have to encode
1449
+ * that difference anyway.
1450
+ *
1451
+ * It is the one thing in this engine that refuses. The marks still gate
1452
+ * nothing and nothing counts them — what this asserts is narrower: a phase
1453
+ * that ended is not finished until a person said something about it.
1454
+ * ========================================================================== */
1455
+
1456
+ const GATE_VERSION = 1
1457
+
1458
+ /**
1459
+ * The reason a BYPASS records, and it is deliberately a fixed string.
1460
+ *
1461
+ * A skip normally carries what the operator typed. This one is chosen from a
1462
+ * picker with nothing typed, so the record has to say that for itself — and a
1463
+ * string nobody would write by hand is what keeps a tapped bypass tellable from
1464
+ * a considered one when both appear in the same log.
1465
+ *
1466
+ * It stays `by: 'skip'` rather than minting a third `DISARMED_BY` verb: the
1467
+ * sidecar's vocabulary is versioned, and a distinct reason already buys the only
1468
+ * thing a third verb would have.
1469
+ */
1470
+ const GATE_BYPASS_REASON = 'none: chose to commit without reading the diff'
1471
+
1472
+ /**
1473
+ * The way out of an armed gate, declared beside the refusal rather than left
1474
+ * for a caller to reconstruct.
1475
+ *
1476
+ * A BYPASS, NOT A SATISFY: the phase still owes a verdict and nobody has read
1477
+ * it, so this steps past a guard that is still unsatisfied — which is exactly
1478
+ * why it is recorded and a `satisfy` is not.
1479
+ *
1480
+ * WHAT THIS IS NOT: a `--force`. One was asked for and rejected, because a
1481
+ * reasonless lift is indistinguishable from nobody having looked. The exits
1482
+ * this gate has are unchanged — a committing verdict, or a recorded skip — and
1483
+ * this is the second of those two at an address a person will actually reach.
1484
+ */
1485
+ const GATE_BYPASS_OFFER = Object.freeze({
1486
+ kind: 'bypass',
1487
+ label: 'Commit without reading the diff — recorded as such',
1488
+ command: `skitterspec spec-env review skip "${GATE_BYPASS_REASON}"`,
1489
+ })
1490
+
1491
+ // How a disarm happened. `verdict` is a review that reached a committing
1492
+ // conclusion; `skip` is the operator saying, on the record, that they are
1493
+ // moving on without one.
1494
+ const DISARMED_BY = ['verdict', 'skip']
1495
+
1496
+ function reviewGatePath(outPath) {
1497
+ return outPath.replace(/\.html$/, '') + '.gate.json'
1498
+ }
1499
+
1500
+ function emptyGate(specFolder) {
1501
+ return { version: GATE_VERSION, spec: specFolder, armed: false, armedAt: null, phase: null, offeredAt: null, permit: null, log: [] }
1502
+ }
1503
+
1504
+ /**
1505
+ * Read the gate sidecar. Never throws, and reports `corrupt` rather than
1506
+ * hiding it — the same three states `readNotes` answers in, for the same
1507
+ * reason. Here the third state is load-bearing in the other direction: a gate
1508
+ * we cannot parse must never READ AS ARMED, because that would refuse a commit
1509
+ * on the strength of a file nobody can interpret.
1510
+ */
1511
+ function readGate(outPath, specFolder) {
1512
+ let raw
1513
+ try {
1514
+ raw = fs.readFileSync(reviewGatePath(outPath), 'utf8')
1515
+ } catch {
1516
+ // Absent is the ordinary state — a project that never ends a phase through
1517
+ // the review path has no gate file at all.
1518
+ return { gate: emptyGate(specFolder), corrupt: false, present: false }
1519
+ }
1520
+ try {
1521
+ const parsed = JSON.parse(raw)
1522
+ return {
1523
+ gate: {
1524
+ version: parsed.version,
1525
+ spec: parsed.spec || specFolder,
1526
+ armed: parsed.armed === true,
1527
+ armedAt: parsed.armedAt || null,
1528
+ phase: parsed.phase === undefined ? null : parsed.phase,
1529
+ // A sidecar written before this field existed reads as never-offered,
1530
+ // which is the correct and harmless default. These files are gitignored,
1531
+ // so there is no fleet to migrate and `GATE_VERSION` does not move.
1532
+ offeredAt: parsed.offeredAt || null,
1533
+ // Same rule as `offeredAt`: a sidecar written before this existed reads
1534
+ // as no permit, which is the state that refuses. Anything that is not
1535
+ // an object is read as absent rather than trusted — this file is the
1536
+ // one thing standing between an armed gate and a commit.
1537
+ permit: parsed.permit && typeof parsed.permit === 'object' ? parsed.permit : null,
1538
+ log: Array.isArray(parsed.log) ? parsed.log : [],
1539
+ },
1540
+ corrupt: false,
1541
+ present: true,
1542
+ }
1543
+ } catch {
1544
+ return { gate: emptyGate(specFolder), corrupt: true, present: true }
1545
+ }
1546
+ }
1547
+
1548
+ function writeGate(outPath, gate) {
1549
+ const p = reviewGatePath(outPath)
1550
+ fs.mkdirSync(path.dirname(p), { recursive: true })
1551
+ fs.writeFileSync(p, JSON.stringify(gate, null, 2) + '\n')
1552
+ return p
1553
+ }
1554
+
1555
+ /* ==========================================================================
1556
+ * The render record — what the caller DECLARED, for the re-render to read
1557
+ * ========================================================================== */
1558
+
1559
+ // Same stem as the other sidecars, so one spec's set still reads as one set.
1560
+ function renderRecordPath(outPath) {
1561
+ return outPath.replace(/\.html$/, '') + '.render.json'
1562
+ }
1563
+
1564
+ /**
1565
+ * The button set the last CLI render of this spec declared, or null.
1566
+ *
1567
+ * WHY THIS EXISTS AT ALL. `--buttons` is declared by the caller — `BUTTON_SETS`
1568
+ * above says why it is never derived — and the review daemon is not that
1569
+ * caller. It re-renders a spec's page per request from the tree, so without a
1570
+ * record it can only guess, and what it guessed was the default: every
1571
+ * `/no-spec` page served over http offered `Commit & Start`.
1572
+ *
1573
+ * WHAT WOULD FOOL A CONSUMER THAT TRUSTED THIS OUTRIGHT: it is a file on disk
1574
+ * and the tree moves underneath it. A spec rendered as `--docs` and then put in
1575
+ * flight has a day-old record saying `authoring` and a worktree saying
1576
+ * otherwise. So `buttonsForView` in `serve.js` takes only NARROWING words from
1577
+ * here and reads the family off the tree — this function's job is to report
1578
+ * what was written, not to decide whether it still holds.
1579
+ *
1580
+ * Three states, like `readNotes` and `readGate`: absent is the ordinary case,
1581
+ * and corrupt is reported rather than hidden so neither reads as a declaration.
1582
+ */
1583
+ function readRenderRecord(outPath, specFolder) {
1584
+ let raw
1585
+ try {
1586
+ raw = fs.readFileSync(renderRecordPath(outPath), 'utf8')
1587
+ } catch {
1588
+ // Absent is ordinary: most pages are opened off the index without the CLI
1589
+ // ever having rendered that spec.
1590
+ return { buttons: null, corrupt: false, present: false }
1591
+ }
1592
+ try {
1593
+ const parsed = JSON.parse(raw)
1594
+ const buttons = typeof parsed.buttons === 'string' && parsed.buttons ? parsed.buttons : null
1595
+ // The folder is checked, not trusted: a record found under this spec's stem
1596
+ // that names another spec is not this spec's declaration.
1597
+ if (parsed.spec && specFolder && parsed.spec !== specFolder) {
1598
+ return { buttons: null, corrupt: false, present: true }
1599
+ }
1600
+ return { buttons, corrupt: false, present: true }
1601
+ } catch {
1602
+ return { buttons: null, corrupt: true, present: true }
1603
+ }
1604
+ }
1605
+
1606
+ function writeRenderRecord(outPath, record) {
1607
+ const p = renderRecordPath(outPath)
1608
+ fs.mkdirSync(path.dirname(p), { recursive: true })
1609
+ fs.writeFileSync(p, JSON.stringify(record, null, 2) + '\n')
1610
+ return p
1611
+ }
1612
+
1613
+ /**
1614
+ * Arm the gate. Pure.
1615
+ *
1616
+ * IDEMPOTENT ON PURPOSE. Re-rendering a page for a phase already awaiting a
1617
+ * verdict must not move `armedAt` — the timestamp answers "how long has this
1618
+ * been waiting", and a render is not an event that resets that. A gate armed
1619
+ * for a DIFFERENT phase is re-armed, because that is a new obligation.
1620
+ */
1621
+ function armGate(gate, { at, phase = null }) {
1622
+ // Idempotent within a phase — a re-render must not restart the clock, and it
1623
+ // must not hand back an offer this arming has already spent.
1624
+ if (gate.armed && gate.phase === phase) return gate
1625
+ // A NEW ARMING GETS A NEW OFFER. Each phase that ends is its own obligation,
1626
+ // so the once-only rule is scoped to the arming rather than to the file.
1627
+ //
1628
+ // AND A NEW OBLIGATION STARTS WITH NO WAY PAST IT. A permit is granted
1629
+ // against one arming — the commit one pressed `live-on` depends on — so
1630
+ // carrying it into the next phase would hand that phase a hole nobody asked
1631
+ // for. It is bound to a HEAD as well, so this is belt and braces; both are
1632
+ // cheap and only one of them has to hold.
1633
+ return { ...gate, armed: true, armedAt: at, phase, offeredAt: null, permit: null }
1634
+ }
1635
+
1636
+ /**
1637
+ * Record that the bypass was offered for this arming. Pure.
1638
+ *
1639
+ * SPENT BY BEING MADE, not by being taken. A reader who declines still saw it,
1640
+ * and re-offering on the next refused commit is how a bypass becomes the default
1641
+ * rather than a deliberate step.
1642
+ *
1643
+ * NOT DONE AS A SIDE EFFECT OF READING THE GATE, deliberately. `/spec-next`
1644
+ * asks `review gate --json` on every single run, so a read that spent the
1645
+ * offer would burn it with no picker ever raised — and the operator would never
1646
+ * be offered the thing this exists to offer them.
1647
+ *
1648
+ * Marking an unarmed gate changes nothing: there is no obligation, so there is
1649
+ * nothing to have offered a way out of.
1650
+ */
1651
+ function markGateOffered(gate, { at }) {
1652
+ if (!gate.armed || gate.offeredAt) return { gate, marked: false }
1653
+ return { gate: { ...gate, offeredAt: at }, marked: true }
1654
+ }
1655
+
1656
+ /**
1657
+ * The actions whose handling REQUIRES a commit in the spec's own worktree.
1658
+ *
1659
+ * One, and the list exists so the answer is looked up rather than remembered.
1660
+ * `allow-network` and `allow-remote` write a config key and commit nothing, so
1661
+ * neither has a precondition to permit — and a permit granted for an action
1662
+ * that never commits is a hole with no commit to close it.
1663
+ */
1664
+ const PERMITTED_ACTIONS = ['live-on']
1665
+
1666
+ /**
1667
+ * Record that ONE commit is a claimed action's mechanical precondition. Pure.
1668
+ *
1669
+ * WHY THIS EXISTS. The page offers `live-on` only on a non-midrun render, which
1670
+ * is what a finished phase produces — and a finished phase arms the gate. The
1671
+ * action is deliberately not a verdict, so it clears nothing; but `live take`
1672
+ * refuses a dirty worktree, so handling the press means committing the phase
1673
+ * first. That commit is a `git commit` in the spec's own worktree, which is
1674
+ * exactly what the armed gate denies. Without this the button could never
1675
+ * succeed in the only state it was ever offered in.
1676
+ *
1677
+ * WHAT IT IS NOT: a verdict, a skip, or a lift. The gate stays `armed`, so
1678
+ * `/spec-next` still refuses the next phase and the obligation is still
1679
+ * discharged by a committing verdict or a recorded skip and by nothing else.
1680
+ * This widens one commit, not the guard.
1681
+ *
1682
+ * THREE POSITIVE SIGNALS, all required (`.claude/rules/negative-checks.md`
1683
+ * rule 1), and every cannot-tell grants nothing:
1684
+ *
1685
+ * - The gate is ARMED. Nothing is refusing otherwise, so there is nothing to
1686
+ * permit past — and a permit written onto a clear gate would sit there
1687
+ * waiting for the next phase to arm.
1688
+ * - The action is one that COMMITS. See `PERMITTED_ACTIONS` above.
1689
+ * - The worktree is DIRTY, and a `head` was readable. A clean tree has no
1690
+ * precondition, so granting one would leave a permit lying around to cover
1691
+ * some later commit made for an entirely different reason.
1692
+ *
1693
+ * BOUND TO A HEAD, which is what makes it exactly one commit rather than a
1694
+ * standing permission. `permitHonoured` compares the worktree's HEAD against
1695
+ * the one recorded here, so the permit dies the instant its commit lands — and
1696
+ * a commit that FAILED (red tests, a rejected message) leaves HEAD alone, so
1697
+ * the retry is still covered. Spending it on the check instead would have made
1698
+ * that retry a refusal.
1699
+ */
1700
+ function grantPermit(gate, { at, action, head, dirty }) {
1701
+ if (!gate || !gate.armed) return { gate, granted: false }
1702
+ if (!PERMITTED_ACTIONS.includes(action)) return { gate, granted: false }
1703
+ if (dirty !== true || !head) return { gate, granted: false }
1704
+ return {
1705
+ gate: { ...gate, permit: { for: action, at, head, phase: gate.phase === undefined ? null : gate.phase } },
1706
+ granted: true,
1707
+ }
1708
+ }
1709
+
1710
+ /**
1711
+ * Does this gate's permit cover a commit happening at `head`? Pure.
1712
+ *
1713
+ * WHAT WOULD FOOL THIS: nothing here checks WHERE the commit is running. That
1714
+ * is deliberate — the caller has already established that it is inside this
1715
+ * spec's own worktree, which is the same positive signal `--for-command` wants
1716
+ * before it refuses at all, and duplicating it here would be a second copy free
1717
+ * to drift from the first.
1718
+ *
1719
+ * A permit for an action not in `PERMITTED_ACTIONS` is ignored rather than
1720
+ * trusted. The sidecar is gitignored and hand-editable, and an older engine
1721
+ * meeting a newer word should refuse, not guess.
1722
+ */
1723
+ function permitHonoured(gate, { head }) {
1724
+ if (!gate || !gate.armed) return false
1725
+ const permit = gate.permit
1726
+ if (!permit || typeof permit !== 'object') return false
1727
+ if (!PERMITTED_ACTIONS.includes(permit.for)) return false
1728
+ if (!permit.head || !head) return false
1729
+ return permit.head === head
1730
+ }
1731
+
1732
+ /**
1733
+ * Disarm it, and say how. Pure.
1734
+ *
1735
+ * The log is append-only and nothing reads it back to decide anything — it is
1736
+ * the record that a decision was taken, which is the whole value of a skip
1737
+ * over a silence. Disarming an already-clear gate logs nothing: there was no
1738
+ * obligation, so there is no outcome to record.
1739
+ */
1740
+ function disarmGate(gate, { at, by, reason = null }) {
1741
+ if (!gate.armed) return { gate, logged: false }
1742
+ const log = Array.isArray(gate.log) ? gate.log.slice() : []
1743
+ log.push({ by, at, phase: gate.phase === undefined ? null : gate.phase, reason })
1744
+ // The permit goes with the obligation it was a way through. Nothing refuses
1745
+ // once the gate is clear, so a surviving one could only ever apply to the
1746
+ // next phase's arming — which is not what anybody pressed.
1747
+ return { gate: { ...gate, armed: false, armedAt: null, phase: null, permit: null, log }, logged: true }
1748
+ }
1749
+
1750
+ /**
1751
+ * What the gate says, in three states. Pure.
1752
+ *
1753
+ * `armed` is the only one that refuses, and it is reached only by a POSITIVE
1754
+ * signal: a sidecar that is present, parseable, and says so
1755
+ * (`.claude/rules/negative-checks.md` rule 1). Everything else routes to the
1756
+ * harmless branch (rule 4) under its own name:
1757
+ *
1758
+ * - `clear` — read it, nothing is owed.
1759
+ * - `unknown` — could not read it, or the project turned the gate off. Nothing
1760
+ * is claimed and nothing refuses.
1761
+ *
1762
+ * WHAT WOULD FOOL THIS: a gate armed for a phase whose work has since been
1763
+ * committed by hand still reads armed, so the refusal outlives the thing it
1764
+ * was guarding. That is deliberate — the exit is one `skip` with a reason,
1765
+ * which is precisely the decision this exists to put on the record — and it
1766
+ * fails toward asking rather than toward letting a phase through unread.
1767
+ */
1768
+ /**
1769
+ * What the page is told about the gate. Pure. `null` when there is nothing to
1770
+ * say, so a project that never armed one renders byte-identically to how it
1771
+ * did before any of this existed — the same rule `phases` and `context` follow.
1772
+ *
1773
+ * The last SKIP travels with it, because that is the page's answer to the one
1774
+ * question an untouched-looking review raises: was this read and moved past, or
1775
+ * never read at all? A verdict already had an answer there; a skip did not.
1776
+ */
1777
+ function gateForPage(gate) {
1778
+ if (!gate) return null
1779
+ const log = Array.isArray(gate.log) ? gate.log : []
1780
+ const skips = log.filter((e) => e && e.by === 'skip')
1781
+ const lastSkip = skips.length ? skips[skips.length - 1] : null
1782
+ if (!gate.armed && !lastSkip) return null
1783
+ return {
1784
+ armed: gate.armed === true,
1785
+ armedAt: gate.armedAt || null,
1786
+ phase: gate.phase === undefined ? null : gate.phase,
1787
+ ...(lastSkip ? { lastSkip: { at: lastSkip.at || null, reason: lastSkip.reason || null } } : {}),
1788
+ }
1789
+ }
1790
+
1791
+ function gateState({ gate, corrupt, present, required }) {
1792
+ if (required === false) return { state: 'unknown', reason: 'review.required is false', gate }
1793
+ if (corrupt) return { state: 'unknown', reason: 'the gate sidecar is not readable JSON', gate }
1794
+ if (!present) return { state: 'clear', reason: 'no gate recorded', gate }
1795
+ if (gate.version !== GATE_VERSION) {
1796
+ return {
1797
+ state: 'unknown',
1798
+ reason: `gate version ${JSON.stringify(gate.version)} — this engine reads version ${GATE_VERSION}`,
1799
+ gate,
1800
+ }
1801
+ }
1802
+ if (!gate.armed) return { state: 'clear', reason: 'nothing is awaiting a verdict', gate }
1803
+ // The refusal is unchanged by the offer having been spent — what is withheld
1804
+ // is the way out, never the guard. A second refused commit still refuses, and
1805
+ // still names its exits in the text; the operator simply is not asked again.
1806
+ return {
1807
+ state: 'armed',
1808
+ reason: 'a phase is awaiting a verdict',
1809
+ gate,
1810
+ ...(gate.offeredAt ? {} : { offer: GATE_BYPASS_OFFER }),
1811
+ }
1812
+ }
1813
+
1814
+ /**
1815
+ * Validate a blob from the page, wholesale.
1816
+ *
1817
+ * It refuses on the FIRST problem and writes nothing, because a half-merged
1818
+ * sidecar is worse than a rejected paste: you would have to work out which
1819
+ * half landed. Every message names the offending entry so the answer is in the
1820
+ * refusal rather than in a second investigation.
1821
+ *
1822
+ * Unknown keys are IGNORED rather than rejected — an older engine meeting a
1823
+ * newer page should drop what it does not understand, not refuse the accepts it
1824
+ * does.
1825
+ */
1826
+ function validateNotesBlob(blob, specFolder) {
1827
+ const fail = (m) => {
1828
+ throw new Error(`notes blob: ${m}`)
1829
+ }
1830
+ if (!blob || typeof blob !== 'object' || Array.isArray(blob)) fail('not a JSON object')
1831
+ if (blob.version !== NOTES_VERSION) {
1832
+ fail(`version ${JSON.stringify(blob.version)} — this engine reads version ${NOTES_VERSION}`)
1833
+ }
1834
+ // Pasting the wrong spec's review is the one mistake that would look entirely
1835
+ // successful, so the blob names its spec and the engine checks it.
1836
+ if (blob.spec && specFolder && blob.spec !== specFolder) {
1837
+ fail(`written for ${blob.spec}, but this is ${specFolder}`)
1838
+ }
1839
+ const arr = (key) => {
1840
+ const v = blob[key]
1841
+ if (v === undefined || v === null) return []
1842
+ if (!Array.isArray(v)) fail(`${key} must be an array`)
1843
+ return v
1844
+ }
1845
+ const accepted = arr('accepted')
1846
+ for (const a of accepted) {
1847
+ if (!a || typeof a !== 'object' || Array.isArray(a)) fail('every accepted entry must be an object')
1848
+ if (typeof a.path !== 'string' || !a.path) fail('an accepted entry has no path')
1849
+ if (typeof a.hash !== 'string' || !a.hash) fail(`accepted entry ${a.path} has no hash`)
1850
+ }
1851
+ const unaccepted = arr('unaccepted')
1852
+ for (const p of unaccepted) {
1853
+ if (typeof p !== 'string' || !p) fail('every unaccepted entry must be a path')
1854
+ }
1855
+ const comments = arr('comments')
1856
+ for (const c of comments) {
1857
+ if (!c || typeof c !== 'object' || Array.isArray(c)) fail('every comment must be an object')
1858
+ if (typeof c.id !== 'string' || !c.id) fail('a comment has no id')
1859
+ if (typeof c.file !== 'string' || !c.file) fail(`comment ${c.id} has no file`)
1860
+ if (typeof c.note !== 'string' || !c.note.trim()) fail(`comment ${c.id} has no note`)
1861
+ if (c.line !== undefined && c.line !== null && !Number.isInteger(c.line)) {
1862
+ fail(`comment ${c.id} has a non-integer line`)
1863
+ }
1864
+ }
1865
+ // A KNOWN KEY WITH AN UNKNOWN VALUE IS REFUSED BY NAME, which is not the
1866
+ // unknown-key rule above wearing a hat. Dropping `verdict: "aprove"` the way
1867
+ // we drop a key we have never heard of would read as `discuss` — a review
1868
+ // that silently did nothing, reported as if it had. Absent is a different
1869
+ // thing from wrong, and only absent is allowed through.
1870
+ let verdict = null
1871
+ if (blob.verdict !== undefined && blob.verdict !== null) {
1872
+ // Read through the rename BEFORE checking: a page that has not been
1873
+ // reloaded still sends `approve`, and refusing it would turn a stale tab
1874
+ // into a rejected review rather than a committed one.
1875
+ const named = typeof blob.verdict === 'string' ? readVerdict(blob.verdict) : blob.verdict
1876
+ if (typeof named !== 'string' || !VERDICTS.includes(named)) {
1877
+ fail(`verdict ${JSON.stringify(blob.verdict)} is not one of ${VERDICTS.join(', ')}`)
1878
+ }
1879
+ verdict = named
1880
+ }
1881
+ // AN ACTION, refused by name for the same reason a misspelt verdict is: a
1882
+ // dropped `action: "live-onn"` would read as a pass carrying no instruction
1883
+ // at all, reported as if the press had been honoured.
1884
+ let action = null
1885
+ if (blob.action !== undefined && blob.action !== null) {
1886
+ if (typeof blob.action !== 'string' || !ACTIONS.includes(blob.action)) {
1887
+ fail(`action ${JSON.stringify(blob.action)} is not one of ${ACTIONS.join(', ')}`)
1888
+ }
1889
+ action = blob.action
1890
+ }
1891
+ if (action && verdict) {
1892
+ fail(`carries both a verdict (${verdict}) and an action (${action}) — send one`)
1893
+ }
1894
+ return { accepted, unaccepted, comments, verdict, action }
1895
+ }
1896
+
1897
+ /**
1898
+ * Decide what a verdict actually does, given the notes it arrives with. Pure.
1899
+ *
1900
+ * THE ONE ACCUSING CHECK HERE: an approval is refused while any comment is
1901
+ * unresolved. It reads a PRESENCE — an open comment, sitting in the sidecar —
1902
+ * rather than an absence, so there is no lookup that could have been too narrow
1903
+ * to see the thing (`.claude/rules/negative-checks.md` rule 1). The comments
1904
+ * counted are whatever is in `notes` at the moment of the call, which is after
1905
+ * the blob has been merged and after any `--resolve` has landed, so a note
1906
+ * raised and answered in the same run does not block.
1907
+ *
1908
+ * WHAT WOULD FOOL THIS: nothing about the count, but the intent behind it is
1909
+ * narrow. UNACCEPTED FILES ARE NOT COUNTED AND MUST NEVER BE. A comment is a
1910
+ * request you made; an unticked file is merely something you said nothing
1911
+ * about, and requiring every file ticked would be the counting gate this whole
1912
+ * feature was designed not to become.
1913
+ *
1914
+ * A refused approval routes to `discuss` — report and stop — rather than
1915
+ * staying `approve` with a flag beside it. Three states, and the one we cannot
1916
+ * honour goes to the harmless branch (rule 4): a caller that reads `effective`
1917
+ * and ignores `honoured` then talks instead of committing, which is the
1918
+ * failure we can afford.
1919
+ */
1920
+ function judgeVerdict(verdict, notes) {
1921
+ const read = readVerdict(verdict) || null
1922
+ // A WORD THIS ENGINE DOES NOT KNOW IS NOT HONOURED AS ONE. Every door into
1923
+ // here validates first — `validateNotesBlob` refuses an unknown verdict by
1924
+ // name and refuses an action in the verdict slot, and `--verdict` checks the
1925
+ // list — so nothing reaches this today. It used to pass such a word straight
1926
+ // through with `honoured: true`, which routes cannot-tell to the branch that
1927
+ // acts (`.claude/rules/negative-checks.md` rule 4 inverted). Closing it costs
1928
+ // nothing and means a fifth door added later cannot reopen it.
1929
+ const sent = read && VERDICTS.includes(read) ? read : null
1930
+ const asked = sent || DEFAULT_VERDICT
1931
+ const open = (notes.comments || []).filter((c) => !c.resolved)
1932
+ const openFiles = [...new Set(open.map((c) => c.file))]
1933
+ if (!COMMITTING.includes(asked) || open.length === 0) {
1934
+ return { sent, effective: asked, honoured: true, reason: null, openCount: open.length, openFiles }
1935
+ }
1936
+ return {
1937
+ sent,
1938
+ effective: DEFAULT_VERDICT,
1939
+ honoured: false,
1940
+ reason:
1941
+ `${open.length} comment${open.length === 1 ? ' is' : 's are'} unresolved ` +
1942
+ `(${openFiles.join(', ')})`,
1943
+ openCount: open.length,
1944
+ openFiles,
1945
+ }
1946
+ }
1947
+
1948
+ /**
1949
+ * Append one line to the outcome log. Pure.
1950
+ *
1951
+ * HISTORY, NOT STATE. Nothing reads `decisions` to decide anything — a verdict
1952
+ * is consumed by the thing it asked for (a commit, or the work) and is never
1953
+ * stored as a pending instruction, so an approval cannot go stale and later
1954
+ * commit something nobody read. What is kept is the account of what was
1955
+ * decided, when, and eventually what it produced.
1956
+ */
1957
+ function appendDecision(notes, { verdict, at, note = null, code = null }) {
1958
+ const decisions = Array.isArray(notes.decisions) ? notes.decisions.slice() : []
1959
+ // `code` NAMES THE PASS this decision came out of, and it is the only present
1960
+ // thing anyone can assert to conclude that a pass was picked up. Four
1961
+ // different things make a code vanish from the holding area and only one of
1962
+ // them is a claim — `--drop` is another, a moved store and a mistyped folder
1963
+ // are two more — so `passState` reads this, and never reads an absence.
1964
+ //
1965
+ // `null` for every other path in: a pasted blob and a `--verdict` word carry
1966
+ // no code, and inventing one would put a pass in the record that never
1967
+ // existed.
1968
+ decisions.push({ verdict, at, note: note === undefined ? null : note, code: code || null })
1969
+ return { ...notes, updatedAt: at, decisions }
1970
+ }
1971
+
1972
+ /**
1973
+ * Write what a decision PRODUCED onto the last entry in the log. Pure.
1974
+ *
1975
+ * The verdict is logged when it is honoured, which is before the thing it asked
1976
+ * for has happened — so the sha, and which path produced it, can only be added
1977
+ * afterwards. That is this function.
1978
+ *
1979
+ * WHAT WOULD FOOL THIS: it annotates the LAST entry, whatever that is. If a
1980
+ * second verdict is logged between the approval and its commit, the outcome
1981
+ * lands on the wrong one. Reaching that takes two review passes interleaved
1982
+ * against one sidecar, and the cost is a misfiled line in a history nothing
1983
+ * reads to decide anything — so it is accepted rather than guarded, and named
1984
+ * here so a later reader does not have to rediscover it.
1985
+ *
1986
+ * An empty log is left exactly as it was: there is no decision to describe, and
1987
+ * inventing one would put an outcome in the record with no decision behind it.
1988
+ */
1989
+ function annotateLastDecision(notes, note) {
1990
+ const decisions = Array.isArray(notes.decisions) ? notes.decisions.slice() : []
1991
+ if (!decisions.length) return { notes, annotated: false }
1992
+ decisions[decisions.length - 1] = { ...decisions[decisions.length - 1], note }
1993
+ return { notes: { ...notes, decisions }, annotated: true }
1994
+ }
1995
+
1996
+ /**
1997
+ * Merge a validated blob into the stored notes. Pure.
1998
+ *
1999
+ * MERGE, NEVER REPLACE. The page only knows the render it was built from, so a
2000
+ * replace would drop the agent's resolutions and let a tab left open overnight
2001
+ * roll back everything recorded since. Anything the blob does not mention is
2002
+ * carried through untouched — which is also why un-accepting is an EXPLICIT
2003
+ * `unaccepted` list rather than absence from `accepted`.
2004
+ *
2005
+ * Comments merge by id, and the page mints ids from the render timestamp, so
2006
+ * pasting the same blob twice is idempotent instead of doubling every note.
2007
+ */
2008
+ function mergeNotes(existing, blob, now) {
2009
+ const notes = {
2010
+ version: NOTES_VERSION,
2011
+ spec: existing.spec,
2012
+ updatedAt: now,
2013
+ files: { ...existing.files },
2014
+ comments: existing.comments.slice(),
2015
+ }
2016
+ // The log is history and the merge rebuilds the object from scratch, so it
2017
+ // has to be carried across explicitly or every paste would erase it.
2018
+ if (Array.isArray(existing.decisions)) notes.decisions = existing.decisions.slice()
2019
+ for (const a of blob.accepted) notes.files[a.path] = { acceptedHash: a.hash, acceptedAt: now }
2020
+ for (const p of blob.unaccepted) delete notes.files[p]
2021
+
2022
+ const indexById = new Map(notes.comments.map((c, i) => [c.id, i]))
2023
+ for (const c of blob.comments) {
2024
+ const entry = {
2025
+ id: c.id,
2026
+ file: c.file,
2027
+ line: c.line === undefined ? null : c.line,
2028
+ lineText: c.lineText === undefined ? null : c.lineText,
2029
+ check: c.check === undefined ? null : c.check,
2030
+ note: c.note,
2031
+ raisedAt: now,
2032
+ resolved: null,
2033
+ }
2034
+ const at = indexById.get(c.id)
2035
+ if (at === undefined) {
2036
+ indexById.set(c.id, notes.comments.length)
2037
+ notes.comments.push(entry)
2038
+ continue
2039
+ }
2040
+ // A re-paste refreshes the text but keeps the history: when it was first
2041
+ // raised, and any resolution already written against it.
2042
+ const prev = notes.comments[at]
2043
+ notes.comments[at] = { ...entry, raisedAt: prev.raisedAt, resolved: prev.resolved }
2044
+ }
2045
+ return notes
2046
+ }
2047
+
2048
+ /**
2049
+ * Validate a resolutions file — the agent's half of the round-trip.
2050
+ *
2051
+ * Separate from `validateNotesBlob` because the shapes and the authors differ:
2052
+ * a blob comes from a page through a clipboard, and this comes from whatever
2053
+ * just finished working the comments. Same rule though — refuse wholesale, and
2054
+ * name what is wrong.
2055
+ */
2056
+ function validateResolutions(raw) {
2057
+ const fail = (m) => {
2058
+ throw new Error(`resolutions: ${m}`)
2059
+ }
2060
+ const list = Array.isArray(raw) ? raw : raw && Array.isArray(raw.resolved) ? raw.resolved : null
2061
+ if (!list) fail('expected an array of { id, note }')
2062
+ for (const r of list) {
2063
+ if (!r || typeof r !== 'object' || Array.isArray(r)) fail('every entry must be an object')
2064
+ if (typeof r.id !== 'string' || !r.id) fail('an entry has no id')
2065
+ // "Resolved" with nothing said is not a resolution — the note is the half
2066
+ // that lets the next read verify the fix instead of trusting it.
2067
+ if (typeof r.note !== 'string' || !r.note.trim()) fail(`entry ${r.id} has no note`)
2068
+ }
2069
+ return list
2070
+ }
2071
+
2072
+ /**
2073
+ * Attach resolutions to the comments they name. Pure.
2074
+ *
2075
+ * An id matching nothing is REPORTED AND SKIPPED, never invented and never
2076
+ * fatal. A resolution naming a comment that does not exist is a mistake worth
2077
+ * surfacing — but failing the whole call would throw away the work that was
2078
+ * genuinely done on the ids that did match.
2079
+ *
2080
+ * Re-resolving overwrites rather than stacking: the newest account of what was
2081
+ * done is the one that matches the code.
2082
+ */
2083
+ function applyResolutions(notes, resolutions, now) {
2084
+ const byId = new Map(notes.comments.map((c) => [c.id, c]))
2085
+ const unknown = []
2086
+ let applied = 0
2087
+ for (const r of resolutions) {
2088
+ const c = byId.get(r.id)
2089
+ if (!c) {
2090
+ unknown.push(r.id)
2091
+ continue
2092
+ }
2093
+ c.resolved = { at: now, note: r.note }
2094
+ applied++
2095
+ }
2096
+ return { notes: { ...notes, updatedAt: now }, applied, unknown }
2097
+ }
2098
+
2099
+ /**
2100
+ * Fold the stored notes onto the collected files.
2101
+ *
2102
+ * `accepted` is `true` only when the recorded hash matches what is there now;
2103
+ * a difference is `'lapsed'` and is SAID rather than silently forgotten, so a
2104
+ * file you vouched for two phases ago cannot quietly pass as still-read.
2105
+ */
2106
+ function applyNotes(files, notes, hashes) {
2107
+ const byFile = new Map()
2108
+ for (const c of notes.comments) {
2109
+ if (!byFile.has(c.file)) byFile.set(c.file, [])
2110
+ byFile.get(c.file).push(c)
2111
+ }
2112
+ const totals = { accepted: 0, lapsed: 0, unresolved: 0, resolved: 0 }
2113
+ for (const f of files) {
2114
+ const hash = hashes.has(f.path) ? hashes.get(f.path) : null
2115
+ f.hash = hash === undefined ? null : hash
2116
+ const rec = notes.files[f.path]
2117
+ if (!rec || !rec.acceptedHash) {
2118
+ f.accepted = false
2119
+ f.acceptedAt = null
2120
+ } else if (f.hash && rec.acceptedHash === f.hash) {
2121
+ f.accepted = true
2122
+ f.acceptedAt = rec.acceptedAt || null
2123
+ totals.accepted++
2124
+ } else {
2125
+ f.accepted = 'lapsed'
2126
+ f.acceptedAt = rec.acceptedAt || null
2127
+ totals.lapsed++
2128
+ }
2129
+ f.comments = byFile.get(f.path) || []
2130
+ }
2131
+ for (const c of notes.comments) {
2132
+ if (c.resolved) totals.resolved++
2133
+ else totals.unresolved++
2134
+ }
2135
+ // A comment on a file that is no longer in the diff (reverted, or landed in
2136
+ // an earlier phase) would otherwise vanish from the page along with its file.
2137
+ const shown = new Set(files.map((f) => f.path))
2138
+ const unanchored = notes.comments.filter((c) => !shown.has(c.file))
2139
+ return { totals, unanchored }
2140
+ }
2141
+
2142
+
2143
+ /**
2144
+ * The page template — a SHIPPED ASSET, not generated markup.
2145
+ *
2146
+ * `assets/` is in every distribution's `files`, and the build copies non-`.md`
2147
+ * assets across verbatim, so this resolves identically from the source package
2148
+ * (`packages/common/src/env` → `packages/common/assets`) and from a built
2149
+ * distribution (`src/env` → `<pkg>/assets`).
2150
+ *
2151
+ * The engine SPLICES into it and never authors markup: the model's prose arrives
2152
+ * as JSON and the diff arrives as text, so neither has to survive a round-trip
2153
+ * through generated HTML.
2154
+ */
2155
+ const TEMPLATE_PATH = path.join(__dirname, '..', '..', 'assets', 'review', 'page.html')
2156
+ const DATA_PLACEHOLDER = '__REVIEW_DATA__'
2157
+ const REVIEW_PLACEHOLDER = '__REVIEW_BLOCK__'
2158
+ const TITLE_PLACEHOLDER = '__REVIEW_TITLE__'
2159
+ const PLACEHOLDER_RE = /__REVIEW_(?:TITLE|BLOCK|DATA)__/g
2160
+
2161
+ function loadTemplate() {
2162
+ return fs.readFileSync(TEMPLATE_PATH, 'utf8')
2163
+ }
2164
+
2165
+ // Escape the one sequence that could end the data island early. A patch
2166
+ // containing `</script>` is not hypothetical — this feature reviews its own
2167
+ // source, and the template above contains one.
2168
+ function escapeIsland(json) {
2169
+ return json.replace(/<\//g, '<\\/')
2170
+ }
2171
+
2172
+ function escapeHtml(s) {
2173
+ return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
2174
+ }
2175
+
2176
+ /**
2177
+ * Splice the collected data (and, later, the written review) into the template.
2178
+ * The data goes in as JSON, never as generated markup — that is the property the
2179
+ * whole design rests on.
2180
+ */
2181
+ /**
2182
+ * The fragment form of the template: what an artifact host can accept.
2183
+ *
2184
+ * A published page is wrapped in the host's own `<!doctype>`/`<head>`/`<body>`
2185
+ * skeleton, so handing it a complete document nests two of them. The fragment
2186
+ * carries the `<title>` (hosts read it for the tab name), every `<style>` and
2187
+ * `<script>` block out of `<head>`, then the body contents — and no wrapper.
2188
+ *
2189
+ * IT SPLITS THE TEMPLATE, NEVER THE RENDERED PAGE, and that is the whole
2190
+ * correctness argument. The template contains exactly one of each boundary tag
2191
+ * and nothing but placeholders where content goes. A rendered page contains the
2192
+ * diff, and this feature reviews its own source — so `<!doctype html>`,
2193
+ * `<html>` and `<body>` appear inside it as ordinary patch text. A regex over
2194
+ * the rendered page finds those and cuts in the wrong place; the first hand
2195
+ * publish of `feat-review-offer-lands` hit exactly that, with four
2196
+ * wrapper-looking tags in the output that were all patch content.
2197
+ */
2198
+ function fragmentTemplate(template = null) {
2199
+ const t = template || loadTemplate()
2200
+
2201
+ const headStart = t.indexOf('<head>')
2202
+ const headEnd = t.indexOf('</head>')
2203
+ const bodyOpen = t.indexOf('<body')
2204
+ const bodyStart = t.indexOf('>', bodyOpen) + 1
2205
+ const bodyEnd = t.lastIndexOf('</body>')
2206
+ if (headStart < 0 || headEnd < 0 || bodyOpen < 0 || bodyEnd < 0) {
2207
+ throw new Error('review template has no <head>/<body> to split — cannot build a fragment')
2208
+ }
2209
+
2210
+ const head = t.slice(headStart, headEnd)
2211
+ const title = /<title>[\s\S]*?<\/title>/.exec(head)
2212
+ const carried = head.match(/<(style|script)\b[\s\S]*?<\/\1>/g) || []
2213
+
2214
+ return [title ? title[0] : '', ...carried, t.slice(bodyStart, bodyEnd).trim()]
2215
+ .filter(Boolean)
2216
+ .join('\n')
2217
+ }
2218
+
2219
+ /**
2220
+ * The same data, spliced into the fragment instead of the whole document. One
2221
+ * pass over the placeholders, for the same reason `renderReviewPage` uses one.
2222
+ */
2223
+ function renderReviewFragment(data, { template = null, reviewHtml = '' } = {}) {
2224
+ const values = {
2225
+ [TITLE_PLACEHOLDER]: escapeHtml(data.title),
2226
+ [REVIEW_PLACEHOLDER]: reviewHtml,
2227
+ [DATA_PLACEHOLDER]: escapeIsland(JSON.stringify(data)),
2228
+ }
2229
+ return fragmentTemplate(template).replace(PLACEHOLDER_RE, (m) => values[m])
2230
+ }
2231
+
2232
+ function renderReviewPage(data, { template = null, reviewHtml = '' } = {}) {
2233
+ const values = {
2234
+ [TITLE_PLACEHOLDER]: escapeHtml(data.title),
2235
+ [REVIEW_PLACEHOLDER]: reviewHtml,
2236
+ [DATA_PLACEHOLDER]: escapeIsland(JSON.stringify(data)),
2237
+ }
2238
+ // ONE pass, so nothing spliced in is ever rescanned. This is not theoretical:
2239
+ // the page reviews its own source, so the data island legitimately CONTAINS
2240
+ // all three placeholder strings, and sequential replaces would splice a whole
2241
+ // JSON blob into the middle of a patch — or into a review note that happened
2242
+ // to quote a placeholder name.
2243
+ return (template || loadTemplate()).replace(PLACEHOLDER_RE, (m) => values[m])
2244
+ }
2245
+
2246
+ /**
2247
+ * Render the written review into HTML.
2248
+ *
2249
+ * THE ENGINE RENDERS; THE MODEL JUDGES. The review arrives as JSON — a short
2250
+ * read plus severity-tagged checks — and this turns it into markup. The two
2251
+ * rejected alternatives: the model emitting HTML (more tokens, and one unclosed
2252
+ * tag breaks the page), and the engine parsing markdown (a markdown renderer to
2253
+ * ship and maintain for one surface).
2254
+ *
2255
+ * Every field is escaped. The review is model-authored text arriving through a
2256
+ * file, which is exactly the input that should never be trusted as markup.
2257
+ */
2258
+ const CHECK_LEVELS = ['flag', 'confirm', 'good']
2259
+
2260
+ function renderReviewBlock(review) {
2261
+ if (!review || typeof review !== 'object') return ''
2262
+ const parts = ['<section class="review">']
2263
+ if (review.summary) {
2264
+ parts.push(`<p class="review-summary">${escapeHtml(review.summary)}</p>`)
2265
+ }
2266
+ parts.push(...renderReviewerStrip(review.reviewers))
2267
+ const checks = Array.isArray(review.checks) ? review.checks : []
2268
+ if (checks.length) {
2269
+ parts.push('<ul class="checks">')
2270
+ checks.forEach((c, i) => {
2271
+ // An unknown level is shown as `confirm` rather than dropped: losing a
2272
+ // reviewer's note because it was tagged oddly is worse than showing it
2273
+ // under a neutral heading.
2274
+ const level = CHECK_LEVELS.includes(c.level) ? c.level : 'confirm'
2275
+ // `file:line` where a line is known, and the whole thing is a button so a
2276
+ // finding can be read against the code it is about. A check with no line
2277
+ // renders exactly as it always did — the written review supplies none, and
2278
+ // its output must not change because machine findings now exist.
2279
+ const hasLine = Number.isInteger(c.line) && c.line > 0
2280
+ const label = c.file ? escapeHtml(c.file) + (hasLine ? `:${c.line}` : '') : ''
2281
+ const file = c.file
2282
+ ? `<button type="button" class="check-file" data-goto="${escapeHtml(c.file)}"` +
2283
+ `${hasLine ? ` data-goto-line="${c.line}"` : ''}>${label}</button>`
2284
+ : ''
2285
+ // WHO SAID IT. Absent for a written review, which is what it has always
2286
+ // been; present the moment two authors can appear on one page, because a
2287
+ // reader weighing a finding needs to know whether a person read the code
2288
+ // or a tool matched a pattern.
2289
+ const src = c.source ? `<span class="check-src">${escapeHtml(c.source)}</span>` : ''
2290
+ // The id is positional and therefore stable for THIS render, which is all
2291
+ // a reply needs: it travels back in the same blob the page was built from.
2292
+ // `data-file` is what lets an answer land on the file the check is about.
2293
+ const id = `k${i}`
2294
+ const fileAttr = c.file ? ` data-file="${escapeHtml(c.file)}"` : ''
2295
+ const lineAttr = hasLine ? ` data-line="${c.line}"` : ''
2296
+ parts.push(
2297
+ `<li class="check ${level}" data-check="${id}"${fileAttr}${lineAttr}>` +
2298
+ `<span class="check-level">${level}</span>` +
2299
+ `${src}${file}<span class="check-note">${escapeHtml(c.note || '')}</span></li>`,
2300
+ )
2301
+ })
2302
+ parts.push('</ul>')
2303
+ }
2304
+ parts.push('</section>')
2305
+ return parts.join('\n')
2306
+ }
2307
+
2308
+ /**
2309
+ * One line per configured reviewer, saying what its run ended as.
2310
+ *
2311
+ * PRESENT EVEN WHEN EVERY REVIEWER FOUND NOTHING, which is the whole point and
2312
+ * the one place `.claude/rules/negative-checks.md` inverts here. Silence is
2313
+ * normally the safe branch; on this page it is not, because a reviewer that was
2314
+ * rate-limited renders identically to one that read the diff and approved of it
2315
+ * — and the reader is about to decide whether to commit. So the strip reports
2316
+ * the RUN, which is a positive signal, rather than accusing the code.
2317
+ *
2318
+ * Absent entirely when no reviewer is configured: every project that exists
2319
+ * today is in that state, and a strip saying "no reviewers" would be an absence
2320
+ * reported at everyone.
2321
+ */
2322
+ function renderReviewerStrip(outcomes) {
2323
+ const list = Array.isArray(outcomes) ? outcomes : []
2324
+ if (!list.length) return []
2325
+ const parts = ['<ul class="reviewers">']
2326
+ for (const o of list) {
2327
+ const state = typeof o.state === 'string' ? o.state : 'failed'
2328
+ parts.push(
2329
+ `<li class="reviewer ${escapeHtml(state)}">` +
2330
+ `<span class="reviewer-name">${escapeHtml(o.name || 'reviewer')}</span>` +
2331
+ `<span class="reviewer-said">${escapeHtml(reviewerSaid(o))}</span></li>`,
2332
+ )
2333
+ }
2334
+ parts.push('</ul>')
2335
+ return parts
2336
+ }
2337
+
2338
+ /**
2339
+ * What one reviewer's outcome reads as. Pure.
2340
+ *
2341
+ * A STATE THIS BUILD DOES NOT KNOW IS SHOWN, not dropped — the same rule the
2342
+ * check level follows. An unrecognised state means a newer engine wrote the
2343
+ * cache, and losing the line entirely would read as a reviewer that was never
2344
+ * configured.
2345
+ */
2346
+ function reviewerSaid(o) {
2347
+ const n = Number.isInteger(o.count) ? o.count : 0
2348
+ const detail = o.detail ? ` — ${o.detail}` : ''
2349
+ switch (o.state) {
2350
+ case 'findings':
2351
+ return `${n} finding${n === 1 ? '' : 's'}${detail}`
2352
+ case 'cached':
2353
+ return n ? `${n} finding${n === 1 ? '' : 's'} · cached` : 'clean · cached'
2354
+ case 'clean':
2355
+ return 'clean'
2356
+ case 'missing':
2357
+ return `did not run${o.detail ? ` — ${o.detail}` : ''}`
2358
+ case 'timeout':
2359
+ return 'did not run — timed out'
2360
+ case 'failed':
2361
+ return `did not run${o.detail ? ` — ${o.detail}` : ''}`
2362
+ default:
2363
+ return `${o.state}${detail}`
2364
+ }
2365
+ }
2366
+
2367
+ /**
2368
+ * Where a spec's published URL is remembered, and what it currently says.
2369
+ *
2370
+ * The engine READS this file and never writes it, and it does not know what the
2371
+ * string means — it cannot publish, and nothing here can. It is named here so
2372
+ * the skill that does publish never has to construct a path, which is the only
2373
+ * way the two halves stay in step.
2374
+ */
2375
+ function reviewUrlPath(outPath) {
2376
+ return outPath.replace(/\.html$/, '') + '.url'
2377
+ }
2378
+
2379
+ /**
2380
+ * What teardown says about a published page. PURE — url in, lines out.
2381
+ *
2382
+ * A published page is the ONE thing teardown cannot reclaim. The worktree goes,
2383
+ * the branch goes, the tracker assignment is released — and the page stays up,
2384
+ * because nothing in this tooling can delete it and nothing should pretend to.
2385
+ * So it is named, with where it can be removed, and that is the whole feature.
2386
+ *
2387
+ * Returns `[]` when the spec was never published, which is most specs. An
2388
+ * absence explained is noise: a teardown that mentions publishing to everyone
2389
+ * who never published is worse than one that says nothing.
2390
+ *
2391
+ * **Deliberately not a command, and never part of `run these:`.** There is no
2392
+ * command to run — removal is a person opening `/artifacts`. Folding it into the
2393
+ * batch would imply skitterspec could do it.
2394
+ */
2395
+ function publishedPageNotice(url) {
2396
+ if (!url) return []
2397
+ return [
2398
+ '',
2399
+ ' published page survives this teardown — skitterspec cannot remove it:',
2400
+ ` ${url}`,
2401
+ ' delete it yourself: /artifacts in the terminal (o opens, c copies), or',
2402
+ ' the gallery at claude.ai/code/artifacts.',
2403
+ ]
2404
+ }
2405
+
2406
+ /**
2407
+ * What to say about a review server at teardown. PURE — takes the facts as
2408
+ * arguments so a test states the world it describes.
2409
+ *
2410
+ * The server the operator never started is the one nobody remembers to stop:
2411
+ * `spec-env review` stands it up on a remote reader, and one path token unlocks
2412
+ * every provisioned spec's diff for as long as it runs. So teardown names it —
2413
+ * but only when there is genuinely nothing left for it to serve.
2414
+ *
2415
+ * `running` must come from a POSITIVE signal (`isAlive`), never from a pidfile
2416
+ * existing: a crashed process leaves one behind, and telling someone to stop a
2417
+ * server that is already gone is an accusation against a healthy teardown.
2418
+ */
2419
+ function reviewServerNotice({ running, othersServed }) {
2420
+ if (!running || othersServed > 0) return []
2421
+ return [
2422
+ '',
2423
+ ' review server — this was the last spec it served:',
2424
+ ' skitterspec spec-env review serve --stop',
2425
+ ]
2426
+ }
2427
+
2428
+ // The publish-ready copy, beside the page it came from. Same stem, so every
2429
+ // sidecar (`.notes.json`, `.url`, `.publish.html`, `.render.json`) reads as one
2430
+ // spec's set.
2431
+ function reviewPublishPath(outPath) {
2432
+ return outPath.replace(/\.html$/, '') + '.publish.html'
2433
+ }
2434
+
2435
+ function readReviewUrl(outPath) {
2436
+ try {
2437
+ return fs.readFileSync(reviewUrlPath(outPath), 'utf8').trim() || null
2438
+ } catch {
2439
+ return null
2440
+ }
2441
+ }
2442
+
2443
+ /**
2444
+ * Whether ONE SPEC may publish, beside its page in the gitignored store.
2445
+ *
2446
+ * WHY PER SPEC. `review.allowRemote` in the committed config permits every
2447
+ * spec in the repo at once, and writing it from a worktree dirtied the base
2448
+ * branch (bug-remote-review-toggle). Publishing sends a diff to a third party,
2449
+ * so what may generate an artifact is decided per worktree; the committed key
2450
+ * stays as the project default beneath it.
2451
+ */
2452
+ function reviewRemotePath(outPath) {
2453
+ return outPath.replace(/\.html$/, '') + '.remote.json'
2454
+ }
2455
+
2456
+ // true / false when the sidecar says so, null for every cannot-tell — absent,
2457
+ // unreadable, a non-boolean — so the committed default stands
2458
+ // (`.claude/rules/negative-checks.md` rule 4: a typo must not permit a publish).
2459
+ function readRemoteAllow(outPath) {
2460
+ try {
2461
+ const v = JSON.parse(fs.readFileSync(reviewRemotePath(outPath), 'utf8')).allowRemote
2462
+ return typeof v === 'boolean' ? v : null
2463
+ } catch {
2464
+ return null
2465
+ }
2466
+ }
2467
+
2468
+ function writeRemoteAllow(outPath, on, at = new Date().toISOString()) {
2469
+ const file = reviewRemotePath(outPath)
2470
+ fs.mkdirSync(path.dirname(file), { recursive: true })
2471
+ fs.writeFileSync(file, JSON.stringify({ allowRemote: on, at }, null, 2) + '\n')
2472
+ return file
2473
+ }
2474
+
2475
+ // The config one spec's render reads: the project's, with this spec's own
2476
+ // remote permission over it. A new object — the caller's config is shared.
2477
+ function withSpecRemote(config, outPath) {
2478
+ const own = readRemoteAllow(outPath)
2479
+ if (own === null) return config
2480
+ return { ...config, review: { ...config.review, allowRemote: own } }
2481
+ }
2482
+
2483
+ // Default output path for a spec's page, under the gitignored `.spec-env/`.
2484
+ function reviewOutPath(dir, specFolder, out) {
2485
+ if (out) return path.resolve(dir, out)
2486
+ return path.join(dir, REVIEW_DIR, `${specFolder}.html`)
2487
+ }
2488
+
2489
+ /**
2490
+ * The page's path as a `file://` URL.
2491
+ *
2492
+ * A bare absolute path is not clickable in ANY terminal; `file://` is linkified
2493
+ * by iTerm2, VS Code's terminal and Terminal.app, so the page is one click from
2494
+ * the line that announces it. Printed ALONGSIDE the path, never instead of it —
2495
+ * the path is what you pass to another command, and the URL is what you click.
2496
+ *
2497
+ * Deliberately not an `open` call: that assumes a GUI, a default browser and
2498
+ * that you are sitting at the machine. The whole point of this page is that it
2499
+ * does not care where you are.
2500
+ */
2501
+ function reviewFileUrl(outPath) {
2502
+ // Encode each segment, then restore the separators — a path can legitimately
2503
+ // contain spaces, `#` or `?`, and all three break a URL left raw.
2504
+ const encoded = path
2505
+ .resolve(outPath)
2506
+ .split(path.sep)
2507
+ .map((seg) => encodeURIComponent(seg))
2508
+ .join('/')
2509
+ return `file://${encoded.startsWith('/') ? '' : '/'}${encoded}`
2510
+ }
2511
+
2512
+ // Write the page, creating its directory. Returns the absolute path written.
2513
+ function writeReviewPage(outPath, html) {
2514
+ fs.mkdirSync(path.dirname(outPath), { recursive: true })
2515
+ fs.writeFileSync(outPath, html)
2516
+ return outPath
2517
+ }
2518
+
2519
+ module.exports = {
2520
+ WHOLE_FILE_CONTEXT,
2521
+ NOTES_VERSION,
2522
+ VERDICTS,
2523
+ ACTIONS,
2524
+ tierAction,
2525
+ surfacesFor,
2526
+ reviewTierStack,
2527
+ reviewTierLine,
2528
+ isTailnetAddress,
2529
+ tailnetLabel,
2530
+ COMMITTING,
2531
+ BUTTON_SETS,
2532
+ DEFAULT_BUTTON_SET,
2533
+ readVerdict,
2534
+ DEFAULT_VERDICT,
2535
+ DELETED_HASH,
2536
+ fileHashes,
2537
+ reviewNotesPath,
2538
+ emptyNotes,
2539
+ readNotes,
2540
+ writeNotes,
2541
+ validateNotesBlob,
2542
+ validateResolutions,
2543
+ judgeVerdict,
2544
+ appendDecision,
2545
+ annotateLastDecision,
2546
+ reviewPendingPath,
2547
+ PENDING_SUFFIX,
2548
+ emptyPending,
2549
+ readPending,
2550
+ writePending,
2551
+ mintPendingCode,
2552
+ addPending,
2553
+ claimPending,
2554
+ passesSince,
2555
+ waitingPasses,
2556
+ waitForPass,
2557
+ WAIT_HEARTBEAT_MS,
2558
+ WAIT_POLL_MS,
2559
+ passState,
2560
+ describePending,
2561
+ pendingAge,
2562
+ PENDING_CODE_LENGTH,
2563
+ GATE_VERSION,
2564
+ DISARMED_BY,
2565
+ reviewGatePath,
2566
+ emptyGate,
2567
+ readGate,
2568
+ writeGate,
2569
+ renderRecordPath,
2570
+ readRenderRecord,
2571
+ writeRenderRecord,
2572
+ armGate,
2573
+ disarmGate,
2574
+ gateState,
2575
+ markGateOffered,
2576
+ PERMITTED_ACTIONS,
2577
+ grantPermit,
2578
+ permitHonoured,
2579
+ GATE_BYPASS_OFFER,
2580
+ GATE_BYPASS_REASON,
2581
+ gateForPage,
2582
+ mergeNotes,
2583
+ applyResolutions,
2584
+ applyNotes,
2585
+ PATCH_LIMIT_BYTES,
2586
+ REVIEW_DIR,
2587
+ rawGitReader,
2588
+ isNoise,
2589
+ lines,
2590
+ parseNumstat,
2591
+ trackedFiles,
2592
+ untrackedFiles,
2593
+ collectReview,
2594
+ renderReviewPage,
2595
+ reviewOutPath,
2596
+ reviewFileUrl,
2597
+ writeReviewPage,
2598
+ escapeIsland,
2599
+ escapeHtml,
2600
+ renderReviewBlock,
2601
+ renderReviewerStrip,
2602
+ reviewerSaid,
2603
+ reviewUrlPath,
2604
+ reviewPublishPath,
2605
+ readReviewUrl,
2606
+ reviewRemotePath,
2607
+ readRemoteAllow,
2608
+ writeRemoteAllow,
2609
+ withSpecRemote,
2610
+ publishedPageNotice,
2611
+ reviewServerNotice,
2612
+ fragmentTemplate,
2613
+ renderReviewFragment,
2614
+ detectReader,
2615
+ resolveReader,
2616
+ CHECK_LEVELS,
2617
+ loadTemplate,
2618
+ TEMPLATE_PATH,
2619
+ DATA_PLACEHOLDER,
2620
+ REVIEW_PLACEHOLDER,
2621
+ TITLE_PLACEHOLDER,
2622
+ }