@skitterbyte/skitterspec-linear 11.0.0 → 13.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +260 -10
- package/README.md +32 -2
- package/assets/claude-md-section.md +48 -2
- package/assets/commands/spec-connect.md +2 -2
- package/assets/commands/spec-live.md +2 -2
- package/assets/core/SETUP.md +21 -3
- package/assets/core/env.config.json.example +9 -3
- package/assets/core/env.config.md +102 -30
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/linear.config.md +67 -8
- package/assets/review/page.html +1501 -0
- package/assets/rules/spec-planning.md +224 -15
- package/assets/rules/spec-reports.md +269 -0
- package/assets/skills/spec/SKILL.md +64 -13
- package/assets/skills/spec-bug/SKILL.md +193 -27
- package/assets/skills/spec-cancel/SKILL.md +99 -8
- package/assets/skills/spec-claim/SKILL.md +114 -0
- package/assets/skills/spec-complete/SKILL.md +123 -22
- package/assets/skills/spec-diff/SKILL.md +564 -0
- package/assets/skills/spec-hotfix/SKILL.md +202 -22
- package/assets/skills/spec-init/SKILL.md +49 -9
- package/assets/skills/spec-linear-setup/SKILL.md +86 -7
- package/assets/skills/spec-list/SKILL.md +218 -0
- package/assets/skills/spec-next/SKILL.md +300 -7
- package/assets/skills/spec-push/SKILL.md +45 -22
- package/assets/skills/spec-review/SKILL.md +59 -11
- package/assets/skills/spec-reviewed/SKILL.md +241 -0
- package/assets/skills/spec-start/SKILL.md +426 -66
- package/assets/skills/spec-status/SKILL.md +24 -2
- package/assets/skills/spec-sync/SKILL.md +47 -11
- package/assets/skills/spec-to-main/SKILL.md +42 -20
- package/package.json +11 -7
- package/src/cli.js +1710 -80
- package/src/env/building.js +143 -0
- package/src/env/classify.js +91 -0
- package/src/env/config.js +57 -9
- package/src/env/provision.js +192 -19
- package/src/env/proxy.js +34 -1
- package/src/env/render.js +3 -12
- package/src/env/resolve.js +296 -9
- package/src/env/review.js +1329 -0
- package/src/env/serve.js +549 -0
- package/src/env/teardown.js +13 -6
- package/src/gating.js +155 -0
- package/src/init.js +124 -2
- package/src/prompts.js +10 -1
- package/src/vendor/linear/api.js +104 -1
- package/src/vendor/linear/cli-sync.js +874 -17
- package/src/vendor/linear/config.js +8 -0
- package/src/vendor/linear/credentials.js +94 -0
- package/src/vendor/linear/doctor.js +35 -0
- package/src/vendor/linear/identity.js +105 -0
- package/src/vendor/linear/mcp.js +26 -0
- package/src/vendor/sync-core/index.js +6 -2
- package/src/vendor/sync-core/src/compare.js +74 -5
- package/src/vendor/sync-core/src/normalize.js +30 -0
- package/src/vendor/sync-core/src/push.js +11 -1
- package/src/vendor/sync-core/src/write.js +38 -0
- 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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
|
+
}
|