@sabaiway/agent-workflow-kit 7.1.0 → 7.3.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.
@@ -23,12 +23,12 @@ Read in order, then confirm before starting:
23
23
  4. Confirm with the user: *"I'm taking task X. Confirm?"*
24
24
 
25
25
  ### 1.2. During Work
26
- **Before any feature:** read the relevant page spec (`docs/ai/pages/<page>.md`). If behaviour changes, update the spec FIRST so docs and code never diverge.
26
+ **Before any feature:** name its governing spec(s) — the feature spec under `docs/ai/specs/` for every touched spec-covered slice (zero is legal during adoption; each cited spec's Out of scope bounds that slice's work). Where only a page spec (`docs/ai/pages/<page>.md`) exists it governs as an ADOPTION SHIM: state Out of scope + Revision inline in the plan. If the contract changes, the spec revision is authored WITH the plan (visible at review) and lands with the code, so docs and code never diverge.
27
27
 
28
28
  **For every code change:**
29
29
  1. Grep for similar implementations — reuse existing patterns.
30
30
  2. Check the design-system layer for an existing component; if missing, add it there FIRST, then use it.
31
- 3. Verify changes align with `docs/ai/pages/<page>.md`; for a new page, create a full spec.
31
+ 3. Verify changes align with the governing spec(s) and `docs/ai/pages/<page>.md`; a new feature gets a `draft` spec (the skill's `SPEC_TEMPLATE.md`), a new page a full page spec.
32
32
  4. Follow §2 (Self-Review): functional style, named exports, full variable names, no magic literals.
33
33
  5. Write/update tests FIRST (TDD): unit for pure functions, E2E for user flows.
34
34
  6. Run quality checks: lint, type-check, tests.
@@ -36,7 +36,7 @@ Read in order, then confirm before starting:
36
36
  ### 1.3. Task Completion
37
37
  Before claiming "done":
38
38
  1. Run all quality gates (lint + type-check + tests) — all green.
39
- 2. Update docs: `current_state.md` (feature ready), `changelog.md` (entry), `handover.md` (**REPLACE** the last-session block — session delta, never append; older deltas live in `changelog.md` → `history/`), `pages/<page>.md` (matches implementation). Only bump "Last Updated" when content actually changed.
39
+ 2. Update docs: `current_state.md` (feature ready), `changelog.md` (entry), `handover.md` (**REPLACE** the last-session block — session delta, never append; older deltas live in `changelog.md` → `history/`), `pages/<page>.md` (matches implementation), the governing spec (its revision lands with the code: `draft` -> `live` on the landing row). Only bump "Last Updated" when content actually changed.
40
40
  3. Run the docs cap-validator + index-freshness gate (pre-commit also enforces). On failure: trim the offending file, or run the changelog rotation if the offender is `changelog.md`.
41
41
  4. If the work executed a plan file — run that plan's final **Phase: Cleanup** (see the planning skill / §5). Without it the plan is not done.
42
42
  5. **Ask before committing** (§4): report lint / type-check / test counts + docs status, then wait for explicit approval. DO NOT auto-commit.
@@ -78,9 +78,11 @@ Apply these when authoring a plan, reviewing, folding a finding, or editing code
78
78
  - **Fold by code, not prose.** Before folding a code-touching finding into a plan or change, read the cited `file:line` and cite it — a prose fold drifts from the code and seeds the next bug.
79
79
  - **Finding scope (plan-execution) — name the invariant BEFORE the edit.** During EXECUTION only — a plan under authoring has no shipped behaviour to call a live defect in, so plan-review carries none of this. Every finding names the invariant its fix would enforce, and where that invariant already lives decides the disposition: already an acceptance criterion of the phase → **fold here**; it would have to be ADDED → ship the **narrow fix** for the found site (red first, then green) and queue ONLY the generalization — a deferral row carries the invariant, the origin `file:line`, the narrow fix, its proof and a residual exposure declared NOT live; no correct narrow fix → **blocking**: the phase does not close, and it is **never queued**. Two bars declared before each round: a finding counts only if it changes a **WRITE/REMOVE decision** or is a false statement in shipped text; a repeat finding in one subarea **routes to SUBTRACTION**, not a fourth patch.
80
80
  - **Right altitude.** Pin intent + invariants + acceptance criteria (named tests); leave fine code-mechanics to Execute, where prose cannot diverge from reality.
81
+ - **Spec-first.** A plan names its GOVERNING spec(s) — zero, one or many, one per touched spec-covered slice (the feature spec under `docs/ai/specs/`; page-only coverage governs as an ADOPTION SHIM, with Out of scope + Revision stated inline in the plan). Each cited spec's Out of scope bounds that slice's work and the plan's non-goals restate it per slice — no global union; a cross-spec conflict is resolved by a spec revision BEFORE approval, never by silent precedence. A NEW feature's draft spec exists AT plan review (a `create` row); a change to a governed contract rides the plan as its proposed revision (a `modify` row); approval confirms plan and contract atomically, and the revision lands with the code. Scenario bindings are per scenario: a new scenario is `unbound` until its test lands in the same plan, and a status never regresses for an extension.
81
82
  - **No code-mechanics in the plan.** A ledger row carries its path and anchor, and Verification carries the exact commands (the plan-shape canon) — checked syntax: the plan's own Verification runs them against an explicit expected outcome or gate; the only other syntax a plan may carry is a literal fixture/schema fragment a named test copies or validates. Un-run, logic-bearing syntax — control-flow, a regex, a glob, a grammar, an algorithm body, a mini-DSL — never lives in plan prose, however plausible or shell-verified it looks: a fold or draft that wants one is the trigger to write the test instead.
82
83
  - **Test-as-spec.** Fold a code-touching finding into a red→green TEST, not a prose paragraph — the gate is the only deterministic checker; a paragraph cannot self-check.
83
84
  - **Characterize-first.** Before editing UNCOVERED code, pin its current behavior in a green test, then edit — any unintended change goes red. Never edit what has no checker; first give it one. Keep edits atomic/reversible; prefer SUBTRACTIVE folds.
85
+ - **State table BEFORE the guard — enumerate by PROOF, never by exclusion.** The subtraction rule above fires on a repeat finding, which is a LATE signal: by then the review has paid for each miss. The EARLY signal is structural — a decision whose input has **several independent state dimensions** (is it tracked? do the bytes still match the source? does the neighbouring file exist?). Write the table first, admit the write with **ONE conjunction of proven facts**, and funnel every other cell into a single refusal; the table is then the table-driven test. An exclusion list (`if (bad1) return; if (bad2) return;`) fails **OPEN** on the first state nobody enumerated — and "unreadable" is a state, distinct from "absent". A reviewer cannot save you here: it judges the patch in front of it and can only name the NEXT missing state, one round at a time.
84
86
  - **Fold minimally — prose has no checker.** An ephemeral, gitignored plan is prose with no executable checker; fold **minimally, in ONE place** and run a **self-consistency** read across the plan before every re-review — a fold that drifts several prose spots is what turns a 2-round review into churn.
85
87
  - **Heavy review at the diff.** Plan-review settles architecture only (≤2 rounds, stop at the pre-existing→fold-induced crossover); the exhaustive per-row review runs against real compiling code + the full suite, where a regression fails a gate immediately. **Backend divergence** (one backend grounded-ships while another keeps revising mechanics) IS that crossover — resolve at altitude, don't exhaust the strictest backend; route an all-mechanics/CI or prose-only artifact to a **thin plan + diff-review**.
86
88
  - **Convergence bar.** A review loop is CLEAN only when one round returns **0 blockers + 0 majors** from EVERY backend the recipe names (nits + a ship verdict is the stop). Folding ≠ convergence — re-review after folding.
@@ -0,0 +1,21 @@
1
+ ---
2
+ type: spec
3
+ lastUpdated: {{DATE}}
4
+ scope: permanent
5
+ staleAfter: 90d
6
+ owner: none
7
+ maxLines: 80
8
+ kind: index
9
+ ---
10
+
11
+ # Specs
12
+
13
+ > Up: [technical_specification.md](../technical_specification.md)
14
+
15
+ The top spec is the system root; this store holds the per-feature contracts under it. Every folder's
16
+ `index.md` lists ONLY its immediate children (at most 30); a spec over 150 lines is promoted to
17
+ `<slug>/index.md` + parts. Author a new spec from the skill's `SPEC_TEMPLATE.md`; the canon is the
18
+ engine's `references/specs.md`.
19
+
20
+ ## Children
21
+
@@ -33,7 +33,12 @@ export const stop = (message, fields = {}) =>
33
33
  // may have unrelated scripts). `/.claude/settings.json` is carried HIDDEN-ONLY: in hidden mode the
34
34
  // kit's own attribution file is a footprint; in visible mode the kit commits it and never runs this
35
35
  // tool. It passes the same tracked→ASK classifier, so a project that already commits it gets an ASK,
36
- // never a silent un-track. `/docs/plans/` + both `.claude/settings*.json` are listed because a pure
36
+ // never a silent un-track. `/.mcp.json` (the `mcp` mode's registration) is the second shared
37
+ // container carried on those terms — the kit merges into it and preserves foreign servers, so the
38
+ // same tracked→ASK arm is what protects a team that commits its own. It is seated here rather than
39
+ // in KNOWN_FOOTPRINT deliberately: KIT_OWN candidacy is unconditional, so the path is in the block
40
+ // BEFORE a later `mcp --apply` writes the file — present-only candidacy would re-open the very
41
+ // follow-up-reconcile gap this entry closes. Never provisioned into a worktree (worktrees.mjs). `/docs/plans/` + both `.claude/settings*.json` are listed because a pure
37
42
  // hidden deploy has no tracked `.gitignore`; the classifier drops any candidate a tracked `.gitignore`
38
43
  // already covers, so in a repo that DOES track those ignores they are never re-written.
39
44
  // The enumeration must cover EVERY file the deploy copies into `scripts/` (bootstrap step 8 copies
@@ -64,9 +69,12 @@ export const KIT_OWN_PATHS = [
64
69
  '/scripts/migrate-gates-branches.test.mjs',
65
70
  '/scripts/migrate-gates.mjs',
66
71
  '/scripts/migrate-gates.test.mjs',
72
+ '/scripts/spec-schema.mjs',
73
+ '/scripts/spec-schema.test.mjs',
67
74
  '/docs/plans/',
68
75
  '/.claude/settings.local.json',
69
76
  '/.claude/settings.json',
77
+ '/.mcp.json',
70
78
  ];
71
79
 
72
80
  // Every OTHER tool's footprint. `falsePositiveRisk` flags a name generic/ambiguous enough that a
@@ -183,6 +183,76 @@ export const decideSettingsText = (text, allowRules) => {
183
183
  };
184
184
  };
185
185
 
186
+ // The token-removal COMPLEMENT of mergeSettings (not its exact inverse — merging into an empty file
187
+ // and removing again leaves the managed containers behind), for a settings copy moved away from its `.mcp.json`
188
+ // (a provisioned worktree: it gets no launcher, so a copied enable would be a client error on every
189
+ // client start). Removes ONLY our own tokens — our `SERVER_NAME` membership and our derived allow
190
+ // rules — and preserves every foreign server, foreign rule, foreign key and the file's EOL. Returns
191
+ // `{ text, changed, hasTokens, reason }` (the four outcomes are tabulated at the function); `text`
192
+ // is the input byte-for-byte whenever `changed` is false — this never repairs, and never edits what
193
+ // it did not understand.
194
+ // A JSON round-trip is not content-preserving for everything a file may legally carry, and this
195
+ // function rewrites a file it does NOT own — so each way the trip can lose data is detected and
196
+ // REFUSES the rewrite whole. Two are known and both are checked on the SOURCE TEXT, because after
197
+ // `JSON.parse` the evidence is already gone:
198
+ // • a number past double precision (9007199254740993 → …992) comes back a different value;
199
+ // • a duplicate key ({"a":1,"a":2}) collapses to the last one, dropping foreign data silently.
200
+ // String bodies are blanked to `""` first, so a digit run or a colon inside a string is never read
201
+ // as a number or a key. Deliberately strict on numbers: `1.0` and `1e2` are re-spellings rather
202
+ // than losses and are refused too — fail-closed is the correct direction here.
203
+ const blankStrings = (text) => text.replace(/"(?:[^"\\]|\\.)*"/g, '""');
204
+ const JSON_NUMBER = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][-+]?\d+)?/g;
205
+
206
+ const countKeys = (value) => (Array.isArray(value)
207
+ ? value.reduce((n, v) => n + countKeys(v), 0)
208
+ : isPlainObject(value)
209
+ ? Object.entries(value).reduce((n, [, v]) => n + 1 + countKeys(v), 0)
210
+ : 0);
211
+
212
+ // Why a COUNT: a duplicate at any depth makes the source carry more key tokens than the parsed
213
+ // value has keys. No second JSON parser, and nothing depends on where the duplicate sits.
214
+ const roundTripLoss = (text, parsed) => {
215
+ const blanked = blankStrings(text);
216
+ const numbers = blanked.match(JSON_NUMBER) ?? [];
217
+ if (!numbers.every((lit) => String(JSON.parse(lit)) === lit)) return 'a number would not survive the rewrite';
218
+ const keyTokens = (blanked.match(/""\s*:/g) ?? []).length;
219
+ if (keyTokens !== countKeys(parsed)) return 'a duplicate key would be collapsed by the rewrite';
220
+ return null;
221
+ };
222
+
223
+ // Four outcomes over `{ text, changed, hasTokens, reason }`, and a caller must be able to tell them
224
+ // apart — `changed:false` alone conflates
225
+ // "nothing of ours is here" with "ours is here and the rewrite was REFUSED", and the second one owes
226
+ // the operator a word:
227
+ // unreadable → {changed:false, hasTokens:false, reason:'…'} not JSON of the shape we write
228
+ // nothing of ours → {changed:false, hasTokens:false, reason:null}
229
+ // refused (lossy) → {changed:false, hasTokens:true, reason:'…'} tokens stay, say why
230
+ // rewritten → {changed:true, hasTokens:true, reason:null}
231
+ export const withoutRegistration = (text, allowRules = allowRulesFor()) => {
232
+ const keep = (hasTokens, reason) => ({ text, changed: false, hasTokens, reason });
233
+ const decided = decideSettingsText(text, allowRules);
234
+ if (decided.state !== STATE.PRESENT) return keep(false, 'not readable as settings JSON');
235
+ if (!decided.enabled && decided.allowPresent.length === 0) return keep(false, null);
236
+ const loss = roundTripLoss(text, decided.data);
237
+ if (loss !== null) return keep(true, loss);
238
+ const { [ENABLED_KEY]: enabledList, permissions } = decided.data;
239
+ const keptEnabled = (Array.isArray(enabledList) ? enabledList : []).filter((n) => n !== SERVER_NAME);
240
+ const keptAllow = (isPlainObject(permissions) && Array.isArray(permissions.allow) ? permissions.allow : [])
241
+ .filter((rule) => !allowRules.includes(rule));
242
+ // Only an allow array that ALREADY existed is rewritten — synthesising `allow: []` where the file
243
+ // had no such key would be adding foreign content under the name of removing ours.
244
+ const nextPermissions = isPlainObject(permissions) && Array.isArray(permissions.allow)
245
+ ? { ...permissions, allow: keptAllow }
246
+ : permissions;
247
+ // Rebuilt through the original order of the NON-INDEX keys — a settings file the user reads should
248
+ // not be reordered by a removal it did not ask for. Integer-like keys are the stated exception:
249
+ // any JSON round-trip hoists them to the front, because JS object semantics say so.
250
+ const next = Object.fromEntries(Object.entries(decided.data).map(([key, value]) => (
251
+ key === ENABLED_KEY ? [key, keptEnabled] : key === 'permissions' ? [key, nextPermissions] : [key, value]
252
+ )));
253
+ return { text: formatJson(next, decided.eol), changed: true, hasTokens: true, reason: null };
254
+ };
255
+
186
256
  // readRegistration(root, io?) → the full registration picture of ONE project. `io.serverPath`
187
257
  // overrides the running kit's server path (tests); every fs primitive in `io` is the fs-read-nofollow
188
258
  // injection contract. NEVER throws.
package/tools/mcp.mjs CHANGED
@@ -185,16 +185,35 @@ export const writeMcp = ({ cwd, dryRun = true } = {}, deps = {}) => {
185
185
  writeContainedFileAtomic(root, registration.mcpJson.abs, plan.mcpBody, deps, { stop, label: MCP_JSON_REL });
186
186
  }
187
187
  if (plan.writeSettings) {
188
- // The ONE write the preflight deliberately does not do: creating `.claude/` is a mutation, so it
189
- // belongs on the apply lane only a preview that made a directory would not be a preview.
190
- assertCreatableDirSafe(join(root, CLAUDE_DIR_REL), deps, { stop, noun: SETTINGS_REL });
191
- writeContainedFileAtomic(root, registration.settings.abs, plan.settingsBody, deps, { stop, label: SETTINGS_REL });
188
+ // A settings failure AFTER the entry landed leaves a STANDING registration on disk that never
189
+ // reaches formatResult so the reconcile note has to ride the failure too, or a hidden
190
+ // deployment is left with a visible `.mcp.json` and no mention of it anywhere.
191
+ // The catch is around the WRITE, not inside it: a raw fs error (a failing rename) never passes
192
+ // through the injected stop, and it is exactly the case that strands a standing registration.
193
+ try {
194
+ // The ONE write the preflight deliberately does not do: creating `.claude/` is a mutation, so
195
+ // it belongs on the apply lane only — a preview that made a directory would not be a preview.
196
+ assertCreatableDirSafe(join(root, CLAUDE_DIR_REL), deps, { stop, noun: SETTINGS_REL });
197
+ writeContainedFileAtomic(root, registration.settings.abs, plan.settingsBody, deps, { stop, label: SETTINGS_REL });
198
+ } catch (err) {
199
+ // Reaching here always leaves a STANDING registration on disk — either this run wrote the
200
+ // entry (`writeMcpJson`) or the preflight found it already current (`matches`), and those two
201
+ // are exhaustive, so the note is unconditional rather than guarded by a branch nothing can
202
+ // take. A differing entry never reaches the writer at all; it STOPs in the preflight.
203
+ throw Object.assign(err, { message: `${err.message}\n${HIDDEN_MODE_LINE}` });
204
+ }
192
205
  }
193
206
  return { ...base, wrote: plan.writeMcpJson || plan.writeSettings };
194
207
  };
195
208
 
196
209
  // ── the report ─────────────────────────────────────────────────────────────────────────
197
210
 
211
+ // A registration is an AI-tool footprint: `/.mcp.json` is in the known-footprint registry, so a
212
+ // HIDDEN deployment needs the reconcile before `git status` is clean again. Stated conditionally —
213
+ // this mode never detects visibility, and detecting it would widen what it reads.
214
+ const HIDDEN_MODE_LINE =
215
+ 'hidden-mode note: if this deployment is hidden, run the hide-footprint reconcile so the registration stays out of `git status` (the registry carries /.mcp.json).';
216
+
198
217
  const POSTURE_LINE =
199
218
  'trust posture: the registered server is a READ-ONLY child of your MCP client (path/type/size/line facts and literal search over this project root) — it runs OUTSIDE the Bash sandbox, as the client itself does, and exposes no write or exec API. The two allow rules make its tool calls promptless; nothing else in this project changes.';
200
219
 
@@ -229,6 +248,7 @@ const maskedReport = (result) => {
229
248
  ` merge into ${SETTINGS_REL} (that file was observable — and read where present — so this body already carries what is in it):`,
230
249
  indented(result.fragments.settings),
231
250
  POSTURE_LINE,
251
+ HIDDEN_MODE_LINE,
232
252
  ].join(LF);
233
253
  };
234
254
 
@@ -236,7 +256,7 @@ export const formatResult = (result) => {
236
256
  if (result.masked) return maskedReport(result);
237
257
  const nothingToDo = !result.plan.writeMcpJson && !result.plan.writeSettings;
238
258
  if (nothingToDo) {
239
- return [`agent-workflow MCP registration — already registered ("${SERVER_NAME}"); nothing to do.`, POSTURE_LINE].join(LF);
259
+ return [`agent-workflow MCP registration — already registered ("${SERVER_NAME}"); nothing to do.`, POSTURE_LINE, HIDDEN_MODE_LINE].join(LF);
240
260
  }
241
261
  const lines = [
242
262
  result.dryRun
@@ -247,6 +267,7 @@ export const formatResult = (result) => {
247
267
  ' the entry this registration declares (re-serialized here; the same structured value goes into the file):',
248
268
  indented(JSON.stringify(result.registration.entry, null, JSON_INDENT)),
249
269
  POSTURE_LINE,
270
+ HIDDEN_MODE_LINE,
250
271
  ];
251
272
  if (result.dryRun) lines.push(` to apply: ${applyMcpCommand(result.root)}`);
252
273
  return lines.join(LF);
@@ -16,6 +16,7 @@ import { join, dirname, basename, resolve, relative, isAbsolute, sep } from 'nod
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { spawnSync } from 'node:child_process';
18
18
  import { randomBytes } from 'node:crypto';
19
+ import { MCP_JSON_REL, SERVER_NAME, SETTINGS_REL, withoutRegistration } from './mcp-registration.mjs';
19
20
  import {
20
21
  KIT_OWN_PATHS, KNOWN_FOOTPRINT, expandGlob, normalizeSlashes, isDirPattern, isGlobPattern,
21
22
  patternToProbe,
@@ -65,6 +66,9 @@ const EXCLUDED_BASENAMES = new Set([
65
66
  ]);
66
67
  // Registry entries provision seeds by its own rules instead of copying wholesale.
67
68
  const SEEDED_SEPARATELY = new Set([`/${PLANS_REL}/`]);
69
+ // Never copied AND never owned — unlike SEEDED_SEPARATELY, whose members cleanup may still remove.
70
+ // A satellite's own `.mcp.json` must stay FOREIGN or an ordinary cleanup deletes what we never placed.
71
+ const NEVER_PROVISIONED = new Set(['/.mcp.json']);
68
72
  // Copied files whose absolute main-root pins are rebased onto the worktree root.
69
73
  const REBASE_TARGETS = Object.freeze(['docs/ai/gates.json', '.claude/settings.json', '.claude/settings.local.json']);
70
74
  const TRACKED_PIN_DECLARATION =
@@ -192,6 +196,12 @@ const readFileNoFollow = (fs, abs) => {
192
196
  }
193
197
  };
194
198
 
199
+ // Fatal UTF-8: a lossy decode would let invalid bytes become U+FFFD and pass for text we may rewrite.
200
+ // `ignoreBOM: true` KEEPS a leading BOM in the string — the default STRIPS it, which would make a
201
+ // BOM-carrying file and a BOM-less one decode identically and defeat a byte-faithful comparison.
202
+ const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
203
+ const decodeUtf8Strict = (buf) => { try { return UTF8_STRICT.decode(buf); } catch { return null; } };
204
+
195
205
  const NOFOLLOW_WRITE = fsC.O_WRONLY | fsC.O_CREAT | fsC.O_EXCL | (fsC.O_NOFOLLOW ?? 0);
196
206
  const COPY_BUFFER_BYTES = 64 * 1024;
197
207
 
@@ -650,7 +660,7 @@ export const provisionCopySet = (root, deps = {}) => {
650
660
  const fs = fsOf(deps);
651
661
  const out = [];
652
662
  for (const pattern of KIT_OWN_PATHS) {
653
- if (SEEDED_SEPARATELY.has(pattern)) continue;
663
+ if (SEEDED_SEPARATELY.has(pattern) || NEVER_PROVISIONED.has(pattern)) continue;
654
664
  if (isPresent(root, pattern, fs)) out.push(pattern);
655
665
  }
656
666
  for (const entry of KNOWN_FOOTPRINT) {
@@ -902,7 +912,7 @@ export const rebaseAbsolutePins = (text, mainRoot, wtRoot) => {
902
912
  const assertProvisionSourcesContained = ({ root, rootReal, fs, statFollow }) => {
903
913
  const rels = [];
904
914
  for (const pattern of [...KIT_OWN_PATHS, ...KNOWN_FOOTPRINT.map((e) => e.pattern)]) {
905
- if (SEEDED_SEPARATELY.has(pattern)) continue;
915
+ if (SEEDED_SEPARATELY.has(pattern) || NEVER_PROVISIONED.has(pattern)) continue;
906
916
  if (isGlobPattern(pattern)) {
907
917
  rels.push(...expandGlob(pattern, { dir: root, readdir: fs.readdir, stat: statFollow }).map((p) => patternToProbe(p)));
908
918
  continue;
@@ -2057,7 +2067,13 @@ const rebasePins = ({ root, wtRoot, git, fs, report, journal = NO_JOURNAL }) =>
2057
2067
  }
2058
2068
  const tracked = git(['ls-files', '--', target], wtRoot);
2059
2069
  if (tracked.status !== 0) throw stop(`git ls-files failed for ${target}: ${(tracked.stderr || tracked.stdout).trim()}`);
2060
- const text = String(cur.bytes);
2070
+ // Fatal decode BEFORE any rewrite: `String(buffer)` turns invalid bytes into U+FFFD, so a
2071
+ // rebase would silently destroy them in a file this lane only means to re-point.
2072
+ const text = decodeUtf8Strict(cur.bytes);
2073
+ if (text === null) {
2074
+ report.push(` ${target}: not valid UTF-8 — left untouched, pins not rebased`);
2075
+ continue;
2076
+ }
2061
2077
  const { text: rebased, changes } = rebaseAbsolutePins(text, root, wtRoot);
2062
2078
  if (tracked.stdout.trim() !== '') {
2063
2079
  if (changes.length > 0) report.push(` ${target}: ${TRACKED_PIN_DECLARATION}`);
@@ -2065,7 +2081,7 @@ const rebasePins = ({ root, wtRoot, git, fs, report, journal = NO_JOURNAL }) =>
2065
2081
  }
2066
2082
  if (changes.length === 0) continue;
2067
2083
  const main = readFileNoFollow(fs, join(root, target));
2068
- const mainText = main.bytes ? String(main.bytes) : null;
2084
+ const mainText = main.bytes ? decodeUtf8Strict(main.bytes) : null;
2069
2085
  const rebasedMain = mainText === null ? null : rebaseAbsolutePins(mainText, root, wtRoot).text;
2070
2086
  if (mainText !== null && (text === mainText || text === rebasedMain)) {
2071
2087
  writeContainedFileAtomic(wtRoot, wtAbs, rebasedMain, fs, { stop: (m) => stop(m) });
@@ -2163,6 +2179,13 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
2163
2179
  // Overlap refusal (pre-mutation): an include rel that another provision lane also populates
2164
2180
  // (the frozen registry footprint, the seeded plan, the handoff) — or another include root —
2165
2181
  // would meet the copy-if-missing kept-exit and skip the identity door entirely.
2182
+ // A NEVER_PROVISIONED path is absent from reservedRels precisely because provision skips it, so
2183
+ // the overlap arm below cannot see it — and an --include would copy it AND record it as owned,
2184
+ // which is the never-owned half of the rule defeated through the one door that bypasses the set.
2185
+ const neverProvisioned = [...NEVER_PROVISIONED].find((p) => includeRelsOverlap(rel, patternToProbe(p).replace(/\/$/, '')));
2186
+ if (neverProvisioned !== undefined) {
2187
+ throw stop(`--include names a path a worktree never gets and never owns (${neverProvisioned}): ${inc} — an MCP registration is machine-local and its consent is per checkout; register the satellite itself instead`);
2188
+ }
2166
2189
  const reserved = reservedRels.find((r) => includeRelsOverlap(rel, r));
2167
2190
  if (reserved !== undefined) {
2168
2191
  throw stop(`--include overlaps a path provision itself populates (${reserved}): ${inc} — the footprint, the seeded plan, and the handoff are copied by provision; drop the --include`);
@@ -2239,6 +2262,69 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
2239
2262
  }
2240
2263
  };
2241
2264
 
2265
+ // The worktree gets no `.mcp.json`, so a settings enable carried over would name a server it does
2266
+ // not declare. Eligibility is decided LIVE by the same two questions rebasePins asks — is the file
2267
+ // tracked, and is it still MAIN's bytes (before or after the pin rebase)? — never by a run-local
2268
+ // copy log, which a --resume turns into `kept` and silently skips. Anything else is user work: the
2269
+ // registration TOKENS stay — the bytes may still move, since rebasePins rewrites absolute pins on
2270
+ // its own lane — and the tokens are REPORTED rather than quietly left. Scope note: this is the
2271
+ // untracked provision lane; a TRACKED `.mcp.json` arrives with `git worktree add`, not with us.
2272
+ //
2273
+ // SCOPE, stated: this speaks only about a settings file that is readable, well-formed and actually
2274
+ // carries tokens of ours. An absent, unreadable or non-JSON one is not this lane's business and
2275
+ // returns silently — `rebasePins` above already reports the unreadable case in its own words.
2276
+ const dropMcpRegistration = ({ root, wtRoot, git, fs, report }) => {
2277
+ const wtAbs = join(wtRoot, SETTINGS_REL);
2278
+ const cur = readFileNoFollow(fs, wtAbs);
2279
+ if (!cur.bytes) return;
2280
+ // STRICT decode, and the MAIN comparison below is on BYTES: `String(buffer)` replaces invalid
2281
+ // UTF-8 with U+FFFD, so two DIFFERENT byte sequences can decode to the same string — which would
2282
+ // let a file that is not MAIN's satisfy the "matches MAIN" proof and be rewritten.
2283
+ const text = decodeUtf8Strict(cur.bytes);
2284
+ if (text === null) {
2285
+ report.push(` ${SETTINGS_REL}: not valid UTF-8 — left untouched, and no registration claim is made about it`);
2286
+ return;
2287
+ }
2288
+ const removal = withoutRegistration(text);
2289
+ if (!removal.hasTokens) return; // nothing of ours in it, or not readable as ours
2290
+ if (removal.reason !== null) {
2291
+ // Tokens ARE here and the rewrite was refused to protect foreign data — never a silent skip.
2292
+ report.push(` ${SETTINGS_REL}: carries registration tokens for "${SERVER_NAME}" but ${removal.reason}; left untouched`);
2293
+ return;
2294
+ }
2295
+ // ONE conjunction of PROVEN facts admits the write; every other state — including every state a
2296
+ // read cannot settle — takes the single report branch below. Stated as a positive proof rather
2297
+ // than a list of exclusions on purpose: three review rounds each found one more launcher/copy
2298
+ // sub-state an exclusion list had not enumerated, and an unenumerated state failed OPEN.
2299
+ const tracked = git(['ls-files', '--', SETTINGS_REL], wtRoot);
2300
+ if (tracked.status !== 0) throw stop(`git ls-files failed for ${SETTINGS_REL}: ${(tracked.stderr || tracked.stdout).trim()}`);
2301
+ const main = readFileNoFollow(fs, join(root, SETTINGS_REL));
2302
+ const mainText = main.bytes ? decodeUtf8Strict(main.bytes) : null;
2303
+ // Byte equality, and it is NOT interchangeable with string equality: a decoder that strips the BOM
2304
+ // maps a BOM-carrying file and a BOM-less one to the same string. Only the DIRECT branch is
2305
+ // independent of that policy — the rebased branch rebuilds the expected bytes FROM `mainText`, so
2306
+ // it holds only because `ignoreBOM: true` kept the BOM in the decode.
2307
+ const isMainCopy = mainText !== null
2308
+ && (cur.bytes.equals(main.bytes)
2309
+ || cur.bytes.equals(Buffer.from(rebaseAbsolutePins(mainText, root, wtRoot).text, 'utf8')));
2310
+ // ABSENT is the only launcher state that proves the tokens are orphaned. Unreadable, symlinked,
2311
+ // device-masked and present-with-an-entry all mean "a declaration may be live" — never strip.
2312
+ const launcherAbsent = readFileNoFollow(fs, join(wtRoot, MCP_JSON_REL)).absent === true;
2313
+ // The admission is ONE positive conjunction; the chain below only NAMES which proof was missing.
2314
+ const canStrip = tracked.stdout.trim() === '' && isMainCopy && launcherAbsent;
2315
+ const why = canStrip ? null
2316
+ : tracked.stdout.trim() !== '' ? 'tracked'
2317
+ // NOT "user-modified": the same arm covers a MAIN settings file that is absent or unreadable.
2318
+ : !isMainCopy ? 'not proven to match MAIN'
2319
+ : `a ${MCP_JSON_REL} is present or unreadable`;
2320
+ if (!canStrip) {
2321
+ report.push(` ${SETTINGS_REL}: ${why} — carries registration tokens for "${SERVER_NAME}"; left untouched, remove them by hand if this worktree declares no server`);
2322
+ return;
2323
+ }
2324
+ writeContainedFileAtomic(wtRoot, wtAbs, removal.text, fs, { stop: (m) => stop(m) });
2325
+ report.push(` ${SETTINGS_REL}: dropped our registration tokens (this worktree declares no ${MCP_JSON_REL})`);
2326
+ };
2327
+
2242
2328
  const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed, includeSources, provisionSet, git, deps, fs, report, log }) => {
2243
2329
  // THIS run's proof set: every lane journals the leaf it placed or kept, and nothing else is ever
2244
2330
  // examined by the resume verify — the session's own work is out of scope by construction.
@@ -2261,6 +2347,9 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
2261
2347
  const vscodeMode = provisionVscode({ root, wtRoot: targetPath, slug, git, fs, report, journal });
2262
2348
 
2263
2349
  rebasePins({ root, wtRoot: targetPath, git, fs, report, journal });
2350
+ // AFTER the rebase: stripping first makes the copy differ from MAIN, and rebasePins would then
2351
+ // read it as user work and skip the pins it exists to fix.
2352
+ dropMcpRegistration({ root, wtRoot: targetPath, git, fs, report });
2264
2353
 
2265
2354
  const inFlight = plansInFlight(targetPath, fs.readdir);
2266
2355
  if (inFlight.length !== 1 || inFlight[0] !== seed.name) {
@@ -2877,6 +2966,7 @@ const headEntry = (git, cwd, head, path) => {
2877
2966
  const registryRoots = () => {
2878
2967
  const roots = [];
2879
2968
  for (const pattern of [...KIT_OWN_PATHS, ...KNOWN_FOOTPRINT.map((entry) => entry.pattern)]) {
2969
+ if (NEVER_PROVISIONED.has(pattern)) continue; // on the RAW pattern — normalizing first never matches
2880
2970
  const normalized = normalizeSlashes(pattern).replace(/^\//, '').replace(/\/$/, '');
2881
2971
  if (normalized) roots.push(normalized);
2882
2972
  }
@@ -2891,10 +2981,20 @@ const safeRecordedPath = (path) => {
2891
2981
  return normalized;
2892
2982
  };
2893
2983
 
2984
+ // Ownership has TWO sources — the registry and the record's own includes — and BOTH must honour
2985
+ // NEVER_PROVISIONED. A record written before the --include refusal existed still names the path, and
2986
+ // without this filter that legacy record hands cleanup a registration it may delete. The comparison
2987
+ // is includeRelsOverlap, the SAME predicate the refusal uses. Stated honestly: on POSIX that is
2988
+ // indistinguishable from an exact match here (a recorded DESCENDANT covers nothing at this path, and
2989
+ // case is significant); what it buys is the backslash-platform case fold, already pinned on the
2990
+ // predicate itself (worktrees-include-identity.test.mjs) rather than claimed by a test run here.
2991
+ const NEVER_PROVISIONED_RELS = [...NEVER_PROVISIONED].map((p) => patternToProbe(p).replace(/\/$/, ''));
2992
+ const isNeverProvisionedRel = (rel) => NEVER_PROVISIONED_RELS.some((p) => includeRelsOverlap(rel, p));
2993
+
2894
2994
  const provisionKnownRoots = (identity) => {
2895
2995
  const roots = [
2896
2996
  ...registryRoots(),
2897
- ...identity.record.includes.map(safeRecordedPath),
2997
+ ...identity.record.includes.map(safeRecordedPath).filter((rel) => !isNeverProvisionedRel(rel)),
2898
2998
  PLANS_REL,
2899
2999
  ];
2900
3000
  if (identity.record.vscode === 'written') roots.push('.vscode/settings.json');