@skitterbyte/skitterspec 21.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/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
- * Resolve the specs worth listing: every spec with a worktree of its own.
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
- * A spec with no worktree is OMITTED, not listed as an error. An unstarted spec
173
- * has nothing to diff, which is the ordinary state of most of `specs/` and a
174
- * doorway that lists them as failures would be wrong about a healthy repo.
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
- if (!spec || !fs.existsSync(spec.worktreePath)) return null
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
- if (!spec || !fs.existsSync(spec.worktreePath)) return null
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
- const git = rawGitReader(spec.worktreePath)
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
- let data = collectReview({ spec, git, mode, ref, base: baseName, now, notes, gate })
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
- function specSummary(spec) {
315
- if (!spec || !fs.existsSync(spec.worktreePath)) return null
316
- const git = rawGitReader(spec.worktreePath)
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
- return recorded === running ? 'current' : 'stale'
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(
@@ -147,4 +147,11 @@ async function waitHealthy(
147
147
  return false
148
148
  }
149
149
 
150
- module.exports = { startProcess, stopProcess, waitHealthy, isAlive, readPid }
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
@@ -179,7 +179,7 @@ function assertComposedAssets() {
179
179
  const SPEC_MARKER_START = '<!-- skitterspec:start -->'
180
180
  const SPEC_MARKER_END = '<!-- skitterspec:end -->'
181
181
 
182
- const report = { created: [], updated: [], skipped: [], refused: [], removed: [], customized: [], healed: [], warnings: [] }
182
+ const report = { created: [], updated: [], skipped: [], refused: [], removed: [], customized: [], adopted: [], healed: [], warnings: [] }
183
183
 
184
184
  function resetReport() {
185
185
  for (const k of Object.keys(report)) report[k].length = 0
@@ -241,16 +241,31 @@ function managedTargets(dir) {
241
241
  }
242
242
 
243
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.
244
255
  function readManifest(dir) {
245
256
  try {
246
257
  const parsed = JSON.parse(fs.readFileSync(path.join(dir, MANIFEST_FILE), 'utf8'))
247
258
  if (parsed && typeof parsed === 'object' && parsed.files && typeof parsed.files === 'object') {
248
- return { version: parsed.version || MANIFEST_VERSION, files: parsed.files }
259
+ return {
260
+ version: parsed.version || MANIFEST_VERSION,
261
+ files: parsed.files,
262
+ baselined: Object.keys(parsed.files).length > 0,
263
+ }
249
264
  }
250
265
  } catch {
251
266
  /* missing or malformed → empty baseline */
252
267
  }
253
- return { version: MANIFEST_VERSION, files: {} }
268
+ return { version: MANIFEST_VERSION, files: {}, baselined: false }
254
269
  }
255
270
 
256
271
  function writeManifest(dir, files) {
@@ -265,6 +280,29 @@ function writeManifest(dir, files) {
265
280
  // missing — not on disk
266
281
  // pristine — ours to update: it matches the package asset, or the hash we recorded
267
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.
268
306
  //
269
307
  // `bundled` (the current package asset) is optional but decisive: a file whose
270
308
  // CONTENT equals what we ship is not customized, whatever the manifest says.
@@ -280,7 +318,8 @@ function managedState(dir, relPath, manifest, bundled) {
280
318
  const onDisk = fs.readFileSync(abs, 'utf8')
281
319
  if (bundled !== undefined && onDisk === bundled) return 'pristine'
282
320
  const known = manifest.files[relPath]
283
- return known && sha1(onDisk) === known ? 'pristine' : 'customized'
321
+ if (known) return sha1(onDisk) === known ? 'pristine' : 'customized'
322
+ return manifest.baselined ? 'adopted' : 'customized'
284
323
  }
285
324
 
286
325
  // Reconcile and persist the manifest after an install/resync run: keep prior
@@ -627,6 +666,8 @@ function checkSync(dir, { claudeMd = true, log = console.log } = {}) {
627
666
  const state = managedState(dir, relPath, manifest, bundled)
628
667
  if (state === 'missing') rows.push([relPath, 'missing — would be created'])
629
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)'])
630
671
  else if (fs.readFileSync(abs, 'utf8') !== bundled) rows.push([relPath, 'out of date — would be updated'])
631
672
  }
632
673
  const section = claudeMd ? claudeMdSectionState(dir) : 'fresh'
@@ -714,14 +755,16 @@ function resyncManagedFile(dir, target, manifest, force) {
714
755
  report[bucket].push(relPath)
715
756
  }
716
757
  if (state === 'missing') return write('created')
717
- if (state === 'customized') {
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') {
718
761
  if (force) return write('updated')
719
762
  writtenHashes[relPath] = manifest.files[relPath] || writtenHashes[relPath] // keep baseline
720
763
  // Carry the change the user just DECLINED. A bare filename tells them a
721
764
  // decision was made on their behalf but not what it was, which leaves
722
765
  // "clobber and re-apply my edits by hand" as the only safe way to upgrade.
723
766
  const { added, removed, hunks } = linesDiff(fs.readFileSync(abs, 'utf8'), bundled)
724
- return report.customized.push({ relPath, added, removed, hunks })
767
+ return report[state].push({ relPath, added, removed, hunks })
725
768
  }
726
769
  // pristine — update only if the bundled content actually changed
727
770
  if (fs.readFileSync(abs, 'utf8') === bundled) {
@@ -836,6 +879,10 @@ function printReport(dir, mode, { diff = false } = {}) {
836
879
  'customized (kept)',
837
880
  report.customized.map((c) => `${c.relPath} +${c.added} \u2212${c.removed}`),
838
881
  )
882
+ line(
883
+ 'adopted upstream (kept — ours was never installed)',
884
+ report.adopted.map((c) => `${c.relPath} +${c.added} \u2212${c.removed}`),
885
+ )
839
886
  line('manifest repaired', report.healed)
840
887
  line('unchanged', report.skipped)
841
888
  if (report.refused.length) {
@@ -851,13 +898,17 @@ function printReport(dir, mode, { diff = false } = {}) {
851
898
  process.stdout.write('\nwarnings:\n')
852
899
  for (const w of report.warnings) process.stdout.write(` ! ${w}\n`)
853
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
+ ]
854
905
  if (diff) {
855
- for (const c of report.customized) {
906
+ for (const [c, why] of declined) {
856
907
  if (!c.hunks.length) continue
857
- process.stdout.write(`\n--- ${c.relPath} (kept — this is what you declined)\n`)
908
+ process.stdout.write(`\n--- ${c.relPath} (${why})\n`)
858
909
  for (const h of c.hunks) process.stdout.write(`${h}\n`)
859
910
  }
860
- } else if (report.customized.length) {
911
+ } else if (declined.length) {
861
912
  process.stdout.write('\nRe-run with --diff to see the changes those files declined.\n')
862
913
  }
863
914
  const isolationOn = fs.existsSync(path.join(dir, 'specs', '.core', 'env.config.json'))