@skitterbyte/skitterspec 17.0.0 → 19.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 (40) hide show
  1. package/MIGRATION.md +260 -10
  2. package/README.md +53 -4
  3. package/assets/claude-md-section.md +48 -2
  4. package/assets/commands/spec-connect.md +2 -2
  5. package/assets/commands/spec-live.md +2 -2
  6. package/assets/core/env.config.json.example +9 -3
  7. package/assets/core/env.config.md +102 -30
  8. package/assets/core/gating.config.json.example +4 -0
  9. package/assets/core/gating.config.md +81 -0
  10. package/assets/review/page.html +1501 -0
  11. package/assets/rules/spec-planning.md +224 -15
  12. package/assets/rules/spec-reports.md +269 -0
  13. package/assets/skills/spec/SKILL.md +63 -12
  14. package/assets/skills/spec-bug/SKILL.md +134 -26
  15. package/assets/skills/spec-cancel/SKILL.md +85 -6
  16. package/assets/skills/spec-complete/SKILL.md +109 -20
  17. package/assets/skills/spec-diff/SKILL.md +564 -0
  18. package/assets/skills/spec-hotfix/SKILL.md +143 -21
  19. package/assets/skills/spec-init/SKILL.md +49 -9
  20. package/assets/skills/spec-next/SKILL.md +289 -7
  21. package/assets/skills/spec-review/SKILL.md +45 -9
  22. package/assets/skills/spec-reviewed/SKILL.md +241 -0
  23. package/assets/skills/spec-start/SKILL.md +323 -66
  24. package/assets/skills/spec-to-main/SKILL.md +42 -20
  25. package/package.json +11 -7
  26. package/src/cli.js +1710 -80
  27. package/src/env/building.js +143 -0
  28. package/src/env/classify.js +91 -0
  29. package/src/env/config.js +57 -9
  30. package/src/env/provision.js +192 -19
  31. package/src/env/proxy.js +34 -1
  32. package/src/env/render.js +3 -12
  33. package/src/env/resolve.js +296 -9
  34. package/src/env/review.js +1329 -0
  35. package/src/env/serve.js +549 -0
  36. package/src/env/teardown.js +13 -6
  37. package/src/gating.js +155 -0
  38. package/src/init.js +124 -2
  39. package/src/prompts.js +10 -1
  40. package/LICENSE +0 -21
@@ -0,0 +1,1329 @@
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
+ * A git runner bound to one checkout, returning stdout **untrimmed**.
49
+ *
50
+ * The untrimmed part is load-bearing and is why this does not reuse the CLI's
51
+ * `gitReader`: `git status --porcelain` puts a SPACE in the first column for an
52
+ * unstaged change, so trimming the whole output shifts every path by one
53
+ * character. (Found the hard way in the prototype.)
54
+ *
55
+ * A non-zero exit whose stdout is still useful is returned rather than thrown —
56
+ * `git diff --no-index` exits 1 precisely when the files differ, which is the
57
+ * case we call it for.
58
+ */
59
+ function rawGitReader(worktreePath) {
60
+ return (argv) => {
61
+ try {
62
+ return execFileSync('git', ['-C', worktreePath, ...argv], {
63
+ stdio: ['ignore', 'pipe', 'ignore'],
64
+ encoding: 'utf8',
65
+ maxBuffer: 256 * 1024 * 1024,
66
+ })
67
+ } catch (err) {
68
+ if (err && typeof err.stdout === 'string') return err.stdout
69
+ if (err && err.stdout) return err.stdout.toString()
70
+ return null
71
+ }
72
+ }
73
+ }
74
+
75
+ // Split output into lines WITHOUT trimming the lines themselves (see above).
76
+ // Only the trailing newline git always emits is dropped.
77
+ function lines(out) {
78
+ if (!out) return []
79
+ return out.replace(/\n$/, '').split('\n').filter((l) => l !== '')
80
+ }
81
+
82
+ // `--numstat` emits one row per file; we ask per-file, so take the FIRST line
83
+ // and split that — splitting the whole output merges a second file's counts into
84
+ // the first (the prototype's other bug). A binary file reports `-` for both.
85
+ function parseNumstat(out) {
86
+ const first = lines(out)[0]
87
+ if (!first) return { additions: 0, deletions: 0, binary: false }
88
+ const [a, d] = first.split('\t')
89
+ if (a === '-' || d === '-') return { additions: 0, deletions: 0, binary: true }
90
+ return { additions: Number(a) || 0, deletions: Number(d) || 0, binary: false }
91
+ }
92
+
93
+ // Map a `--name-status` code to the status the viewer shows.
94
+ function statusFromCode(code) {
95
+ const c = code[0]
96
+ if (c === 'A') return 'new'
97
+ if (c === 'D') return 'deleted'
98
+ if (c === 'R') return 'renamed'
99
+ if (c === 'C') return 'copied'
100
+ return 'modified'
101
+ }
102
+
103
+ /**
104
+ * The tracked files that changed between `ref` and the working tree, in git's
105
+ * own order. `R`/`C` rows carry two paths (old, new) — the NEW path is the one
106
+ * to diff and display, and the old one is kept so the viewer can say where it
107
+ * came from.
108
+ */
109
+ function trackedFiles(git, ref) {
110
+ // Rename detection is git's default (diff.renames since 2.9) — left implicit
111
+ // rather than forced, so a project that turns it off gets what it configured.
112
+ const out = git(['diff', ref, '--name-status'])
113
+ const rows = []
114
+ for (const line of lines(out)) {
115
+ const parts = line.split('\t')
116
+ const code = parts[0]
117
+ if (!code) continue
118
+ if ((code[0] === 'R' || code[0] === 'C') && parts.length >= 3) {
119
+ rows.push({ path: parts[2], from: parts[1], status: statusFromCode(code) })
120
+ } else if (parts.length >= 2) {
121
+ rows.push({ path: parts[1], from: null, status: statusFromCode(code) })
122
+ }
123
+ }
124
+ return rows
125
+ }
126
+
127
+ /**
128
+ * Untracked files, which `git diff` cannot see at all — a new test file would
129
+ * otherwise be invisible, and that is the single most review-worthy thing a
130
+ * phase produces. `-uall` lists them individually rather than collapsing a new
131
+ * directory to one `dir/` row we would then have to walk ourselves.
132
+ */
133
+ function untrackedFiles(git) {
134
+ const out = git(['status', '--porcelain', '-uall'])
135
+ const rows = []
136
+ for (const line of lines(out)) {
137
+ // Columns are fixed: XY then a space then the path. NEVER trim the line.
138
+ if (line.slice(0, 2) !== '??') continue
139
+ const p = line.slice(3)
140
+ if (p) rows.push({ path: p, from: null, status: 'new' })
141
+ }
142
+ return rows
143
+ }
144
+
145
+ // The patch for one file, whole-file by default and falling back to -U3 when
146
+ // that is absurdly large. `untracked` files come in via --no-index against
147
+ // /dev/null, which is the only way git will diff a file it does not track.
148
+ function patchFor(git, ref, file, untracked) {
149
+ const gen = (u) =>
150
+ untracked
151
+ ? git(['diff', '--no-index', `--unified=${u}`, '--', '/dev/null', file.path])
152
+ : git(['diff', ref, `--unified=${u}`, '--', file.path])
153
+
154
+ let patch = gen(WHOLE_FILE_CONTEXT) || ''
155
+ let whole = true
156
+ if (Buffer.byteLength(patch, 'utf8') > PATCH_LIMIT_BYTES) {
157
+ patch = gen(3) || ''
158
+ whole = false
159
+ }
160
+ return { patch, whole }
161
+ }
162
+
163
+ function numstatFor(git, ref, file, untracked) {
164
+ const out = untracked
165
+ ? git(['diff', '--no-index', '--numstat', '--', '/dev/null', file.path])
166
+ : git(['diff', ref, '--numstat', '--', file.path])
167
+ return parseNumstat(out)
168
+ }
169
+
170
+ /**
171
+ * Where is the person reading this?
172
+ *
173
+ * `local` — at the machine that holds the page, so a `file://` URL opens.
174
+ * `remote` — somewhere else, so it does not.
175
+ * `unknown` — CANNOT TELL, and that is a real answer rather than a soft `local`.
176
+ *
177
+ * **This decides wording and nothing else.** Nothing in the engine serves,
178
+ * publishes or refuses on the strength of it, because being wrong has to stay
179
+ * cheap in both directions: a wrong `local` prints a dead link (the bug this
180
+ * exists to fix), and a wrong `remote` acted upon would publish something the
181
+ * tooling cannot remove, unprompted. `unknown` is therefore wired to exactly the
182
+ * behaviour that existed before any of this.
183
+ *
184
+ * `env` is passed in, never read from `process` here, so a test states the world
185
+ * it is testing instead of inheriting the machine the suite happens to run on.
186
+ *
187
+ * WHAT WOULD FOOL THIS: the bridge variable is an undocumented harness internal
188
+ * and may be renamed or dropped, so its ABSENCE proves nothing — which is the
189
+ * whole reason `unknown` exists and the default is not `local`.
190
+ */
191
+ function detectReader(env = {}) {
192
+ // SSH first: a standard convention, and the strongest available signal. If the
193
+ // shell arrived over the network, the page's path is on a machine the reader
194
+ // is not looking at.
195
+ if (env.SSH_CONNECTION || env.SSH_TTY) return { reader: 'remote', why: 'ssh' }
196
+
197
+ // The operator is driving this session from somewhere else — the case that
198
+ // produced the original dead link, read on a phone.
199
+ if (env.CLAUDE_CODE_BRIDGE_SESSION_ID) return { reader: 'remote', why: 'bridge session' }
200
+
201
+ // CLAUDE_CODE_ENTRYPOINT IS DELIBERATELY NOT CONSULTED. It describes the
202
+ // PROCESS, not the reader, and reports `cli` for a bridged session — it said
203
+ // exactly that for the session this was written from, where the reader was on
204
+ // a phone. Using it would produce a confident, wrong `local`.
205
+ //
206
+ // A TTY CHECK IS ALSO USELESS, and is named so nobody reaches for it: stdin is
207
+ // never a tty under Claude Code, so it discriminates nothing at all.
208
+ return { reader: 'unknown', why: null }
209
+ }
210
+
211
+ /**
212
+ * The reader, config first. An explicit `local`/`remote` is BELIEVED without
213
+ * sniffing: the operator knows where they are reading, and no signal outranks
214
+ * being told.
215
+ */
216
+ function resolveReader(config, env = {}) {
217
+ const setting = (config && config.review && config.review.reader) || 'detect'
218
+ if (setting === 'local' || setting === 'remote') return { reader: setting, why: 'configured' }
219
+ return detectReader(env)
220
+ }
221
+
222
+ /**
223
+ * Collect everything the page needs.
224
+ *
225
+ * `mode` is `'working'` (uncommitted work vs HEAD — the default, "what did this
226
+ * phase just do") or `'branch'` (everything since the base branch — "what does
227
+ * this whole spec do"). Both resolve to a single ref diffed against the working
228
+ * tree, so committed and uncommitted work are collected by one code path.
229
+ *
230
+ * `fellBack` records that `branch` was reached because the working tree was
231
+ * clean, not because the caller asked for it — see the fallback in
232
+ * `specEnvReview` (`cli.js`).
233
+ */
234
+ function collectReview({ spec, git, mode = 'working', ref, base = null, now, notes = null, fellBack = false }) {
235
+ const files = []
236
+ for (const f of trackedFiles(git, ref)) {
237
+ const { patch, whole } = patchFor(git, ref, f, false)
238
+ const { additions, deletions, binary } = numstatFor(git, ref, f, false)
239
+ files.push({ ...f, additions, deletions, binary, whole, noise: isNoise(f.path), patch })
240
+ }
241
+ for (const f of untrackedFiles(git)) {
242
+ const { patch, whole } = patchFor(git, ref, f, true)
243
+ const { additions, deletions, binary } = numstatFor(git, ref, f, true)
244
+ files.push({ ...f, additions, deletions, binary, whole, noise: isNoise(f.path), patch })
245
+ }
246
+
247
+ // Content hashes and the stored review state, folded on before the totals so
248
+ // the page and `--json` see one shape.
249
+ const store = notes || emptyNotes(spec.folder)
250
+ const { totals: noteTotals, unanchored } = applyNotes(files, store, fileHashes(git, files))
251
+
252
+ // The last honoured verdict, for the page to show as history. Only the last:
253
+ // the log is an audit trail and the page has one question to answer with it —
254
+ // why does this look untouched? — which the most recent entry answers.
255
+ const decisions = Array.isArray(store.decisions) ? store.decisions : []
256
+ const lastDecision = decisions.length ? decisions[decisions.length - 1] : null
257
+
258
+ // Every bucket, because a page is rendered for specs in `in-progress/` and for
259
+ // finished ones in `complete/` — and the finished one is the case this exists
260
+ // for. `null` when it cannot tell, and the page leaves its button alone.
261
+ const specDir = spec.worktreePath ? findSpecDirIn(spec.worktreePath, spec.folder) : null
262
+ const phases = specDir ? readPhases(specDir) : null
263
+ // The PR description this page never had: why the change exists, what it
264
+ // touches, and what this phase set out to do.
265
+ const context = readContextIn(specDir, phases)
266
+
267
+ const totals = files.reduce(
268
+ (acc, f) => ({
269
+ files: acc.files + 1,
270
+ additions: acc.additions + f.additions,
271
+ deletions: acc.deletions + f.deletions,
272
+ }),
273
+ { files: 0, additions: 0, deletions: 0 },
274
+ )
275
+
276
+ return {
277
+ spec: spec.folder,
278
+ title: spec.folder,
279
+ branch: spec.branch,
280
+ worktree: spec.worktreePath,
281
+ mode,
282
+ base,
283
+ ref,
284
+ // True only when `branch` was reached by the clean-tree fallback rather
285
+ // than by `--branch`. The page says so, because "everything since main" and
286
+ // "everything since main, because there was nothing uncommitted" are
287
+ // different answers to "what am I looking at".
288
+ fellBack,
289
+ generatedAt: now,
290
+ // Is there a phase left for `/spec-next` to build? The page needs it to
291
+ // know whether offering `Commit & Continue` means anything — and it is read
292
+ // from the SPEC'S OWN WORKTREE, where its phase statuses are current. On the
293
+ // base branch an in-flight spec still reads as it did before it started.
294
+ //
295
+ // Absent stays absent: a spec whose phases cannot be read adds no key, so
296
+ // the page renders byte-identically to how it did before any of this.
297
+ ...(phases ? { phases } : {}),
298
+ // Absent stays absent, exactly as `phases` does: a spec this cannot read
299
+ // adds no key and the page renders its header-less self.
300
+ ...(context ? { context } : {}),
301
+ // WHICH ENGINE DREW THIS PAGE. The render is always current — the git reads
302
+ // happen per request — so a page rendered by a stale process looks entirely
303
+ // right: the counts move, `generatedAt` moves, the diff is correct. Only the
304
+ // renderer is old, and nothing on the page said so. This is the line that
305
+ // makes the question answerable by reading the artefact.
306
+ engine: ENGINE_VERSION,
307
+ totals,
308
+ files,
309
+ notes: {
310
+ version: store.version || NOTES_VERSION,
311
+ updatedAt: store.updatedAt || null,
312
+ totals: noteTotals,
313
+ unanchored,
314
+ // Absent stays absent, for the same reason `readNotes` leaves it off: a
315
+ // review that has never reached a verdict must render byte-identically to
316
+ // how it did before any of this existed.
317
+ ...(lastDecision ? { lastDecision } : {}),
318
+ },
319
+ review: null,
320
+ }
321
+ }
322
+
323
+
324
+ /* ==========================================================================
325
+ * Notes — the review round-trip
326
+ *
327
+ * The page is read somewhere else (often a phone), so what you conclude while
328
+ * reading has to travel back as DATA: accepts, comments, and later the agent's
329
+ * resolutions. It arrives as a clipboard blob, is merged into a sidecar beside
330
+ * the page, and is read back at the next render.
331
+ *
332
+ * Everything here is engine-owned and versioned. The blob is UNTRUSTED input —
333
+ * it has been through a clipboard and a chat window — so it is validated
334
+ * wholesale before a byte is written.
335
+ * ========================================================================== */
336
+
337
+ // Read once, from this package — it is this file that draws the page, so its
338
+ // own version is the honest answer to "what rendered this".
339
+ /**
340
+ * Find a spec's folder under any bucket of one checkout, and read its phases.
341
+ *
342
+ * Kept here rather than reaching for `findSpecFolder`, which searches several
343
+ * roots and carries preference rules this does not want: there is exactly one
344
+ * tree to look in — the spec's own worktree — and looking anywhere else would
345
+ * answer about a branch that is not the one being reviewed.
346
+ */
347
+ function findSpecDirIn(worktreePath, folder) {
348
+ for (const bucket of ['in-progress', 'backlog', 'complete', 'cancelled']) {
349
+ const dir = path.join(worktreePath, 'specs', bucket, folder)
350
+ if (fs.existsSync(dir)) return dir
351
+ }
352
+ return null
353
+ }
354
+
355
+ /**
356
+ * What the change is FOR, for the top of the page — composed from the spec's
357
+ * own files, never written.
358
+ *
359
+ * That is the same rule the diff follows: nothing here passes through the
360
+ * model, so the header is free however large the review. The moment it were
361
+ * generated it would start costing tokens and start going stale.
362
+ *
363
+ * `null` when there is nothing to say — and a spec with an unreadable overview
364
+ * is not a broken spec. The page rendered without this yesterday.
365
+ */
366
+ function readContextIn(dir, phases) {
367
+ const overview = dir ? readOverview(dir) : null
368
+ const phase = phases && phases.live ? phases.live : null
369
+ if (!overview && !phase) return null
370
+ return { ...(overview || {}), ...(phase ? { phase } : {}) }
371
+ }
372
+
373
+ const ENGINE_VERSION = (() => {
374
+ try {
375
+ return require('../../package.json').version || null
376
+ } catch {
377
+ return null
378
+ }
379
+ })()
380
+
381
+ const NOTES_VERSION = 1
382
+
383
+ /**
384
+ * The four verdicts a review pass can carry, and what an absent one means.
385
+ *
386
+ * A VERDICT IS CHOSEN BY A PERSON, ONCE, PER REVIEW — it is not derived from
387
+ * how many boxes are ticked. That distinction is the whole point: counting
388
+ * marks stays forbidden (see `applyNotes`, which still gates nothing), and this
389
+ * is the deliberate, single place a review gets to say what it concluded.
390
+ *
391
+ * THE VERDICT NAMES THE ACTION. It was `approve` once, and an approval that
392
+ * only recorded itself is the one thing on a review page that does not describe
393
+ * what happens — a review is the guard in front of an action, so the word is
394
+ * the action: `commit`, `commit-continue`, `changes`, `discuss`.
395
+ *
396
+ * `discuss` is the default because it is the behaviour that existed before any
397
+ * verdict did. So a blob from an older page, or one a reader sent without
398
+ * choosing, keeps doing exactly what it always did.
399
+ */
400
+ const VERDICTS = ['commit', 'commit-continue', 'changes', 'discuss']
401
+ const DEFAULT_VERDICT = 'discuss'
402
+
403
+ // The verdicts that COMMIT, and are therefore blocked by an open comment. One
404
+ // list, so a fourth verdict cannot become a way around the single refusal this
405
+ // engine makes — adding a committing verdict means adding it here, and the
406
+ // block follows for free.
407
+ const COMMITTING = ['commit', 'commit-continue']
408
+
409
+ /**
410
+ * What an older sidecar's `approve` means now. Pure.
411
+ *
412
+ * TOLERANCE, NOT MIGRATION. These files are gitignored, so there is no fleet to
413
+ * migrate and no script anyone would remember to run — the rename is absorbed
414
+ * at the point of reading, where it cannot be skipped. Anything else is passed
415
+ * through untouched, including a value this engine does not know: `readVerdict`
416
+ * answers what a stored word means, and refusing an unknown one is
417
+ * `validateNotesBlob`'s job, not this function's.
418
+ */
419
+ function readVerdict(stored) {
420
+ return stored === 'approve' ? 'commit' : stored
421
+ }
422
+
423
+ // A file that is gone has no content to hash, and an accept still has to mean
424
+ // something about it. A sentinel is comparable and obviously not a blob sha.
425
+ const DELETED_HASH = '(deleted)'
426
+
427
+ // `hash-object` takes every path on one command line, so chunk it rather than
428
+ // discovering ARG_MAX on the one spec that touched 400 files.
429
+ const HASH_BATCH = 100
430
+
431
+ /**
432
+ * The git blob hash of each file's CURRENT working-tree content.
433
+ *
434
+ * THE IDENTITY AN ACCEPT IS KEYED TO, and deliberately not the patch. A patch
435
+ * is a function of the ref as much as the file: commit the phase and `HEAD`
436
+ * moves, so every patch changes while no file did — and every accept would
437
+ * lapse at the exact moment the work was finished. `--branch` does the same in
438
+ * reverse. Content keying survives both, because it describes the thing you
439
+ * actually read.
440
+ *
441
+ * A hash that cannot be computed is `null`, never a guess: it compares unequal
442
+ * to every recorded accept, so the cannot-tell case renders as lapsed rather
443
+ * than as approval (see `.claude/rules/negative-checks.md`).
444
+ */
445
+ function fileHashes(git, files) {
446
+ const out = new Map()
447
+ const live = []
448
+ for (const f of files) {
449
+ if (f.status === 'deleted') out.set(f.path, DELETED_HASH)
450
+ else live.push(f.path)
451
+ }
452
+ for (let i = 0; i < live.length; i += HASH_BATCH) {
453
+ const batch = live.slice(i, i + HASH_BATCH)
454
+ const got = lines(git(['hash-object', '--', ...batch]))
455
+ if (got.length === batch.length) {
456
+ batch.forEach((p, n) => out.set(p, got[n]))
457
+ continue
458
+ }
459
+ // One unhashable path fails the whole batch, so fall back per file rather
460
+ // than losing every other file's hash with it.
461
+ for (const p of batch) out.set(p, lines(git(['hash-object', '--', p]))[0] || null)
462
+ }
463
+ return out
464
+ }
465
+
466
+ // The sidecar sits beside the page and the `.url` file, under gitignored
467
+ // `.spec-env/` — a review leaves no trace in the branch it reviews.
468
+ function reviewNotesPath(outPath) {
469
+ return outPath.replace(/\.html$/, '') + '.notes.json'
470
+ }
471
+
472
+ function emptyNotes(specFolder) {
473
+ return { version: NOTES_VERSION, spec: specFolder, updatedAt: null, files: {}, comments: [] }
474
+ }
475
+
476
+ /**
477
+ * Read the sidecar. Never throws, and reports `corrupt` rather than hiding it.
478
+ *
479
+ * A file we cannot parse is the third state: it is not "no notes". Rendering
480
+ * carries on without them (the page is a convenience), but a MERGE must refuse,
481
+ * because writing over an unreadable file is how someone's whole review pass
482
+ * disappears. The caller decides which of those it is.
483
+ */
484
+ function readNotes(outPath, specFolder) {
485
+ let raw
486
+ try {
487
+ raw = fs.readFileSync(reviewNotesPath(outPath), 'utf8')
488
+ } catch {
489
+ // Absent is the ordinary state — most reviews never write one.
490
+ return { notes: emptyNotes(specFolder), corrupt: false, present: false }
491
+ }
492
+ try {
493
+ const parsed = JSON.parse(raw)
494
+ return {
495
+ notes: {
496
+ version: parsed.version,
497
+ spec: parsed.spec || specFolder,
498
+ updatedAt: parsed.updatedAt || null,
499
+ files: parsed.files && typeof parsed.files === 'object' ? parsed.files : {},
500
+ comments: Array.isArray(parsed.comments) ? parsed.comments : [],
501
+ // Absent stays absent. A sidecar written before verdicts existed, or by
502
+ // a review that never reached one, must not gain an empty `decisions`
503
+ // key just by being read — the round-trip has to be byte-stable for
504
+ // everyone who is not using this.
505
+ //
506
+ // A logged `approve` is read as `commit` HERE, at the read, rather than
507
+ // by a migration nobody would run: these files are gitignored, so the
508
+ // rename has to be absorbed where it cannot be skipped.
509
+ ...(Array.isArray(parsed.decisions)
510
+ ? { decisions: parsed.decisions.map((d) => ({ ...d, verdict: readVerdict(d.verdict) })) }
511
+ : {}),
512
+ },
513
+ corrupt: false,
514
+ present: true,
515
+ }
516
+ } catch {
517
+ return { notes: emptyNotes(specFolder), corrupt: true, present: true }
518
+ }
519
+ }
520
+
521
+ function writeNotes(outPath, notes) {
522
+ const p = reviewNotesPath(outPath)
523
+ fs.mkdirSync(path.dirname(p), { recursive: true })
524
+ fs.writeFileSync(p, JSON.stringify(notes, null, 2) + '\n')
525
+ return p
526
+ }
527
+
528
+ /* ==========================================================================
529
+ * Pending passes — a review that arrived over the wire
530
+ *
531
+ * A page that is SERVED can hand its pass straight back rather than going
532
+ * through a clipboard and a chat window. That is a write path reachable by
533
+ * anyone who can reach the page, so what it writes is deliberately not the
534
+ * sidecar: a POST lands here, in a HOLDING AREA, and nothing reaches the
535
+ * review until a person reads its code out. The code is what makes a stranger's
536
+ * pass inert and says WHICH pass when several are in flight.
537
+ *
538
+ * On disk rather than in the server's memory, and that is a constraint from
539
+ * `feat-review-serve-version` rather than a preference: that feature restarts a
540
+ * stale server automatically, and a restart that dropped the pass you just sent
541
+ * would be a new way to lose work. It also means `--claim` needs no running
542
+ * server at all.
543
+ * ========================================================================== */
544
+
545
+ const PENDING_VERSION = 1
546
+
547
+ // Beside the page and the notes sidecar, under gitignored `.spec-env/`.
548
+ function reviewPendingPath(outPath) {
549
+ return outPath.replace(/\.html$/, '') + '.pending.json'
550
+ }
551
+
552
+ function emptyPending(specFolder) {
553
+ return { version: PENDING_VERSION, spec: specFolder, passes: [] }
554
+ }
555
+
556
+ /**
557
+ * Read the holding area. Never throws; reports `corrupt` rather than hiding it.
558
+ *
559
+ * Same three states as `readNotes`, for the same reason: a file we cannot parse
560
+ * is not "no pending passes", and claiming against it must refuse rather than
561
+ * silently find nothing.
562
+ */
563
+ function readPending(outPath, specFolder) {
564
+ let raw
565
+ try {
566
+ raw = fs.readFileSync(reviewPendingPath(outPath), 'utf8')
567
+ } catch {
568
+ return { pending: emptyPending(specFolder), corrupt: false, present: false }
569
+ }
570
+ try {
571
+ const parsed = JSON.parse(raw)
572
+ return {
573
+ pending: {
574
+ version: parsed.version || PENDING_VERSION,
575
+ spec: parsed.spec || specFolder,
576
+ passes: Array.isArray(parsed.passes) ? parsed.passes : [],
577
+ },
578
+ corrupt: false,
579
+ present: true,
580
+ }
581
+ } catch {
582
+ return { pending: emptyPending(specFolder), corrupt: true, present: true }
583
+ }
584
+ }
585
+
586
+ function writePending(outPath, pending) {
587
+ const p = reviewPendingPath(outPath)
588
+ fs.mkdirSync(path.dirname(p), { recursive: true })
589
+ fs.writeFileSync(p, JSON.stringify(pending, null, 2) + '\n')
590
+ return p
591
+ }
592
+
593
+ const PENDING_CODE_LENGTH = 6
594
+
595
+ /**
596
+ * A code for one pending pass. Unique among those currently pending.
597
+ *
598
+ * NOT A SECRET, and it does not need to be: it is printed on the page by
599
+ * design, and what it authorises is a person reading it out. What it must not
600
+ * do is COLLIDE — two pending passes sharing a code is how the wrong one gets
601
+ * applied — so it is drawn against the set rather than drawn and hoped for.
602
+ *
603
+ * WHAT WOULD FOOL THIS: nothing about uniqueness, but note it is drawn from the
604
+ * passes it is GIVEN. A caller that mints against a stale read could still
605
+ * collide, which is why the mint and the write happen together in `addPending`.
606
+ */
607
+ function mintPendingCode(pending, random = () => crypto.randomInt(0, 10 ** PENDING_CODE_LENGTH)) {
608
+ const taken = new Set((pending.passes || []).map((p) => p.code))
609
+ // Bounded rather than `while (true)`: with a million codes and a handful
610
+ // pending this cannot realistically spin, and a bound means a bug here fails
611
+ // loudly instead of hanging the server.
612
+ for (let i = 0; i < 1000; i++) {
613
+ const code = String(random()).padStart(PENDING_CODE_LENGTH, '0')
614
+ if (!taken.has(code)) return code
615
+ }
616
+ throw new Error('could not mint a unique pending code')
617
+ }
618
+
619
+ /**
620
+ * Add a pass to the holding area, returning the new store and its code. Pure.
621
+ *
622
+ * SUPERSEDES WITHIN A RENDER. A second pass sent from the same page render
623
+ * replaces the first unclaimed one from that render, so the code on the screen
624
+ * is always the pass on the screen — press Approve, change your mind, press
625
+ * Request changes, and there is one pass waiting, not two. Passes from a
626
+ * DIFFERENT render stand alongside it: those are two people, or two sittings,
627
+ * and neither supersedes the other.
628
+ */
629
+ function addPending(pending, { blob, at, render }, mint = mintPendingCode) {
630
+ const kept = (pending.passes || []).filter((p) => p.render !== render)
631
+ const next = { ...pending, passes: kept }
632
+ const code = mint(next)
633
+ next.passes = [...kept, { code, at, render, blob }]
634
+ return { pending: next, code }
635
+ }
636
+
637
+ /**
638
+ * What is waiting, as the render should describe it. Pure.
639
+ *
640
+ * THE BLOB IS DELIBERATELY NOT HERE. A decision about a waiting pass needs its
641
+ * code, its verdict and its age; the notes are what a CLAIM is for. Returning
642
+ * them would put an unclaimed stranger's text into the context of whoever is
643
+ * reading the render — which is the one thing the holding area exists to defer.
644
+ *
645
+ * Oldest first, and stable: two renders in a row must name the passes in the
646
+ * same order, or a reader cannot trust the list they just read against the one
647
+ * they are about to be offered. Ties break on the code, which is unique among
648
+ * pending, so the order is total rather than merely usually-stable.
649
+ */
650
+ function describePending(pending) {
651
+ return (pending.passes || [])
652
+ .map((p) => ({
653
+ code: p.code,
654
+ // Read through the rename too: a pass POSTed by an older page says
655
+ // `approve`, and the operator must be offered the word that describes
656
+ // what claiming it would do.
657
+ verdict: readVerdict((p.blob && p.blob.verdict) || null) || null,
658
+ at: p.at || null,
659
+ }))
660
+ .sort((a, b) => String(a.at).localeCompare(String(b.at)) || a.code.localeCompare(b.code))
661
+ }
662
+
663
+ /**
664
+ * How long ago, in words a person reads at a glance. Pure.
665
+ *
666
+ * AGE IS THE TELL. A pass sent three days ago is not a review anyone in this
667
+ * conversation just pressed, and that is exactly how a stranger's pass gives
668
+ * itself away — so it is reported beside the code rather than left in a
669
+ * timestamp nobody parses.
670
+ *
671
+ * Unknown stays unknown: a pass with no `at` says so rather than being rendered
672
+ * as "just now", which is the reading that would make it look like yours.
673
+ */
674
+ function pendingAge(at, now) {
675
+ const then = Date.parse(at)
676
+ const ms = Date.parse(now) - then
677
+ if (!Number.isFinite(then) || !Number.isFinite(ms) || ms < 0) return 'unknown age'
678
+ const mins = Math.floor(ms / 60000)
679
+ if (mins < 1) return 'just now'
680
+ if (mins < 60) return `${mins} min ago`
681
+ const hours = Math.floor(mins / 60)
682
+ if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`
683
+ const days = Math.floor(hours / 24)
684
+ return `${days} day${days === 1 ? '' : 's'} ago`
685
+ }
686
+
687
+ /**
688
+ * Take a pass out of the holding area by its code. Pure.
689
+ *
690
+ * THE ONE REFUSAL HERE, and it never falls back. A code that matches nothing
691
+ * returns no pass — not "the only one", not "the most recent". Both of those
692
+ * are the same mistake: they would let a pass nobody read out reach the review,
693
+ * which is the entire thing the code exists to prevent
694
+ * (`.claude/rules/negative-checks.md` rule 4 — the unknown case does nothing).
695
+ *
696
+ * Claiming CONSUMES: the entry is gone from the returned store, so the same
697
+ * code cannot be claimed twice and an old code cannot resurrect an old pass.
698
+ */
699
+ function claimPending(pending, code) {
700
+ const passes = pending.passes || []
701
+ const at = passes.findIndex((p) => p.code === code)
702
+ if (at === -1) return { pass: null, pending, count: passes.length }
703
+ const pass = passes[at]
704
+ const rest = passes.slice(0, at).concat(passes.slice(at + 1))
705
+ return { pass, pending: { ...pending, passes: rest }, count: rest.length }
706
+ }
707
+
708
+ /**
709
+ * Validate a blob from the page, wholesale.
710
+ *
711
+ * It refuses on the FIRST problem and writes nothing, because a half-merged
712
+ * sidecar is worse than a rejected paste: you would have to work out which
713
+ * half landed. Every message names the offending entry so the answer is in the
714
+ * refusal rather than in a second investigation.
715
+ *
716
+ * Unknown keys are IGNORED rather than rejected — an older engine meeting a
717
+ * newer page should drop what it does not understand, not refuse the accepts it
718
+ * does.
719
+ */
720
+ function validateNotesBlob(blob, specFolder) {
721
+ const fail = (m) => {
722
+ throw new Error(`notes blob: ${m}`)
723
+ }
724
+ if (!blob || typeof blob !== 'object' || Array.isArray(blob)) fail('not a JSON object')
725
+ if (blob.version !== NOTES_VERSION) {
726
+ fail(`version ${JSON.stringify(blob.version)} — this engine reads version ${NOTES_VERSION}`)
727
+ }
728
+ // Pasting the wrong spec's review is the one mistake that would look entirely
729
+ // successful, so the blob names its spec and the engine checks it.
730
+ if (blob.spec && specFolder && blob.spec !== specFolder) {
731
+ fail(`written for ${blob.spec}, but this is ${specFolder}`)
732
+ }
733
+ const arr = (key) => {
734
+ const v = blob[key]
735
+ if (v === undefined || v === null) return []
736
+ if (!Array.isArray(v)) fail(`${key} must be an array`)
737
+ return v
738
+ }
739
+ const accepted = arr('accepted')
740
+ for (const a of accepted) {
741
+ if (!a || typeof a !== 'object' || Array.isArray(a)) fail('every accepted entry must be an object')
742
+ if (typeof a.path !== 'string' || !a.path) fail('an accepted entry has no path')
743
+ if (typeof a.hash !== 'string' || !a.hash) fail(`accepted entry ${a.path} has no hash`)
744
+ }
745
+ const unaccepted = arr('unaccepted')
746
+ for (const p of unaccepted) {
747
+ if (typeof p !== 'string' || !p) fail('every unaccepted entry must be a path')
748
+ }
749
+ const comments = arr('comments')
750
+ for (const c of comments) {
751
+ if (!c || typeof c !== 'object' || Array.isArray(c)) fail('every comment must be an object')
752
+ if (typeof c.id !== 'string' || !c.id) fail('a comment has no id')
753
+ if (typeof c.file !== 'string' || !c.file) fail(`comment ${c.id} has no file`)
754
+ if (typeof c.note !== 'string' || !c.note.trim()) fail(`comment ${c.id} has no note`)
755
+ if (c.line !== undefined && c.line !== null && !Number.isInteger(c.line)) {
756
+ fail(`comment ${c.id} has a non-integer line`)
757
+ }
758
+ }
759
+ // A KNOWN KEY WITH AN UNKNOWN VALUE IS REFUSED BY NAME, which is not the
760
+ // unknown-key rule above wearing a hat. Dropping `verdict: "aprove"` the way
761
+ // we drop a key we have never heard of would read as `discuss` — a review
762
+ // that silently did nothing, reported as if it had. Absent is a different
763
+ // thing from wrong, and only absent is allowed through.
764
+ let verdict = null
765
+ if (blob.verdict !== undefined && blob.verdict !== null) {
766
+ // Read through the rename BEFORE checking: a page that has not been
767
+ // reloaded still sends `approve`, and refusing it would turn a stale tab
768
+ // into a rejected review rather than a committed one.
769
+ const named = typeof blob.verdict === 'string' ? readVerdict(blob.verdict) : blob.verdict
770
+ if (typeof named !== 'string' || !VERDICTS.includes(named)) {
771
+ fail(`verdict ${JSON.stringify(blob.verdict)} is not one of ${VERDICTS.join(', ')}`)
772
+ }
773
+ verdict = named
774
+ }
775
+ return { accepted, unaccepted, comments, verdict }
776
+ }
777
+
778
+ /**
779
+ * Decide what a verdict actually does, given the notes it arrives with. Pure.
780
+ *
781
+ * THE ONE ACCUSING CHECK HERE: an approval is refused while any comment is
782
+ * unresolved. It reads a PRESENCE — an open comment, sitting in the sidecar —
783
+ * rather than an absence, so there is no lookup that could have been too narrow
784
+ * to see the thing (`.claude/rules/negative-checks.md` rule 1). The comments
785
+ * counted are whatever is in `notes` at the moment of the call, which is after
786
+ * the blob has been merged and after any `--resolve` has landed, so a note
787
+ * raised and answered in the same run does not block.
788
+ *
789
+ * WHAT WOULD FOOL THIS: nothing about the count, but the intent behind it is
790
+ * narrow. UNACCEPTED FILES ARE NOT COUNTED AND MUST NEVER BE. A comment is a
791
+ * request you made; an unticked file is merely something you said nothing
792
+ * about, and requiring every file ticked would be the counting gate this whole
793
+ * feature was designed not to become.
794
+ *
795
+ * A refused approval routes to `discuss` — report and stop — rather than
796
+ * staying `approve` with a flag beside it. Three states, and the one we cannot
797
+ * honour goes to the harmless branch (rule 4): a caller that reads `effective`
798
+ * and ignores `honoured` then talks instead of committing, which is the
799
+ * failure we can afford.
800
+ */
801
+ function judgeVerdict(verdict, notes) {
802
+ const sent = readVerdict(verdict) || null
803
+ const asked = sent || DEFAULT_VERDICT
804
+ const open = (notes.comments || []).filter((c) => !c.resolved)
805
+ const openFiles = [...new Set(open.map((c) => c.file))]
806
+ if (!COMMITTING.includes(asked) || open.length === 0) {
807
+ return { sent, effective: asked, honoured: true, reason: null, openCount: open.length, openFiles }
808
+ }
809
+ return {
810
+ sent,
811
+ effective: DEFAULT_VERDICT,
812
+ honoured: false,
813
+ reason:
814
+ `${open.length} comment${open.length === 1 ? ' is' : 's are'} unresolved ` +
815
+ `(${openFiles.join(', ')})`,
816
+ openCount: open.length,
817
+ openFiles,
818
+ }
819
+ }
820
+
821
+ /**
822
+ * Append one line to the outcome log. Pure.
823
+ *
824
+ * HISTORY, NOT STATE. Nothing reads `decisions` to decide anything — a verdict
825
+ * is consumed by the thing it asked for (a commit, or the work) and is never
826
+ * stored as a pending instruction, so an approval cannot go stale and later
827
+ * commit something nobody read. What is kept is the account of what was
828
+ * decided, when, and eventually what it produced.
829
+ */
830
+ function appendDecision(notes, { verdict, at, note = null }) {
831
+ const decisions = Array.isArray(notes.decisions) ? notes.decisions.slice() : []
832
+ decisions.push({ verdict, at, note: note === undefined ? null : note })
833
+ return { ...notes, updatedAt: at, decisions }
834
+ }
835
+
836
+ /**
837
+ * Write what a decision PRODUCED onto the last entry in the log. Pure.
838
+ *
839
+ * The verdict is logged when it is honoured, which is before the thing it asked
840
+ * for has happened — so the sha, and which path produced it, can only be added
841
+ * afterwards. That is this function.
842
+ *
843
+ * WHAT WOULD FOOL THIS: it annotates the LAST entry, whatever that is. If a
844
+ * second verdict is logged between the approval and its commit, the outcome
845
+ * lands on the wrong one. Reaching that takes two review passes interleaved
846
+ * against one sidecar, and the cost is a misfiled line in a history nothing
847
+ * reads to decide anything — so it is accepted rather than guarded, and named
848
+ * here so a later reader does not have to rediscover it.
849
+ *
850
+ * An empty log is left exactly as it was: there is no decision to describe, and
851
+ * inventing one would put an outcome in the record with no decision behind it.
852
+ */
853
+ function annotateLastDecision(notes, note) {
854
+ const decisions = Array.isArray(notes.decisions) ? notes.decisions.slice() : []
855
+ if (!decisions.length) return { notes, annotated: false }
856
+ decisions[decisions.length - 1] = { ...decisions[decisions.length - 1], note }
857
+ return { notes: { ...notes, decisions }, annotated: true }
858
+ }
859
+
860
+ /**
861
+ * Merge a validated blob into the stored notes. Pure.
862
+ *
863
+ * MERGE, NEVER REPLACE. The page only knows the render it was built from, so a
864
+ * replace would drop the agent's resolutions and let a tab left open overnight
865
+ * roll back everything recorded since. Anything the blob does not mention is
866
+ * carried through untouched — which is also why un-accepting is an EXPLICIT
867
+ * `unaccepted` list rather than absence from `accepted`.
868
+ *
869
+ * Comments merge by id, and the page mints ids from the render timestamp, so
870
+ * pasting the same blob twice is idempotent instead of doubling every note.
871
+ */
872
+ function mergeNotes(existing, blob, now) {
873
+ const notes = {
874
+ version: NOTES_VERSION,
875
+ spec: existing.spec,
876
+ updatedAt: now,
877
+ files: { ...existing.files },
878
+ comments: existing.comments.slice(),
879
+ }
880
+ // The log is history and the merge rebuilds the object from scratch, so it
881
+ // has to be carried across explicitly or every paste would erase it.
882
+ if (Array.isArray(existing.decisions)) notes.decisions = existing.decisions.slice()
883
+ for (const a of blob.accepted) notes.files[a.path] = { acceptedHash: a.hash, acceptedAt: now }
884
+ for (const p of blob.unaccepted) delete notes.files[p]
885
+
886
+ const indexById = new Map(notes.comments.map((c, i) => [c.id, i]))
887
+ for (const c of blob.comments) {
888
+ const entry = {
889
+ id: c.id,
890
+ file: c.file,
891
+ line: c.line === undefined ? null : c.line,
892
+ lineText: c.lineText === undefined ? null : c.lineText,
893
+ check: c.check === undefined ? null : c.check,
894
+ note: c.note,
895
+ raisedAt: now,
896
+ resolved: null,
897
+ }
898
+ const at = indexById.get(c.id)
899
+ if (at === undefined) {
900
+ indexById.set(c.id, notes.comments.length)
901
+ notes.comments.push(entry)
902
+ continue
903
+ }
904
+ // A re-paste refreshes the text but keeps the history: when it was first
905
+ // raised, and any resolution already written against it.
906
+ const prev = notes.comments[at]
907
+ notes.comments[at] = { ...entry, raisedAt: prev.raisedAt, resolved: prev.resolved }
908
+ }
909
+ return notes
910
+ }
911
+
912
+ /**
913
+ * Validate a resolutions file — the agent's half of the round-trip.
914
+ *
915
+ * Separate from `validateNotesBlob` because the shapes and the authors differ:
916
+ * a blob comes from a page through a clipboard, and this comes from whatever
917
+ * just finished working the comments. Same rule though — refuse wholesale, and
918
+ * name what is wrong.
919
+ */
920
+ function validateResolutions(raw) {
921
+ const fail = (m) => {
922
+ throw new Error(`resolutions: ${m}`)
923
+ }
924
+ const list = Array.isArray(raw) ? raw : raw && Array.isArray(raw.resolved) ? raw.resolved : null
925
+ if (!list) fail('expected an array of { id, note }')
926
+ for (const r of list) {
927
+ if (!r || typeof r !== 'object' || Array.isArray(r)) fail('every entry must be an object')
928
+ if (typeof r.id !== 'string' || !r.id) fail('an entry has no id')
929
+ // "Resolved" with nothing said is not a resolution — the note is the half
930
+ // that lets the next read verify the fix instead of trusting it.
931
+ if (typeof r.note !== 'string' || !r.note.trim()) fail(`entry ${r.id} has no note`)
932
+ }
933
+ return list
934
+ }
935
+
936
+ /**
937
+ * Attach resolutions to the comments they name. Pure.
938
+ *
939
+ * An id matching nothing is REPORTED AND SKIPPED, never invented and never
940
+ * fatal. A resolution naming a comment that does not exist is a mistake worth
941
+ * surfacing — but failing the whole call would throw away the work that was
942
+ * genuinely done on the ids that did match.
943
+ *
944
+ * Re-resolving overwrites rather than stacking: the newest account of what was
945
+ * done is the one that matches the code.
946
+ */
947
+ function applyResolutions(notes, resolutions, now) {
948
+ const byId = new Map(notes.comments.map((c) => [c.id, c]))
949
+ const unknown = []
950
+ let applied = 0
951
+ for (const r of resolutions) {
952
+ const c = byId.get(r.id)
953
+ if (!c) {
954
+ unknown.push(r.id)
955
+ continue
956
+ }
957
+ c.resolved = { at: now, note: r.note }
958
+ applied++
959
+ }
960
+ return { notes: { ...notes, updatedAt: now }, applied, unknown }
961
+ }
962
+
963
+ /**
964
+ * Fold the stored notes onto the collected files.
965
+ *
966
+ * `accepted` is `true` only when the recorded hash matches what is there now;
967
+ * a difference is `'lapsed'` and is SAID rather than silently forgotten, so a
968
+ * file you vouched for two phases ago cannot quietly pass as still-read.
969
+ */
970
+ function applyNotes(files, notes, hashes) {
971
+ const byFile = new Map()
972
+ for (const c of notes.comments) {
973
+ if (!byFile.has(c.file)) byFile.set(c.file, [])
974
+ byFile.get(c.file).push(c)
975
+ }
976
+ const totals = { accepted: 0, lapsed: 0, unresolved: 0, resolved: 0 }
977
+ for (const f of files) {
978
+ const hash = hashes.has(f.path) ? hashes.get(f.path) : null
979
+ f.hash = hash === undefined ? null : hash
980
+ const rec = notes.files[f.path]
981
+ if (!rec || !rec.acceptedHash) {
982
+ f.accepted = false
983
+ f.acceptedAt = null
984
+ } else if (f.hash && rec.acceptedHash === f.hash) {
985
+ f.accepted = true
986
+ f.acceptedAt = rec.acceptedAt || null
987
+ totals.accepted++
988
+ } else {
989
+ f.accepted = 'lapsed'
990
+ f.acceptedAt = rec.acceptedAt || null
991
+ totals.lapsed++
992
+ }
993
+ f.comments = byFile.get(f.path) || []
994
+ }
995
+ for (const c of notes.comments) {
996
+ if (c.resolved) totals.resolved++
997
+ else totals.unresolved++
998
+ }
999
+ // A comment on a file that is no longer in the diff (reverted, or landed in
1000
+ // an earlier phase) would otherwise vanish from the page along with its file.
1001
+ const shown = new Set(files.map((f) => f.path))
1002
+ const unanchored = notes.comments.filter((c) => !shown.has(c.file))
1003
+ return { totals, unanchored }
1004
+ }
1005
+
1006
+
1007
+ /**
1008
+ * The page template — a SHIPPED ASSET, not generated markup.
1009
+ *
1010
+ * `assets/` is in every distribution's `files`, and the build copies non-`.md`
1011
+ * assets across verbatim, so this resolves identically from the source package
1012
+ * (`packages/common/src/env` → `packages/common/assets`) and from a built
1013
+ * distribution (`src/env` → `<pkg>/assets`).
1014
+ *
1015
+ * The engine SPLICES into it and never authors markup: the model's prose arrives
1016
+ * as JSON and the diff arrives as text, so neither has to survive a round-trip
1017
+ * through generated HTML.
1018
+ */
1019
+ const TEMPLATE_PATH = path.join(__dirname, '..', '..', 'assets', 'review', 'page.html')
1020
+ const DATA_PLACEHOLDER = '__REVIEW_DATA__'
1021
+ const REVIEW_PLACEHOLDER = '__REVIEW_BLOCK__'
1022
+ const TITLE_PLACEHOLDER = '__REVIEW_TITLE__'
1023
+ const PLACEHOLDER_RE = /__REVIEW_(?:TITLE|BLOCK|DATA)__/g
1024
+
1025
+ function loadTemplate() {
1026
+ return fs.readFileSync(TEMPLATE_PATH, 'utf8')
1027
+ }
1028
+
1029
+ // Escape the one sequence that could end the data island early. A patch
1030
+ // containing `</script>` is not hypothetical — this feature reviews its own
1031
+ // source, and the template above contains one.
1032
+ function escapeIsland(json) {
1033
+ return json.replace(/<\//g, '<\\/')
1034
+ }
1035
+
1036
+ function escapeHtml(s) {
1037
+ return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
1038
+ }
1039
+
1040
+ /**
1041
+ * Splice the collected data (and, later, the written review) into the template.
1042
+ * The data goes in as JSON, never as generated markup — that is the property the
1043
+ * whole design rests on.
1044
+ */
1045
+ /**
1046
+ * The fragment form of the template: what an artifact host can accept.
1047
+ *
1048
+ * A published page is wrapped in the host's own `<!doctype>`/`<head>`/`<body>`
1049
+ * skeleton, so handing it a complete document nests two of them. The fragment
1050
+ * carries the `<title>` (hosts read it for the tab name), every `<style>` and
1051
+ * `<script>` block out of `<head>`, then the body contents — and no wrapper.
1052
+ *
1053
+ * IT SPLITS THE TEMPLATE, NEVER THE RENDERED PAGE, and that is the whole
1054
+ * correctness argument. The template contains exactly one of each boundary tag
1055
+ * and nothing but placeholders where content goes. A rendered page contains the
1056
+ * diff, and this feature reviews its own source — so `<!doctype html>`,
1057
+ * `<html>` and `<body>` appear inside it as ordinary patch text. A regex over
1058
+ * the rendered page finds those and cuts in the wrong place; the first hand
1059
+ * publish of `feat-review-offer-lands` hit exactly that, with four
1060
+ * wrapper-looking tags in the output that were all patch content.
1061
+ */
1062
+ function fragmentTemplate(template = null) {
1063
+ const t = template || loadTemplate()
1064
+
1065
+ const headStart = t.indexOf('<head>')
1066
+ const headEnd = t.indexOf('</head>')
1067
+ const bodyOpen = t.indexOf('<body')
1068
+ const bodyStart = t.indexOf('>', bodyOpen) + 1
1069
+ const bodyEnd = t.lastIndexOf('</body>')
1070
+ if (headStart < 0 || headEnd < 0 || bodyOpen < 0 || bodyEnd < 0) {
1071
+ throw new Error('review template has no <head>/<body> to split — cannot build a fragment')
1072
+ }
1073
+
1074
+ const head = t.slice(headStart, headEnd)
1075
+ const title = /<title>[\s\S]*?<\/title>/.exec(head)
1076
+ const carried = head.match(/<(style|script)\b[\s\S]*?<\/\1>/g) || []
1077
+
1078
+ return [title ? title[0] : '', ...carried, t.slice(bodyStart, bodyEnd).trim()]
1079
+ .filter(Boolean)
1080
+ .join('\n')
1081
+ }
1082
+
1083
+ /**
1084
+ * The same data, spliced into the fragment instead of the whole document. One
1085
+ * pass over the placeholders, for the same reason `renderReviewPage` uses one.
1086
+ */
1087
+ function renderReviewFragment(data, { template = null, reviewHtml = '' } = {}) {
1088
+ const values = {
1089
+ [TITLE_PLACEHOLDER]: escapeHtml(data.title),
1090
+ [REVIEW_PLACEHOLDER]: reviewHtml,
1091
+ [DATA_PLACEHOLDER]: escapeIsland(JSON.stringify(data)),
1092
+ }
1093
+ return fragmentTemplate(template).replace(PLACEHOLDER_RE, (m) => values[m])
1094
+ }
1095
+
1096
+ function renderReviewPage(data, { template = null, reviewHtml = '' } = {}) {
1097
+ const values = {
1098
+ [TITLE_PLACEHOLDER]: escapeHtml(data.title),
1099
+ [REVIEW_PLACEHOLDER]: reviewHtml,
1100
+ [DATA_PLACEHOLDER]: escapeIsland(JSON.stringify(data)),
1101
+ }
1102
+ // ONE pass, so nothing spliced in is ever rescanned. This is not theoretical:
1103
+ // the page reviews its own source, so the data island legitimately CONTAINS
1104
+ // all three placeholder strings, and sequential replaces would splice a whole
1105
+ // JSON blob into the middle of a patch — or into a review note that happened
1106
+ // to quote a placeholder name.
1107
+ return (template || loadTemplate()).replace(PLACEHOLDER_RE, (m) => values[m])
1108
+ }
1109
+
1110
+ /**
1111
+ * Render the written review into HTML.
1112
+ *
1113
+ * THE ENGINE RENDERS; THE MODEL JUDGES. The review arrives as JSON — a short
1114
+ * read plus severity-tagged checks — and this turns it into markup. The two
1115
+ * rejected alternatives: the model emitting HTML (more tokens, and one unclosed
1116
+ * tag breaks the page), and the engine parsing markdown (a markdown renderer to
1117
+ * ship and maintain for one surface).
1118
+ *
1119
+ * Every field is escaped. The review is model-authored text arriving through a
1120
+ * file, which is exactly the input that should never be trusted as markup.
1121
+ */
1122
+ const CHECK_LEVELS = ['flag', 'confirm', 'good']
1123
+
1124
+ function renderReviewBlock(review) {
1125
+ if (!review || typeof review !== 'object') return ''
1126
+ const parts = ['<section class="review">']
1127
+ if (review.summary) {
1128
+ parts.push(`<p class="review-summary">${escapeHtml(review.summary)}</p>`)
1129
+ }
1130
+ const checks = Array.isArray(review.checks) ? review.checks : []
1131
+ if (checks.length) {
1132
+ parts.push('<ul class="checks">')
1133
+ checks.forEach((c, i) => {
1134
+ // An unknown level is shown as `confirm` rather than dropped: losing a
1135
+ // reviewer's note because it was tagged oddly is worse than showing it
1136
+ // under a neutral heading.
1137
+ const level = CHECK_LEVELS.includes(c.level) ? c.level : 'confirm'
1138
+ const file = c.file ? `<span class="check-file">${escapeHtml(c.file)}</span>` : ''
1139
+ // The id is positional and therefore stable for THIS render, which is all
1140
+ // a reply needs: it travels back in the same blob the page was built from.
1141
+ // `data-file` is what lets an answer land on the file the check is about.
1142
+ const id = `k${i}`
1143
+ const fileAttr = c.file ? ` data-file="${escapeHtml(c.file)}"` : ''
1144
+ parts.push(
1145
+ `<li class="check ${level}" data-check="${id}"${fileAttr}>` +
1146
+ `<span class="check-level">${level}</span>` +
1147
+ `${file}<span class="check-note">${escapeHtml(c.note || '')}</span></li>`,
1148
+ )
1149
+ })
1150
+ parts.push('</ul>')
1151
+ }
1152
+ parts.push('</section>')
1153
+ return parts.join('\n')
1154
+ }
1155
+
1156
+ /**
1157
+ * Where a spec's published URL is remembered, and what it currently says.
1158
+ *
1159
+ * The engine READS this file and never writes it, and it does not know what the
1160
+ * string means — it cannot publish, and nothing here can. It is named here so
1161
+ * the skill that does publish never has to construct a path, which is the only
1162
+ * way the two halves stay in step.
1163
+ */
1164
+ function reviewUrlPath(outPath) {
1165
+ return outPath.replace(/\.html$/, '') + '.url'
1166
+ }
1167
+
1168
+ /**
1169
+ * What teardown says about a published page. PURE — url in, lines out.
1170
+ *
1171
+ * A published page is the ONE thing teardown cannot reclaim. The worktree goes,
1172
+ * the branch goes, the tracker assignment is released — and the page stays up,
1173
+ * because nothing in this tooling can delete it and nothing should pretend to.
1174
+ * So it is named, with where it can be removed, and that is the whole feature.
1175
+ *
1176
+ * Returns `[]` when the spec was never published, which is most specs. An
1177
+ * absence explained is noise: a teardown that mentions publishing to everyone
1178
+ * who never published is worse than one that says nothing.
1179
+ *
1180
+ * **Deliberately not a command, and never part of `run these:`.** There is no
1181
+ * command to run — removal is a person opening `/artifacts`. Folding it into the
1182
+ * batch would imply skitterspec could do it.
1183
+ */
1184
+ function publishedPageNotice(url) {
1185
+ if (!url) return []
1186
+ return [
1187
+ '',
1188
+ ' published page survives this teardown — skitterspec cannot remove it:',
1189
+ ` ${url}`,
1190
+ ' delete it yourself: /artifacts in the terminal (o opens, c copies), or',
1191
+ ' the gallery at claude.ai/code/artifacts.',
1192
+ ]
1193
+ }
1194
+
1195
+ /**
1196
+ * What to say about a review server at teardown. PURE — takes the facts as
1197
+ * arguments so a test states the world it describes.
1198
+ *
1199
+ * The server the operator never started is the one nobody remembers to stop:
1200
+ * `spec-env review` stands it up on a remote reader, and one path token unlocks
1201
+ * every provisioned spec's diff for as long as it runs. So teardown names it —
1202
+ * but only when there is genuinely nothing left for it to serve.
1203
+ *
1204
+ * `running` must come from a POSITIVE signal (`isAlive`), never from a pidfile
1205
+ * existing: a crashed process leaves one behind, and telling someone to stop a
1206
+ * server that is already gone is an accusation against a healthy teardown.
1207
+ */
1208
+ function reviewServerNotice({ running, othersServed }) {
1209
+ if (!running || othersServed > 0) return []
1210
+ return [
1211
+ '',
1212
+ ' review server — this was the last spec it served:',
1213
+ ' skitterspec spec-env review serve --stop',
1214
+ ]
1215
+ }
1216
+
1217
+ // The publish-ready copy, beside the page it came from. Same stem, so the three
1218
+ // sidecars (`.notes.json`, `.url`, `.publish.html`) all read as one spec's set.
1219
+ function reviewPublishPath(outPath) {
1220
+ return outPath.replace(/\.html$/, '') + '.publish.html'
1221
+ }
1222
+
1223
+ function readReviewUrl(outPath) {
1224
+ try {
1225
+ return fs.readFileSync(reviewUrlPath(outPath), 'utf8').trim() || null
1226
+ } catch {
1227
+ return null
1228
+ }
1229
+ }
1230
+
1231
+ // Default output path for a spec's page, under the gitignored `.spec-env/`.
1232
+ function reviewOutPath(dir, specFolder, out) {
1233
+ if (out) return path.resolve(dir, out)
1234
+ return path.join(dir, REVIEW_DIR, `${specFolder}.html`)
1235
+ }
1236
+
1237
+ /**
1238
+ * The page's path as a `file://` URL.
1239
+ *
1240
+ * A bare absolute path is not clickable in ANY terminal; `file://` is linkified
1241
+ * by iTerm2, VS Code's terminal and Terminal.app, so the page is one click from
1242
+ * the line that announces it. Printed ALONGSIDE the path, never instead of it —
1243
+ * the path is what you pass to another command, and the URL is what you click.
1244
+ *
1245
+ * Deliberately not an `open` call: that assumes a GUI, a default browser and
1246
+ * that you are sitting at the machine. The whole point of this page is that it
1247
+ * does not care where you are.
1248
+ */
1249
+ function reviewFileUrl(outPath) {
1250
+ // Encode each segment, then restore the separators — a path can legitimately
1251
+ // contain spaces, `#` or `?`, and all three break a URL left raw.
1252
+ const encoded = path
1253
+ .resolve(outPath)
1254
+ .split(path.sep)
1255
+ .map((seg) => encodeURIComponent(seg))
1256
+ .join('/')
1257
+ return `file://${encoded.startsWith('/') ? '' : '/'}${encoded}`
1258
+ }
1259
+
1260
+ // Write the page, creating its directory. Returns the absolute path written.
1261
+ function writeReviewPage(outPath, html) {
1262
+ fs.mkdirSync(path.dirname(outPath), { recursive: true })
1263
+ fs.writeFileSync(outPath, html)
1264
+ return outPath
1265
+ }
1266
+
1267
+ module.exports = {
1268
+ WHOLE_FILE_CONTEXT,
1269
+ NOTES_VERSION,
1270
+ VERDICTS,
1271
+ COMMITTING,
1272
+ readVerdict,
1273
+ DEFAULT_VERDICT,
1274
+ DELETED_HASH,
1275
+ fileHashes,
1276
+ reviewNotesPath,
1277
+ emptyNotes,
1278
+ readNotes,
1279
+ writeNotes,
1280
+ validateNotesBlob,
1281
+ validateResolutions,
1282
+ judgeVerdict,
1283
+ appendDecision,
1284
+ annotateLastDecision,
1285
+ reviewPendingPath,
1286
+ emptyPending,
1287
+ readPending,
1288
+ writePending,
1289
+ mintPendingCode,
1290
+ addPending,
1291
+ claimPending,
1292
+ describePending,
1293
+ pendingAge,
1294
+ PENDING_CODE_LENGTH,
1295
+ mergeNotes,
1296
+ applyResolutions,
1297
+ applyNotes,
1298
+ PATCH_LIMIT_BYTES,
1299
+ REVIEW_DIR,
1300
+ rawGitReader,
1301
+ isNoise,
1302
+ lines,
1303
+ parseNumstat,
1304
+ trackedFiles,
1305
+ untrackedFiles,
1306
+ collectReview,
1307
+ renderReviewPage,
1308
+ reviewOutPath,
1309
+ reviewFileUrl,
1310
+ writeReviewPage,
1311
+ escapeIsland,
1312
+ escapeHtml,
1313
+ renderReviewBlock,
1314
+ reviewUrlPath,
1315
+ reviewPublishPath,
1316
+ readReviewUrl,
1317
+ publishedPageNotice,
1318
+ reviewServerNotice,
1319
+ fragmentTemplate,
1320
+ renderReviewFragment,
1321
+ detectReader,
1322
+ resolveReader,
1323
+ CHECK_LEVELS,
1324
+ loadTemplate,
1325
+ TEMPLATE_PATH,
1326
+ DATA_PLACEHOLDER,
1327
+ REVIEW_PLACEHOLDER,
1328
+ TITLE_PLACEHOLDER,
1329
+ }