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