@skitterbyte/skitterspec 20.0.0 → 22.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 +304 -4
- package/README.md +113 -6
- package/assets/claude-md-section.md +29 -18
- package/assets/commands/spec-remote-review.md +22 -0
- package/assets/core/env.config.json.example +4 -2
- package/assets/core/env.config.md +103 -25
- package/assets/review/page.html +1101 -108
- package/assets/rules/spec-planning.md +39 -7
- package/assets/rules/spec-reports.md +210 -31
- package/assets/skills/spec/SKILL.md +129 -1
- package/assets/skills/spec-bug/SKILL.md +63 -43
- package/assets/skills/spec-diff/SKILL.md +183 -39
- package/assets/skills/spec-hotfix/SKILL.md +57 -43
- package/assets/skills/spec-init/SKILL.md +18 -6
- package/assets/skills/spec-next/SKILL.md +145 -60
- package/assets/skills/spec-review/SKILL.md +87 -0
- package/assets/skills/spec-reviewed/SKILL.md +31 -5
- package/assets/skills/spec-start/SKILL.md +19 -0
- package/package.json +1 -1
- package/src/cli.js +940 -116
- package/src/env/classify.js +87 -2
- package/src/env/config.js +214 -17
- package/src/env/hooks.js +49 -9
- package/src/env/live.js +94 -0
- package/src/env/resolve.js +36 -2
- package/src/env/review.js +581 -21
- package/src/env/serve.js +298 -19
- package/src/env/supervise.js +8 -1
- package/src/init.js +88 -13
- /package/assets/hooks/{review-gate.js → review-gate.cjs} +0 -0
package/src/env/serve.js
CHANGED
|
@@ -37,6 +37,8 @@ const {
|
|
|
37
37
|
resolveBaseBranch,
|
|
38
38
|
} = require('./resolve.js')
|
|
39
39
|
const { loadEnvConfig } = require('./config.js')
|
|
40
|
+
const { specDocsIn } = require('./classify.js')
|
|
41
|
+
const { liveStateFor } = require('./live.js')
|
|
40
42
|
const {
|
|
41
43
|
rawGitReader,
|
|
42
44
|
collectReview,
|
|
@@ -45,6 +47,7 @@ const {
|
|
|
45
47
|
reviewOutPath,
|
|
46
48
|
validateNotesBlob,
|
|
47
49
|
readPending,
|
|
50
|
+
passState: readPassState,
|
|
48
51
|
writePending,
|
|
49
52
|
addPending,
|
|
50
53
|
readNotes,
|
|
@@ -84,6 +87,15 @@ function routeFor(url, { token = null } = {}) {
|
|
|
84
87
|
if (segments.length === 0) return { kind: 'index' }
|
|
85
88
|
if (segments.length > 1) return { kind: 'notfound' }
|
|
86
89
|
|
|
90
|
+
// `?pass=<code>` asks what became of ONE pass, and it is checked BEFORE
|
|
91
|
+
// `?branch` because it is not a view of the diff at all — it is the page
|
|
92
|
+
// finding out whether the verdict it sent was picked up. Deliberately only
|
|
93
|
+
// ever one code wide: a route that listed what is waiting would hand a
|
|
94
|
+
// prober exactly what the six digits are bought to withhold, and the POST
|
|
95
|
+
// route was kept from being that oracle for the same reason.
|
|
96
|
+
const pass = query.get('pass')
|
|
97
|
+
if (pass !== null) return { kind: 'pass', spec: segments[0], code: pass }
|
|
98
|
+
|
|
87
99
|
// `?branch` and `?branch=1` both mean the whole-spec view; `?branch=0` does
|
|
88
100
|
// not, so a link can turn it off as well as on.
|
|
89
101
|
const raw = query.get('branch')
|
|
@@ -167,19 +179,134 @@ ${body}
|
|
|
167
179
|
}
|
|
168
180
|
|
|
169
181
|
/**
|
|
170
|
-
*
|
|
182
|
+
* WHICH VIEW A SPEC GETS, decided in one place so every route agrees.
|
|
183
|
+
*
|
|
184
|
+
* `worktree` — it has one, so the page is its branch's diff, exactly as before.
|
|
185
|
+
* `docs` — it has none, so the page is its own uncommitted documents read from
|
|
186
|
+
* the checkout this server is anchored to. That is the authoring page
|
|
187
|
+
* `spec-env review --docs` writes, and this is what lets it be SERVED: a
|
|
188
|
+
* `file://` page has no server to POST to, so without this the authoring page
|
|
189
|
+
* exists and its verdict buttons have nowhere to go.
|
|
190
|
+
* `null` — neither: no worktree and nothing uncommitted of its own, which is
|
|
191
|
+
* the ordinary state of most of `specs/` and a 404 rather than an error.
|
|
192
|
+
*
|
|
193
|
+
* WHAT WOULD FOOL A WORKTREE-ONLY VERSION of this — which is what shipped, and
|
|
194
|
+
* what 404'd a page rendered minutes earlier: an authoring page belongs to a
|
|
195
|
+
* spec with no worktree BY DEFINITION, so gating the route on a worktree
|
|
196
|
+
* excludes precisely the specs the docs view exists for.
|
|
197
|
+
*/
|
|
198
|
+
function viewFor(dir, config, spec, git, { fallback = false } = {}) {
|
|
199
|
+
if (!spec) return null
|
|
200
|
+
// LIVE COMES FIRST, because while a spec is live its worktree still exists —
|
|
201
|
+
// detached — and would win the check below while holding none of the work.
|
|
202
|
+
// `live take` checks the branch out HERE, and a fix made while live is made
|
|
203
|
+
// here too, so this checkout is the tree that answers.
|
|
204
|
+
if (spec.branch && headBranchOf(git) === spec.branch) return { kind: 'live', tree: dir }
|
|
205
|
+
const wt = spec.worktreePath
|
|
206
|
+
if (wt && wt !== dir && fs.existsSync(wt)) return { kind: 'worktree', tree: wt }
|
|
207
|
+
const found = specDocsIn(dir, spec, config, trimmedGitReader(dir))
|
|
208
|
+
// `error` is cannot-tell — not a page, and not an error page either (rule 4:
|
|
209
|
+
// the harmless branch).
|
|
210
|
+
if (found.error) return null
|
|
211
|
+
if (!found.empty) return { kind: 'docs', tree: found.tree, owned: found.owned }
|
|
212
|
+
|
|
213
|
+
// NOTHING UNCOMMITTED IS NOT NOTHING TO SHOW, on this route. A `--docs` page
|
|
214
|
+
// renders a spec's uncommitted documents, so HONOURING THE VERDICT PRESSED ON
|
|
215
|
+
// IT is what removes them: the reader presses `Commit`, the agent commits, and
|
|
216
|
+
// a reload 404s. The link was valid, the server was up and the network was
|
|
217
|
+
// fine — the page had been deliberately destroyed by the thing it asked for.
|
|
218
|
+
//
|
|
219
|
+
// So fall back to what the spec now IS, which mirrors the phase page's
|
|
220
|
+
// clean-tree fallback for the same reason: the page is rendered before the
|
|
221
|
+
// commit and read after it.
|
|
222
|
+
//
|
|
223
|
+
// ONLY FOR A NAMED PAGE, never for the index. The two routes ask different
|
|
224
|
+
// questions: the index is a menu of what AWAITS review, and every completed
|
|
225
|
+
// spec in the repo falling back would fill it with dozens of finished ones —
|
|
226
|
+
// the doorway would be wrong about a healthy repo, which is the reason it
|
|
227
|
+
// omits them in the first place. A direct request is someone opening an
|
|
228
|
+
// address they were given, and that has to keep resolving.
|
|
229
|
+
if (!fallback) return null
|
|
230
|
+
const committed = committedDocsFor(dir, config, spec)
|
|
231
|
+
if (!committed) return null
|
|
232
|
+
return { kind: 'docs-committed', tree: dir, owned: committed }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The spec's committed document paths at `HEAD`, or null when it has none.
|
|
171
237
|
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
238
|
+
* KEPT ONLY WHERE IT FINDS SOMETHING, exactly as the branch fallback is: a name
|
|
239
|
+
* that is no spec at all must stay a 404 rather than render an empty page with
|
|
240
|
+
* a commit button on it.
|
|
241
|
+
*/
|
|
242
|
+
function committedDocsFor(dir, config, spec) {
|
|
243
|
+
const git = trimmedGitReader(dir)
|
|
244
|
+
const out = git(['ls-files', '--', `specs/*/${spec.folder}/*`])
|
|
245
|
+
if (out == null) return null
|
|
246
|
+
const paths = String(out)
|
|
247
|
+
.split('\n')
|
|
248
|
+
.map((l) => l.trim())
|
|
249
|
+
.filter(Boolean)
|
|
250
|
+
return paths.length ? paths : null
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The tier rows the PAGE needs, from config alone.
|
|
255
|
+
*
|
|
256
|
+
* DELIBERATELY NOT `reviewTierStack`. That stack answers "where can this be
|
|
257
|
+
* read", which needs the server's bind, the machine's addresses and any
|
|
258
|
+
* published URL. The page needs only the half it can act on: which tiers are
|
|
259
|
+
* OFF, so it can offer the press that turns one on. An `off` tier has no URL by
|
|
260
|
+
* definition, so none of that machinery is required — and an `on` tier
|
|
261
|
+
* contributes nothing here, because the reader is standing on one of them.
|
|
262
|
+
*/
|
|
263
|
+
function pageTiers(config) {
|
|
264
|
+
const r = (config && config.review) || {}
|
|
265
|
+
return [
|
|
266
|
+
{ tier: 'network', off: !r.allowNetwork },
|
|
267
|
+
{ tier: 'remote', off: !r.allowRemote },
|
|
268
|
+
]
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The branch name a git reader's checkout is on, or null.
|
|
273
|
+
*
|
|
274
|
+
* NULL FOR A DETACHED HEAD, which `rev-parse --abbrev-ref` spells `HEAD` — a
|
|
275
|
+
* name no branch has, and one that must never compare equal to a spec's.
|
|
276
|
+
* Returning it verbatim would make a detached checkout look like a spec called
|
|
277
|
+
* `HEAD`; the cannot-tell answer is null (`.claude/rules/negative-checks.md`).
|
|
278
|
+
*/
|
|
279
|
+
function headBranchOf(git) {
|
|
280
|
+
const out = git ? git(['rev-parse', '--abbrev-ref', 'HEAD']) : null
|
|
281
|
+
if (out == null) return null
|
|
282
|
+
const name = String(out).trim()
|
|
283
|
+
return name && name !== 'HEAD' ? name : null
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// `specDocsIn` wants a reader that TRIMS, because it compares whole paths.
|
|
287
|
+
function trimmedGitReader(treePath) {
|
|
288
|
+
const git = rawGitReader(treePath)
|
|
289
|
+
return (argv) => {
|
|
290
|
+
const out = git(argv)
|
|
291
|
+
return out == null ? null : String(out).trim()
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Resolve the specs worth listing: every spec this server can render a page for.
|
|
297
|
+
*
|
|
298
|
+
* A spec with neither a worktree nor uncommitted documents of its own is
|
|
299
|
+
* OMITTED, not listed as an error — that is the ordinary state of most of
|
|
300
|
+
* `specs/`, and a doorway listing them as failures would be wrong about a
|
|
301
|
+
* healthy repo.
|
|
302
|
+
*
|
|
303
|
+
* IT INCLUDES WORKTREE-LESS SPECS NOW, and both halves are needed together: a
|
|
304
|
+
* page that serves but is absent from the index is a page nobody finds.
|
|
175
305
|
*/
|
|
176
306
|
function servableSpecs(dir, config, git) {
|
|
177
307
|
const worktreePaths = liveWorktreePaths(git)
|
|
178
308
|
return allSpecs(dir, config, worktreePaths)
|
|
179
|
-
.filter((s) =>
|
|
180
|
-
const wt = s.worktreePath
|
|
181
|
-
return wt && wt !== dir && worktreePaths.has(path.resolve(wt))
|
|
182
|
-
})
|
|
309
|
+
.filter((s) => Boolean(viewFor(dir, config, s, git)))
|
|
183
310
|
.sort((a, b) => a.folder.localeCompare(b.folder))
|
|
184
311
|
}
|
|
185
312
|
|
|
@@ -207,7 +334,15 @@ function servableSpecs(dir, config, git) {
|
|
|
207
334
|
* which is the harmless direction.
|
|
208
335
|
*/
|
|
209
336
|
function receivePass(dir, config, spec, blob) {
|
|
210
|
-
|
|
337
|
+
// A VIEW, NOT A WORKTREE. The pending store lives in the checkout this server
|
|
338
|
+
// is anchored to, so accepting a pass needs no worktree — and requiring one
|
|
339
|
+
// rejected every verdict sent from an authoring page, which is the one page
|
|
340
|
+
// whose spec never has a worktree.
|
|
341
|
+
// `fallback: true` so the POST follows the PAGE. A reader who reloads a
|
|
342
|
+
// committed docs page gets buttons; rejecting what they press would be the
|
|
343
|
+
// failure this whole area keeps producing — a control that appears to do
|
|
344
|
+
// nothing. Whether the verdict is worth acting on is the routing's call.
|
|
345
|
+
if (!spec || !viewFor(dir, config, spec, rawGitReader(dir), { fallback: true })) return null
|
|
211
346
|
let parsed
|
|
212
347
|
try {
|
|
213
348
|
parsed = validateNotesBlob(blob, spec.folder)
|
|
@@ -234,9 +369,43 @@ function receivePass(dir, config, spec, blob) {
|
|
|
234
369
|
}
|
|
235
370
|
|
|
236
371
|
function renderSpecPage(dir, config, spec, { branch = false } = {}) {
|
|
237
|
-
|
|
372
|
+
const view = viewFor(dir, config, spec, rawGitReader(dir), { fallback: true })
|
|
373
|
+
if (!view) return null
|
|
374
|
+
|
|
375
|
+
// THE DOCS VIEW IS A DIFFERENT TREE AND A DIFFERENT FILE SET, and it takes
|
|
376
|
+
// the authoring buttons: a spec with no worktree has no phase in flight, so
|
|
377
|
+
// "commit and build the next phase" is the wrong offer for it.
|
|
378
|
+
if (view.kind === 'docs' || view.kind === 'docs-committed') {
|
|
379
|
+
const docsGit = rawGitReader(view.tree)
|
|
380
|
+
const out = reviewOutPath(dir, spec.folder, null)
|
|
381
|
+
const notes = readNotes(out, spec.folder).notes
|
|
382
|
+
const gateRead = readGate(out, spec.folder)
|
|
383
|
+
const data = collectReview({
|
|
384
|
+
spec,
|
|
385
|
+
git: docsGit,
|
|
386
|
+
mode: view.kind,
|
|
387
|
+
// A committed view diffs against the commit BEFORE HEAD's spec content —
|
|
388
|
+
// there is nothing uncommitted to compare, so the whole document is the
|
|
389
|
+
// content, exactly as an untracked file renders.
|
|
390
|
+
ref: view.kind === 'docs-committed' ? 'HEAD~1' : 'HEAD',
|
|
391
|
+
now: new Date().toISOString(),
|
|
392
|
+
notes,
|
|
393
|
+
gate: gateRead.corrupt ? null : gateRead.gate,
|
|
394
|
+
buttons: 'authoring',
|
|
395
|
+
only: view.owned,
|
|
396
|
+
treePath: view.tree,
|
|
397
|
+
})
|
|
398
|
+
return {
|
|
399
|
+
html: renderReviewPage(data, { reviewHtml: renderReviewBlock(data.review) }),
|
|
400
|
+
totals: data.totals,
|
|
401
|
+
mode: data.mode,
|
|
402
|
+
fellBack: false,
|
|
403
|
+
}
|
|
404
|
+
}
|
|
238
405
|
|
|
239
|
-
|
|
406
|
+
// `view.tree` rather than `spec.worktreePath`: the two differ for a live
|
|
407
|
+
// spec, whose branch is checked out here instead.
|
|
408
|
+
const git = rawGitReader(view.tree || spec.worktreePath)
|
|
240
409
|
const trimmed = (argv) => {
|
|
241
410
|
const out = git(argv)
|
|
242
411
|
return out == null ? null : String(out).trim() || null
|
|
@@ -270,7 +439,30 @@ function renderSpecPage(dir, config, spec, { branch = false } = {}) {
|
|
|
270
439
|
// is a convenience and the gate is not what it is for.
|
|
271
440
|
const gate = gateRead.corrupt ? null : gateRead.gate
|
|
272
441
|
|
|
273
|
-
|
|
442
|
+
// WHERE ELSE THIS REVIEW LIVES, and whether it is also running — the block
|
|
443
|
+
// above the verdicts. Worktree views only: a `--docs` page returns above,
|
|
444
|
+
// because a spec with no branch has nothing to put live.
|
|
445
|
+
const live = liveStateFor(spec, {
|
|
446
|
+
isolated: true,
|
|
447
|
+
onBase: headBranchOf(rawGitReader(dir)) !== spec.branch,
|
|
448
|
+
primaryBranch: headBranchOf(rawGitReader(dir)),
|
|
449
|
+
receipt: null,
|
|
450
|
+
worktreeExists: Boolean(spec.worktreePath && fs.existsSync(spec.worktreePath)),
|
|
451
|
+
})
|
|
452
|
+
const tiers = pageTiers(config)
|
|
453
|
+
|
|
454
|
+
let data = collectReview({
|
|
455
|
+
spec,
|
|
456
|
+
git,
|
|
457
|
+
mode,
|
|
458
|
+
ref,
|
|
459
|
+
base: baseName,
|
|
460
|
+
now,
|
|
461
|
+
notes,
|
|
462
|
+
gate,
|
|
463
|
+
live,
|
|
464
|
+
tiers,
|
|
465
|
+
})
|
|
274
466
|
|
|
275
467
|
if (!branch && data.totals.files === 0) {
|
|
276
468
|
const fallbackBase = base()
|
|
@@ -285,6 +477,8 @@ function renderSpecPage(dir, config, spec, { branch = false } = {}) {
|
|
|
285
477
|
now,
|
|
286
478
|
notes,
|
|
287
479
|
gate,
|
|
480
|
+
live,
|
|
481
|
+
tiers,
|
|
288
482
|
fellBack: true,
|
|
289
483
|
})
|
|
290
484
|
if (wider.totals.files > 0) {
|
|
@@ -311,9 +505,51 @@ function renderSpecPage(dir, config, spec, { branch = false } = {}) {
|
|
|
311
505
|
* same question in one call per spec; untracked files are counted separately
|
|
312
506
|
* because `git diff` cannot see them.
|
|
313
507
|
*/
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
508
|
+
/**
|
|
509
|
+
* Counts for a docs view: numstat over just this spec's owned paths.
|
|
510
|
+
*
|
|
511
|
+
* Untracked files are counted with `--no-index` against /dev/null, which is the
|
|
512
|
+
* only way git will diff a file it does not track — and a brand-new spec is
|
|
513
|
+
* entirely untracked, so without that half the index would show a spec with
|
|
514
|
+
* four documents as `0 files`.
|
|
515
|
+
*/
|
|
516
|
+
function docsSummary(view) {
|
|
517
|
+
const git = rawGitReader(view.tree)
|
|
518
|
+
const totals = { files: 0, additions: 0, deletions: 0 }
|
|
519
|
+
const add = (row) => {
|
|
520
|
+
const [a, d] = String(row).split('\t')
|
|
521
|
+
totals.files += 1
|
|
522
|
+
if (a === '-' || d === '-') return
|
|
523
|
+
totals.additions += Number(a) || 0
|
|
524
|
+
totals.deletions += Number(d) || 0
|
|
525
|
+
}
|
|
526
|
+
for (const rel of view.owned) {
|
|
527
|
+
const tracked = git(['diff', '--numstat', 'HEAD', '--', rel])
|
|
528
|
+
const rows = String(tracked || '').split('\n').filter(Boolean)
|
|
529
|
+
if (rows.length) {
|
|
530
|
+
rows.forEach(add)
|
|
531
|
+
continue
|
|
532
|
+
}
|
|
533
|
+
const untracked = git(['diff', '--no-index', '--numstat', '--', '/dev/null', rel])
|
|
534
|
+
String(untracked || '')
|
|
535
|
+
.split('\n')
|
|
536
|
+
.filter(Boolean)
|
|
537
|
+
.forEach(add)
|
|
538
|
+
}
|
|
539
|
+
return totals
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function specSummary(spec, dir = null, config = null) {
|
|
543
|
+
// The index counts whatever the page would show, so it takes the same view.
|
|
544
|
+
// `dir`/`config` are optional so an existing caller counting a worktree spec
|
|
545
|
+
// is unchanged; without them only the worktree view can be counted.
|
|
546
|
+
const view = dir ? viewFor(dir, config, spec, rawGitReader(dir)) : null
|
|
547
|
+
if (view && view.kind === 'docs') return docsSummary(view)
|
|
548
|
+
// The tree the page would read, which for a live spec is this checkout. The
|
|
549
|
+
// worktree fallback keeps the callers that pass no `dir` working unchanged.
|
|
550
|
+
const tree = view && view.tree ? view.tree : spec && spec.worktreePath
|
|
551
|
+
if (!tree || !fs.existsSync(tree)) return null
|
|
552
|
+
const git = rawGitReader(tree)
|
|
317
553
|
|
|
318
554
|
const totals = { files: 0, additions: 0, deletions: 0 }
|
|
319
555
|
const add = (row) => {
|
|
@@ -388,7 +624,7 @@ function readBody(req, limit = MAX_PASS_BYTES) {
|
|
|
388
624
|
})
|
|
389
625
|
}
|
|
390
626
|
|
|
391
|
-
function createReviewServer({ resolveEntries, render, receive = null, token = null }) {
|
|
627
|
+
function createReviewServer({ resolveEntries, render, receive = null, passState = null, token = null }) {
|
|
392
628
|
return http.createServer((req, res) => {
|
|
393
629
|
const send = (code, body, type = 'text/html; charset=utf-8') => {
|
|
394
630
|
res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store' })
|
|
@@ -425,6 +661,22 @@ function createReviewServer({ resolveEntries, render, receive = null, token = nu
|
|
|
425
661
|
return
|
|
426
662
|
}
|
|
427
663
|
|
|
664
|
+
// THE READ THAT IS NOT A RENDER. It answers three words and never a diff,
|
|
665
|
+
// so a page polling it costs the server a file read rather than a patch.
|
|
666
|
+
//
|
|
667
|
+
// A SERVER BUILT WITHOUT THE LOOKUP SAYS SO, rather than 404ing or falling
|
|
668
|
+
// through to the diff page. An older daemon still running beside a newer
|
|
669
|
+
// page is the ordinary way here, and both of those answers would be read as
|
|
670
|
+
// something they are not — a 404 as "that pass does not exist", HTML as a
|
|
671
|
+
// parse failure. `unknown` is the one answer that keeps the command on the
|
|
672
|
+
// reader's screen, which is where it belongs when nothing can be
|
|
673
|
+
// established.
|
|
674
|
+
if (route.kind === 'pass') {
|
|
675
|
+
const out = passState ? passState(route.spec, route.code) : null
|
|
676
|
+
const state = out && typeof out.state === 'string' ? out.state : 'unknown'
|
|
677
|
+
return send(200, JSON.stringify({ state }), 'application/json; charset=utf-8')
|
|
678
|
+
}
|
|
679
|
+
|
|
428
680
|
try {
|
|
429
681
|
if (route.kind === 'index') return send(200, renderIndex(resolveEntries(), { token }))
|
|
430
682
|
const out = render(route.spec, { branch: route.branch })
|
|
@@ -496,10 +748,26 @@ function engineVersionFor(scriptPath) {
|
|
|
496
748
|
* (a bundled build, an odd install layout). Both are answered `unknown`, and
|
|
497
749
|
* neither is evidence of anything.
|
|
498
750
|
*/
|
|
499
|
-
function staleServer(recorded, running) {
|
|
751
|
+
function staleServer(recorded, running, recordedMtime, runningMtime) {
|
|
500
752
|
if (typeof recorded !== 'string' || !recorded) return 'unknown'
|
|
501
753
|
if (typeof running !== 'string' || !running) return 'unknown'
|
|
502
|
-
|
|
754
|
+
if (recorded !== running) return 'stale'
|
|
755
|
+
|
|
756
|
+
// SAME VERSION IS NOT SAME CODE, which is the whole of this addition. The
|
|
757
|
+
// daemon runs a built copy of the engine — in this repo a symlink to the
|
|
758
|
+
// gitignored dist — so a rebuild moves the file without moving the version,
|
|
759
|
+
// and a version-only comparison adopted a process running last week's code.
|
|
760
|
+
// It cost two confidently wrong diagnoses in one session: a page reported as
|
|
761
|
+
// 404ing that the new code serves, and a regression reported as unfixed that
|
|
762
|
+
// had already been fixed.
|
|
763
|
+
//
|
|
764
|
+
// CANNOT TELL ADOPTS. A settings file written before this existed records no
|
|
765
|
+
// mtime, and an unreadable script has none to offer — restarting a healthy
|
|
766
|
+
// server over an absent field is the destructive reading, so an absent half
|
|
767
|
+
// falls back to the version verdict (rule 4).
|
|
768
|
+
const both = Number.isFinite(recordedMtime) && Number.isFinite(runningMtime)
|
|
769
|
+
if (both && recordedMtime !== runningMtime) return 'stale'
|
|
770
|
+
return 'current'
|
|
503
771
|
}
|
|
504
772
|
|
|
505
773
|
module.exports = {
|
|
@@ -509,6 +777,9 @@ module.exports = {
|
|
|
509
777
|
engineVersionFor,
|
|
510
778
|
staleServer,
|
|
511
779
|
specSummary,
|
|
780
|
+
viewFor,
|
|
781
|
+
headBranchOf,
|
|
782
|
+
pageTiers,
|
|
512
783
|
routeFor,
|
|
513
784
|
renderIndex,
|
|
514
785
|
servableSpecs,
|
|
@@ -550,7 +821,7 @@ if (require.main === module) {
|
|
|
550
821
|
return {
|
|
551
822
|
folder: s.folder,
|
|
552
823
|
branch: one ? one.branch : '',
|
|
553
|
-
totals: specSummary(one),
|
|
824
|
+
totals: specSummary(one, dir, config),
|
|
554
825
|
}
|
|
555
826
|
})
|
|
556
827
|
|
|
@@ -558,6 +829,14 @@ if (require.main === module) {
|
|
|
558
829
|
resolveEntries,
|
|
559
830
|
render: (folder, opts) => renderSpecPage(dir, config, resolveOne(folder), opts),
|
|
560
831
|
receive: (folder, blob) => receivePass(dir, config, resolveOne(folder), blob),
|
|
832
|
+
// A spec this daemon cannot resolve is not a pass that was claimed — it is
|
|
833
|
+
// a lookup that could not see, so it says nothing rather than reporting the
|
|
834
|
+
// comfortable answer. Same rule the engine's own three states follow.
|
|
835
|
+
passState: (folder, code) => {
|
|
836
|
+
const one = resolveOne(folder)
|
|
837
|
+
if (!one) return { state: 'unknown' }
|
|
838
|
+
return readPassState(reviewOutPath(dir, one.folder, null), one.folder, code)
|
|
839
|
+
},
|
|
561
840
|
token,
|
|
562
841
|
})
|
|
563
842
|
startReviewServer(server, { port, host }).then(
|
package/src/env/supervise.js
CHANGED
|
@@ -147,4 +147,11 @@ async function waitHealthy(
|
|
|
147
147
|
return false
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
|
|
150
|
+
// `signalGroup` is exported for TESTS that need to simulate a process dying.
|
|
151
|
+
// Killing the recorded pid alone is not that simulation: the pid is the detached
|
|
152
|
+
// `sh -c` leader, and where the shell forks rather than execs, the real server
|
|
153
|
+
// is its child and survives. A test that reaches for `process.kill(pid, …)`
|
|
154
|
+
// therefore kills the wrapper, leaves the server holding its port, and then
|
|
155
|
+
// blames whatever it asserts next. Killing the group is what `stopProcess` does
|
|
156
|
+
// and what a test must do to mean "it died".
|
|
157
|
+
module.exports = { startProcess, stopProcess, waitHealthy, isAlive, readPid, signalGroup }
|
package/src/init.js
CHANGED
|
@@ -39,14 +39,23 @@ function listCommands() {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
// Hook scripts shipped as `assets/hooks/*.
|
|
43
|
-
// Discovered from the bundled tree like everything else, so a
|
|
44
|
-
// installs precisely what it ships and a hook can be retired by
|
|
42
|
+
// Hook scripts shipped as `assets/hooks/*.cjs` (or `*.mjs`), installed to
|
|
43
|
+
// `.claude/hooks/`. Discovered from the bundled tree like everything else, so a
|
|
44
|
+
// distribution installs precisely what it ships and a hook can be retired by
|
|
45
|
+
// deleting it.
|
|
46
|
+
//
|
|
47
|
+
// A BARE `.js` IS DELIBERATELY NOT DISCOVERED. These files are copied into the
|
|
48
|
+
// TARGET project, where that project's `package.json` — the one file skitterspec
|
|
49
|
+
// does not control — decides how node parses a `.js`. Shipping CommonJS as `.js`
|
|
50
|
+
// crashed the review-gate hook in every `"type": "module"` project, on every
|
|
51
|
+
// Bash tool call. The extension is the only thing that settles it at the file,
|
|
52
|
+
// so both accepted forms pin it; `env-review-hook.test.js` asserts the rule over
|
|
53
|
+
// the whole directory rather than over this filter.
|
|
45
54
|
function listHooks() {
|
|
46
55
|
try {
|
|
47
56
|
return fs
|
|
48
57
|
.readdirSync(path.join(ASSETS, 'hooks'))
|
|
49
|
-
.filter((f) => f.endsWith('.
|
|
58
|
+
.filter((f) => f.endsWith('.cjs') || f.endsWith('.mjs'))
|
|
50
59
|
.sort()
|
|
51
60
|
} catch {
|
|
52
61
|
return [] // a distribution may ship no hooks
|
|
@@ -170,7 +179,7 @@ function assertComposedAssets() {
|
|
|
170
179
|
const SPEC_MARKER_START = '<!-- skitterspec:start -->'
|
|
171
180
|
const SPEC_MARKER_END = '<!-- skitterspec:end -->'
|
|
172
181
|
|
|
173
|
-
const report = { created: [], updated: [], skipped: [], refused: [], removed: [], customized: [], healed: [], warnings: [] }
|
|
182
|
+
const report = { created: [], updated: [], skipped: [], refused: [], removed: [], customized: [], adopted: [], healed: [], warnings: [] }
|
|
174
183
|
|
|
175
184
|
function resetReport() {
|
|
176
185
|
for (const k of Object.keys(report)) report[k].length = 0
|
|
@@ -232,16 +241,31 @@ function managedTargets(dir) {
|
|
|
232
241
|
}
|
|
233
242
|
|
|
234
243
|
// Read the manifest (tolerant: missing/malformed → an empty baseline).
|
|
244
|
+
// `baselined` answers a question `files` cannot: could this manifest have SEEN
|
|
245
|
+
// a path? It is true only when the file parsed into the expected shape AND
|
|
246
|
+
// records at least one entry — a POSITIVE signal, not an absence, which is what
|
|
247
|
+
// lets `managedState` read a path's absence from it as evidence of anything
|
|
248
|
+
// (`.claude/rules/negative-checks.md` rule 1).
|
|
249
|
+
//
|
|
250
|
+
// WHAT WOULD FOOL IT: nothing silently. A missing manifest and a malformed one
|
|
251
|
+
// both fall into the same empty baseline below — correct for reading hashes,
|
|
252
|
+
// and useless for reasoning about absence, since on a repo with no manifest yet
|
|
253
|
+
// EVERY path is absent. Both answer `baselined: false`, so the one conclusion
|
|
254
|
+
// that depends on it is simply not drawn.
|
|
235
255
|
function readManifest(dir) {
|
|
236
256
|
try {
|
|
237
257
|
const parsed = JSON.parse(fs.readFileSync(path.join(dir, MANIFEST_FILE), 'utf8'))
|
|
238
258
|
if (parsed && typeof parsed === 'object' && parsed.files && typeof parsed.files === 'object') {
|
|
239
|
-
return {
|
|
259
|
+
return {
|
|
260
|
+
version: parsed.version || MANIFEST_VERSION,
|
|
261
|
+
files: parsed.files,
|
|
262
|
+
baselined: Object.keys(parsed.files).length > 0,
|
|
263
|
+
}
|
|
240
264
|
}
|
|
241
265
|
} catch {
|
|
242
266
|
/* missing or malformed → empty baseline */
|
|
243
267
|
}
|
|
244
|
-
return { version: MANIFEST_VERSION, files: {} }
|
|
268
|
+
return { version: MANIFEST_VERSION, files: {}, baselined: false }
|
|
245
269
|
}
|
|
246
270
|
|
|
247
271
|
function writeManifest(dir, files) {
|
|
@@ -256,6 +280,29 @@ function writeManifest(dir, files) {
|
|
|
256
280
|
// missing — not on disk
|
|
257
281
|
// pristine — ours to update: it matches the package asset, or the hash we recorded
|
|
258
282
|
// customized — on disk but differs from both — a user edit; keep it
|
|
283
|
+
// adopted — a path this version has NEWLY CLAIMED, where the project
|
|
284
|
+
// already had a file: it differs from what we ship, and the
|
|
285
|
+
// manifest that could have named it does not. Keep it too — the
|
|
286
|
+
// two states differ only in what the report CLAIMS about it.
|
|
287
|
+
//
|
|
288
|
+
// The split exists because `customized` made a false statement about the second
|
|
289
|
+
// case. "Your edit — kept" describes our file at a path upstream had not
|
|
290
|
+
// claimed, and the reader acts on it: v21 renamed the commit hook to
|
|
291
|
+
// `review-gate.cjs`, which is the same name anyone would have picked to work
|
|
292
|
+
// around v20's ESM crash — so their shim was kept, the real hook was never
|
|
293
|
+
// installed, and `review-gate.js` was pruned out from under it. The shim fails
|
|
294
|
+
// open, so the gate went silently absent under a report that said success.
|
|
295
|
+
//
|
|
296
|
+
// WHAT WOULD FOOL THIS, and why neither does:
|
|
297
|
+
// - A repo with no manifest (or an unreadable one) has EVERY path absent.
|
|
298
|
+
// `baselined` is the positive signal that gates the conclusion, so those
|
|
299
|
+
// fall back to `customized` — which keeps the file either way
|
|
300
|
+
// (`.claude/rules/negative-checks.md` rules 1 and 4).
|
|
301
|
+
// - The signal is ONE-SHOT. `flushManifest`'s migration seed gives any
|
|
302
|
+
// present managed file an entry on the very next run, so an adopted path
|
|
303
|
+
// reads `customized` from then on. Anyone who already upgraded has spent
|
|
304
|
+
// it — which is why the `.cjs` trap is also written into MIGRATION.md,
|
|
305
|
+
// where that reader is actually looking.
|
|
259
306
|
//
|
|
260
307
|
// `bundled` (the current package asset) is optional but decisive: a file whose
|
|
261
308
|
// CONTENT equals what we ship is not customized, whatever the manifest says.
|
|
@@ -271,7 +318,8 @@ function managedState(dir, relPath, manifest, bundled) {
|
|
|
271
318
|
const onDisk = fs.readFileSync(abs, 'utf8')
|
|
272
319
|
if (bundled !== undefined && onDisk === bundled) return 'pristine'
|
|
273
320
|
const known = manifest.files[relPath]
|
|
274
|
-
|
|
321
|
+
if (known) return sha1(onDisk) === known ? 'pristine' : 'customized'
|
|
322
|
+
return manifest.baselined ? 'adopted' : 'customized'
|
|
275
323
|
}
|
|
276
324
|
|
|
277
325
|
// Reconcile and persist the manifest after an install/resync run: keep prior
|
|
@@ -412,6 +460,12 @@ function registerReviewGateHook(dir) {
|
|
|
412
460
|
report.created.push(label)
|
|
413
461
|
} else if (res.reason === 'added') {
|
|
414
462
|
report.updated.push(label)
|
|
463
|
+
} else if (res.reason === 'migrated') {
|
|
464
|
+
// A WRITE, so it may not fall through to `skipped`. It was doing exactly
|
|
465
|
+
// that: rewriting the registered path and then reporting "already
|
|
466
|
+
// registered" — a run that acts and says it did not, which is the shape of
|
|
467
|
+
// the bug this whole change exists to fix.
|
|
468
|
+
report.updated.push('.claude/settings.json (review-gate hook repointed at the renamed script)')
|
|
415
469
|
} else {
|
|
416
470
|
report.skipped.push('.claude/settings.json (review-gate hook already registered)')
|
|
417
471
|
}
|
|
@@ -612,6 +666,8 @@ function checkSync(dir, { claudeMd = true, log = console.log } = {}) {
|
|
|
612
666
|
const state = managedState(dir, relPath, manifest, bundled)
|
|
613
667
|
if (state === 'missing') rows.push([relPath, 'missing — would be created'])
|
|
614
668
|
else if (state === 'customized') rows.push([relPath, 'your edit — kept (--force overwrites)'])
|
|
669
|
+
else if (state === 'adopted')
|
|
670
|
+
rows.push([relPath, 'adopted upstream — yours kept, theirs not installed (--force takes theirs)'])
|
|
615
671
|
else if (fs.readFileSync(abs, 'utf8') !== bundled) rows.push([relPath, 'out of date — would be updated'])
|
|
616
672
|
}
|
|
617
673
|
const section = claudeMd ? claudeMdSectionState(dir) : 'fresh'
|
|
@@ -699,14 +755,16 @@ function resyncManagedFile(dir, target, manifest, force) {
|
|
|
699
755
|
report[bucket].push(relPath)
|
|
700
756
|
}
|
|
701
757
|
if (state === 'missing') return write('created')
|
|
702
|
-
|
|
758
|
+
// `adopted` writes exactly as `customized` does — keep unless forced. Only the
|
|
759
|
+
// bucket differs, because only the REPORT was ever wrong about it.
|
|
760
|
+
if (state === 'customized' || state === 'adopted') {
|
|
703
761
|
if (force) return write('updated')
|
|
704
762
|
writtenHashes[relPath] = manifest.files[relPath] || writtenHashes[relPath] // keep baseline
|
|
705
763
|
// Carry the change the user just DECLINED. A bare filename tells them a
|
|
706
764
|
// decision was made on their behalf but not what it was, which leaves
|
|
707
765
|
// "clobber and re-apply my edits by hand" as the only safe way to upgrade.
|
|
708
766
|
const { added, removed, hunks } = linesDiff(fs.readFileSync(abs, 'utf8'), bundled)
|
|
709
|
-
return report.
|
|
767
|
+
return report[state].push({ relPath, added, removed, hunks })
|
|
710
768
|
}
|
|
711
769
|
// pristine — update only if the bundled content actually changed
|
|
712
770
|
if (fs.readFileSync(abs, 'utf8') === bundled) {
|
|
@@ -728,6 +786,15 @@ function resync(dir, { force = false, claudeMd = true, diff = false } = {}) {
|
|
|
728
786
|
installFolders(dir)
|
|
729
787
|
removeRetiredFiles(dir)
|
|
730
788
|
pruneRetiredManaged(dir, manifest)
|
|
789
|
+
// The hook SCRIPT arrives above, as one more managed target; registering it is
|
|
790
|
+
// a separate write to a file we do not manage, so it has to be asked for here.
|
|
791
|
+
// It is easy to read this as duplication of `installHooks()` and delete it —
|
|
792
|
+
// it is not. `installHooks()` is unreachable from this path, and the two
|
|
793
|
+
// halves being in different functions is exactly how they drifted apart once:
|
|
794
|
+
// `update` copied `.claude/hooks/` and registered nothing, for every upgrading
|
|
795
|
+
// project, while reporting a file created. `init-review-gate-hook.test.js`
|
|
796
|
+
// asserts over the entry points rather than over this call.
|
|
797
|
+
registerReviewGateHook(dir)
|
|
731
798
|
if (claudeMd) installClaudeMd(dir, { mode: 'update' })
|
|
732
799
|
flushManifest(dir)
|
|
733
800
|
printReport(dir, 'resync', { diff })
|
|
@@ -812,6 +879,10 @@ function printReport(dir, mode, { diff = false } = {}) {
|
|
|
812
879
|
'customized (kept)',
|
|
813
880
|
report.customized.map((c) => `${c.relPath} +${c.added} \u2212${c.removed}`),
|
|
814
881
|
)
|
|
882
|
+
line(
|
|
883
|
+
'adopted upstream (kept — ours was never installed)',
|
|
884
|
+
report.adopted.map((c) => `${c.relPath} +${c.added} \u2212${c.removed}`),
|
|
885
|
+
)
|
|
815
886
|
line('manifest repaired', report.healed)
|
|
816
887
|
line('unchanged', report.skipped)
|
|
817
888
|
if (report.refused.length) {
|
|
@@ -827,13 +898,17 @@ function printReport(dir, mode, { diff = false } = {}) {
|
|
|
827
898
|
process.stdout.write('\nwarnings:\n')
|
|
828
899
|
for (const w of report.warnings) process.stdout.write(` ! ${w}\n`)
|
|
829
900
|
}
|
|
901
|
+
const declined = [
|
|
902
|
+
...report.customized.map((c) => [c, 'kept — this is what you declined']),
|
|
903
|
+
...report.adopted.map((c) => [c, 'kept — this is what was not installed']),
|
|
904
|
+
]
|
|
830
905
|
if (diff) {
|
|
831
|
-
for (const c of
|
|
906
|
+
for (const [c, why] of declined) {
|
|
832
907
|
if (!c.hunks.length) continue
|
|
833
|
-
process.stdout.write(`\n--- ${c.relPath} (
|
|
908
|
+
process.stdout.write(`\n--- ${c.relPath} (${why})\n`)
|
|
834
909
|
for (const h of c.hunks) process.stdout.write(`${h}\n`)
|
|
835
910
|
}
|
|
836
|
-
} else if (
|
|
911
|
+
} else if (declined.length) {
|
|
837
912
|
process.stdout.write('\nRe-run with --diff to see the changes those files declined.\n')
|
|
838
913
|
}
|
|
839
914
|
const isolationOn = fs.existsSync(path.join(dir, 'specs', '.core', 'env.config.json'))
|
|
File without changes
|