@sabaiway/agent-workflow-kit 5.11.2 → 7.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +3 -2
  3. package/SKILL.md +5 -1
  4. package/bridges/antigravity-cli-bridge/bin/agy-review-await-guard.test.mjs +176 -0
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +61 -14
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +606 -467
  7. package/bridges/antigravity-cli-bridge/references/review-prompt.md +42 -4
  8. package/bridges/codex-cli-bridge/SKILL.md +18 -5
  9. package/bridges/codex-cli-bridge/bin/codex-await-guard.test.mjs +161 -0
  10. package/bridges/codex-cli-bridge/bin/codex-exec.sh +22 -17
  11. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +356 -363
  12. package/bridges/codex-cli-bridge/bin/codex-review.sh +6 -6
  13. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +275 -286
  14. package/bridges/codex-cli-bridge/capability.json +1 -1
  15. package/bridges/codex-cli-bridge/references/driving-codex.md +4 -2
  16. package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +3 -2
  17. package/bridges/codex-cli-bridge/setup/README.md +3 -1
  18. package/capability.json +1 -1
  19. package/package.json +1 -1
  20. package/references/hooks/gate-approve.mjs +1 -1
  21. package/references/modes/grounding.md +1 -1
  22. package/references/modes/mcp.md +37 -0
  23. package/references/modes/procedures.md +3 -3
  24. package/references/modes/recommendations.md +1 -0
  25. package/references/modes/uninstall.md +2 -1
  26. package/references/templates/agent_rules.md +4 -5
  27. package/tools/commands.mjs +7 -0
  28. package/tools/direct-run.mjs +3 -0
  29. package/tools/doc-parity.mjs +18 -2
  30. package/tools/grounding.mjs +10 -20
  31. package/tools/inject-methodology.mjs +2 -0
  32. package/tools/mcp-registration.mjs +283 -0
  33. package/tools/mcp-server.mjs +314 -0
  34. package/tools/mcp-stdio.mjs +229 -0
  35. package/tools/mcp.mjs +299 -0
  36. package/tools/procedures.mjs +7 -8
  37. package/tools/recommendations.mjs +90 -1
  38. package/tools/uninstall.mjs +356 -45
@@ -27,10 +27,24 @@ import { join, resolve, dirname, basename, isAbsolute } from 'node:path';
27
27
  import os from 'node:os';
28
28
  import { isDirectRun } from './direct-run.mjs';
29
29
  import { surveyFamily, surveyProject, FAMILY_MEMBERS, classifyMember, OK } from './family-registry.mjs';
30
- import { removeTreeManaged, unlinkManaged, MANAGED_LINK_CONFLICT } from './fs-safe.mjs';
30
+ import { assertContainedRealPath, removeTreeManaged, unlinkManaged, MANAGED_LINK_CONFLICT } from './fs-safe.mjs';
31
31
  import { deriveLinks } from './setup-backends.mjs';
32
32
  import { hideFootprint, excludePath } from './hide-footprint.mjs';
33
33
  import { HOOK_FILE_REL as GATE_HOOK_FILE_REL, readBundledHook } from './gate-hook.mjs';
34
+ // The MCP registration's two seams are recognized through the registration LEAF — its constants and
35
+ // its own entry decision — so this reporter can never disagree with the writer about what is ours.
36
+ import {
37
+ CLAUDE_DIR_REL,
38
+ DEFAULT_SERVER_PATH as MCP_SERVER_PATH,
39
+ ENABLED_KEY as MCP_ENABLED_KEY,
40
+ MCP_JSON_REL,
41
+ SERVER_NAME as MCP_SERVER_NAME,
42
+ SERVERS_KEY as MCP_SERVERS_KEY,
43
+ STATE as MCP_STATE,
44
+ allowRulesFor,
45
+ buildServerEntry,
46
+ decideMcpJsonText,
47
+ } from './mcp-registration.mjs';
34
48
 
35
49
  // ── surface classes ────────────────────────────────────────────────────────────
36
50
  export const SAFE_REMOVE = 'safe-remove';
@@ -172,10 +186,36 @@ export const buildPlan = ({ family, project = null, projectDir = null, member =
172
186
  items.push({ surface: 'fence', path: fencePath, class: MANAGED_MARKER, reason: 'hidden-mode managed block (removed via the existing unhide path; only the fenced lines)' });
173
187
  }
174
188
 
189
+ // Plan time reads through the same parent chains the executor removes through, so it asks the
190
+ // same question: a hook reached only by traversing a symlink is neither read nor planned.
191
+ // Validates the PARENT CHAIN only. Walking the leaf too collapsed a symlinked settings file or
192
+ // placed hook into "absent", which made their REPORT_ONLY branches unreachable and dropped the
193
+ // surface from the plan entirely — worse than reporting it. The leaf is classified below, by its
194
+ // own no-follow lstat, and reported without being followed.
195
+ const reachable = (target) => {
196
+ try {
197
+ assertContainedRealPath(dir, dirname(target), { lstat: fs.lstat });
198
+ return true;
199
+ } catch {
200
+ return false;
201
+ }
202
+ };
175
203
  const hookPath = join(dir, '.git/hooks/pre-commit');
204
+ // `reachable` answers for the PARENT CHAIN only (by design — walking the leaf dropped surfaces
205
+ // from the plan). The leaf therefore needs its own no-follow classification before any read: a
206
+ // symlinked hook would expose an out-of-tree file, and a FIFO would block planning outright.
207
+ const hookStat = reachable(hookPath) ? lstatNoFollow(hookPath, fs.lstat) : null;
208
+ if (hookStat !== null && (hookStat.isSymbolicLink() || !hookStat.isFile())) {
209
+ items.push({
210
+ surface: 'hook', path: hookPath, class: REPORT_ONLY,
211
+ reason: 'a pre-commit hook path exists but is a symlink or not a regular file — reported UNREAD and left untouched; check it by hand',
212
+ hand: `inspect ${shq(hookPath)} by hand — this tool neither follows nor removes it`,
213
+ });
214
+ }
176
215
  const hook = (() => {
216
+ if (hookStat === null || hookStat.isSymbolicLink() || !hookStat.isFile()) return null;
177
217
  try {
178
- return fs.exists(hookPath) ? String(fs.readFile(hookPath, 'utf8')) : null;
218
+ return String(fs.readFile(hookPath, 'utf8'));
179
219
  } catch {
180
220
  return null;
181
221
  }
@@ -195,10 +235,43 @@ export const buildPlan = ({ family, project = null, projectDir = null, member =
195
235
  return null;
196
236
  }
197
237
  };
238
+ // The `.claude` CONTAINER is classified before anything inside it is read. Path resolution
239
+ // follows an INTERMEDIATE symlink (a no-follow open guards the final component only), so reading
240
+ // first would let a symlinked `.claude` put a settings file from OUTSIDE the work tree into this
241
+ // report — as a claim about surfaces this family placed. An ABSENT container is ordinary (the
242
+ // reads below simply find nothing); a symlinked or non-directory one is reported UNREAD.
243
+ const claudeDirPath = join(dir, CLAUDE_DIR_REL);
244
+ const claudeDirStat = lstatNoFollow(claudeDirPath, fs.lstat);
245
+ // NOT-A-DIRECTORY is the whole class, and every member of it blinds the reads below: a symlink
246
+ // escapes the work tree, and a device / FIFO / socket turns each of them into ENOTDIR. Naming
247
+ // only symlink-or-regular-file left the special classes to fail as an unhandled error instead.
248
+ const claudeDirForeign = claudeDirStat !== null && !claudeDirStat.isDirectory();
249
+ if (claudeDirForeign) {
250
+ // Its own surface AND its own hand line. Under `settings` the report would tell the maintainer
251
+ // to remove a settings KEY from a directory; under the generic fallback it would suggest
252
+ // `rm -rf` on a path this tool has deliberately refused to look inside.
253
+ items.push({
254
+ surface: 'claude-dir', path: claudeDirPath, class: REPORT_ONLY,
255
+ reason: `${CLAUDE_DIR_REL} exists but is not a directory — nothing inside it was read, so no settings or hook surface is reported from here`,
256
+ hand: `inspect ${shq(claudeDirPath)} by hand — it exists but is not a directory, and this tool neither reads through it nor removes it`,
257
+ });
258
+ }
259
+ // A settings file the tool will not read is REPORTED rather than dropped: the seams it may hold
260
+ // are exactly what a teardown owes the maintainer, and silence about them is the worse answer.
198
261
  const settingsPath = join(dir, '.claude/settings.json');
199
- const settings = readRaw(settingsPath);
262
+ const settingsStat = claudeDirForeign || !reachable(settingsPath) ? null : lstatNoFollow(settingsPath, fs.lstat);
263
+ const settingsUnread = !claudeDirForeign && reachable(settingsPath) && settingsStat !== null
264
+ && (settingsStat.isSymbolicLink() || !settingsStat.isFile());
265
+ if (settingsUnread) {
266
+ items.push({
267
+ surface: 'settings', path: settingsPath, class: REPORT_ONLY,
268
+ reason: `${SETTINGS_REL_TXT} is a symlink or not a regular file — left UNREAD and untouched, so no seam in it is reported; check it by hand`,
269
+ hand: `inspect ${shq(settingsPath)} by hand — this tool neither follows nor edits it`,
270
+ });
271
+ }
272
+ const settings = claudeDirForeign || !reachable(settingsPath) || settingsUnread ? null : readRaw(settingsPath);
200
273
  const settingsSeams = detectSettingsSeams(settings);
201
- if (settingsSeams.attribution || settingsSeams.permissions || settingsSeams.gateHook) {
274
+ if (settingsSeams.attribution || settingsSeams.permissions || settingsSeams.gateHook || settingsSeams.mcp) {
202
275
  items.push({
203
276
  surface: 'settings',
204
277
  path: settingsPath,
@@ -216,7 +289,11 @@ export const buildPlan = ({ family, project = null, projectDir = null, member =
216
289
  // itself would warn about; while any entry is present, all surfaces are reported as one bundle
217
290
  // (edit settings first, re-run to remove the file).
218
291
  const localSettingsPath = join(dir, '.claude/settings.local.json');
219
- const localSeams = detectSettingsSeams(readRaw(localSettingsPath));
292
+ // Same leaf discipline as the hook: classified no-follow before any read, so a symlinked local
293
+ // settings file is not followed out of the tree and a non-regular one is never opened.
294
+ const localStat = !claudeDirForeign && reachable(localSettingsPath) ? lstatNoFollow(localSettingsPath, fs.lstat) : null;
295
+ const localReadable = localStat !== null && !localStat.isSymbolicLink() && localStat.isFile();
296
+ const localSeams = detectSettingsSeams(localReadable ? readRaw(localSettingsPath) : null);
220
297
  if (localSeams.gateHook) {
221
298
  items.push({
222
299
  surface: 'settings',
@@ -227,7 +304,9 @@ export const buildPlan = ({ family, project = null, projectDir = null, member =
227
304
  });
228
305
  }
229
306
  const gateHookPath = join(dir, GATE_HOOK_FILE_REL);
230
- const gateHookStat = lstatNoFollow(gateHookPath, fs.lstat);
307
+ // The placed hook lives INSIDE `.claude` too, so a foreign container blinds this probe as well —
308
+ // its own no-follow lstat guards the leaf, never the path that reaches it.
309
+ const gateHookStat = claudeDirForeign || !reachable(gateHookPath) ? null : lstatNoFollow(gateHookPath, fs.lstat);
231
310
  // lstat no-follow BEFORE reading: a symlink (or any non-regular file) at the placed path is
232
311
  // never a kit-placed hook we remove — reading through it would classify a symlink-to-bundle as
233
312
  // SAFE_REMOVE and only removeTreeManaged would catch it at mutate time, AFTER earlier removals.
@@ -268,6 +347,40 @@ export const buildPlan = ({ family, project = null, projectDir = null, member =
268
347
  }
269
348
  }
270
349
 
350
+ // The MCP registration's file seam (Mode: mcp). REPORT_ONLY without exception: `.mcp.json` is a
351
+ // shared declaration that may list servers this kit never placed, so it is never rewritten and
352
+ // never removed — only the entry to delete is named. lstat no-follow BEFORE any read: a symlink
353
+ // or a sandbox device mask at this path is reported UNREAD, never followed and never parsed.
354
+ const mcpJsonPath = join(dir, MCP_JSON_REL);
355
+ const mcpStat = lstatNoFollow(mcpJsonPath, fs.lstat);
356
+ if (mcpStat !== null && (mcpStat.isSymbolicLink() || !mcpStat.isFile())) {
357
+ items.push({
358
+ surface: 'mcp-json', path: mcpJsonPath, class: REPORT_ONLY,
359
+ reason: `a file exists at ${MCP_JSON_REL} but is a symlink or not a regular file (an OS sandbox device mask reads exactly this way) — reported unread and left untouched; check it by hand from outside the sandbox`,
360
+ // Every arm of this surface carries its OWN hand line: `.mcp.json` is a SHARED declaration
361
+ // that may list servers this kit never placed, and the generic fallback would offer `rm -rf`.
362
+ hand: `inspect ${shq(mcpJsonPath)} by hand from outside the sandbox — this tool neither follows nor removes it`,
363
+ });
364
+ } else if (mcpStat !== null) {
365
+ const mcpText = readRaw(mcpJsonPath);
366
+ const decided = mcpText == null ? null : decideMcpJsonText(mcpText, buildServerEntry(MCP_SERVER_PATH));
367
+ if (decided === null || decided.state !== MCP_PRESENT) {
368
+ // "Is our entry there?" has no answer over bytes that could not be read or parsed — and
369
+ // treating no-answer as NO made the file vanish from a report whose whole job is completeness.
370
+ items.push({
371
+ surface: 'mcp-json', path: mcpJsonPath, class: REPORT_ONLY,
372
+ reason: `${MCP_JSON_REL} could not be read or parsed (${decided?.reason ?? 'unreadable'}) — left untouched; check it by hand for an "${MCP_SERVER_NAME}" entry`,
373
+ hand: `inspect ${shq(mcpJsonPath)} by hand and remove any "${MCP_SERVER_NAME}" entry under "${MCP_SERVERS_KEY}" — never delete the file, it may declare servers this kit never placed`,
374
+ });
375
+ } else if (decided.hasEntry) {
376
+ items.push({
377
+ surface: 'mcp-json', path: mcpJsonPath, class: REPORT_ONLY,
378
+ reason: `an MCP server entry named "${MCP_SERVER_NAME}" is present in ${MCP_JSON_REL} — remove that entry by hand to unregister the typed channel (the file may declare servers this kit never placed, so it is never rewritten or removed)`,
379
+ hand: `edit ${shq(mcpJsonPath)} → remove the "${MCP_SERVER_NAME}" entry under "${MCP_SERVERS_KEY}" (keep every other server)`,
380
+ });
381
+ }
382
+ }
383
+
271
384
  for (const rel of REPORT_PATHS) {
272
385
  const p = join(dir, rel);
273
386
  if (fs.exists(p)) {
@@ -290,7 +403,11 @@ export const executePlan = (plan, opts = {}, deps = {}) => {
290
403
  const unlink = deps.unlink ?? unlinkManaged;
291
404
  const unhide = deps.hideFootprint ?? hideFootprint;
292
405
  const classify = deps.classify ?? classifyMember;
293
- const rmFile = deps.rmFile ?? ((p) => removeTreeManaged(p, dirname(p), deps)); // marker hook is a regular file
406
+ // `root` names the containment boundary the removal must stay inside. It defaults to the file's own
407
+ // parent (the marker hook in .git/hooks), but a surface living under `.claude` passes the PROJECT
408
+ // dir instead: with the parent as root, a symlinked `.claude` is inside the boundary by
409
+ // construction and the guard can never fire.
410
+ const rmFile = deps.rmFile ?? ((p, root) => removeTreeManaged(p, root ?? dirname(p), deps));
294
411
 
295
412
  const mutable = plan.items.filter((i) => i.class === SAFE_REMOVE || i.class === MANAGED_MARKER);
296
413
  // `reported` (returned + summarized) = everything we do NOT mutate: user-authored (report-only) AND
@@ -309,6 +426,62 @@ export const executePlan = (plan, opts = {}, deps = {}) => {
309
426
  // ours, a wrapper turned foreign, a hook that lost our marker, or a malformed fence (validated by a
310
427
  // dry-run unhide — codex #2 — so the fence can't blow up AFTER wrappers/skills were already removed).
311
428
  // (Plan-time STOP items are NOT a conflict — they were never ours; they are reported + left, above.)
429
+ // Guarding ONE named container is a ladder with no top: close `.claude` and the next symlink moves
430
+ // to `.claude/hooks`, then to `.git`. The property is the WHOLE parent chain from the project root,
431
+ // and the kit already owns the walk that decides it — so this asks that primitive instead of adding
432
+ // a third bespoke check. Re-read LIVE at each call site, never captured: the entire point is that
433
+ // the chain can change, so a cached answer describes a moment that has already passed.
434
+ const reachedSafely = (target) => {
435
+ if (!plan.projectDir) return true;
436
+ try {
437
+ assertContainedRealPath(plan.projectDir, target, { lstat: fs.lstat });
438
+ return true;
439
+ } catch {
440
+ return false;
441
+ }
442
+ };
443
+
444
+ // The ONE classification both phases use, so the preflight and the mutate arm can never disagree
445
+ // about what a leaf is. A non-ENOENT lstat failure is `unreadable`, NOT absent — the distinction the
446
+ // whole late-conflict lane rests on.
447
+ // Every call site runs AFTER `reachedSafely`, whose walk already lstats this same leaf: it turns a
448
+ // non-ENOENT failure AND a symlink into their own conflicts before this is reached. So both a
449
+ // separate unreadable branch and a separate symlink branch here were dead code, and both are gone
450
+ // rather than covered — what still reaches this is the class that walk permits: a non-regular,
451
+ // non-symlink leaf (a device, a FIFO, a socket), which must never be opened.
452
+ const readableRegular = (target) => {
453
+ const st = lstatNoFollow(target, fs.lstat);
454
+ if (st === null) return { kind: 'absent' };
455
+ if (!st.isFile()) return { kind: 'foreign', reason: 'is not a regular file — left untouched, and never opened' };
456
+ return { kind: 'regular', st };
457
+ };
458
+
459
+ // A surface that changes, or whose removal REFUSES, between the conflict pass and its own mutation
460
+ // is a LATE conflict: earlier mutations have already happened, so the zero-mutation guarantee no
461
+ // longer applies and pretending otherwise would be the lie. Every mutating branch routes through
462
+ // here — a contract implemented for two surfaces while documented for all of them is the same
463
+ // silent-shape failure it exists to prevent.
464
+ const lateConflicts = [];
465
+ const partialFailures = [];
466
+ // "left untouched" is a CLAIM, and only a containment/ownership REFUSAL proves it: those are raised
467
+ // before the primitive touches anything. A recursive delete or a fence write that throws part way
468
+ // has already changed the tree, so it is reported as POSSIBLY PARTIAL — and it stops the run,
469
+ // because continuing to remove later surfaces on top of an unknown state is not a teardown.
470
+ const isProvenRefusal = (err) =>
471
+ err?.code === MANAGED_LINK_CONFLICT || err?.code === UNINSTALL_STOP || /refusing to/.test(String(err?.message ?? ''));
472
+ const attemptRemoval = (label, run) => {
473
+ if (partialFailures.length) return false; // stop after an unknown-state failure
474
+ try {
475
+ run();
476
+ return true;
477
+ } catch (err) {
478
+ const note = `${label}: ${err?.message ?? err}`;
479
+ if (isProvenRefusal(err)) lateConflicts.push(`${note} — refused before any change, left untouched`);
480
+ else partialFailures.push(`${note} — it MAY BE PARTIALLY removed; the tool cannot tell`);
481
+ return false;
482
+ }
483
+ };
484
+
312
485
  const conflicts = [];
313
486
  for (const item of mutable) {
314
487
  if (item.surface === 'skill') {
@@ -320,24 +493,49 @@ export const executePlan = (plan, opts = {}, deps = {}) => {
320
493
  const info = inspectWrapper(item.path, item.expectedSrc, fs);
321
494
  if (info.state === 'conflict') conflicts.push(`${item.path} is not ours (${info.reason})`);
322
495
  } else if (item.surface === 'hook') {
323
- const present = (() => { try { return fs.exists(item.path); } catch { return false; } })();
324
- if (present) {
325
- const content = (() => { try { return String(fs.readFile(item.path, 'utf8')); } catch { return ''; } })();
326
- if (!content.includes(HOOK_MARKER_SUFFIX)) conflicts.push(`${item.path} no longer carries our marker`);
496
+ // The marker hook lives under `.git/`, which is a parent chain like any other: a symlinked
497
+ // `.git` makes the read below describe a file outside the project, and the removal delete it.
498
+ if (!reachedSafely(item.path)) {
499
+ conflicts.push(`${item.path} is no longer reachable without traversing a symlink — refusing to read or remove anything through it`);
500
+ continue;
501
+ }
502
+ // lstat, not exists: `exists` answers FALSE for a merely unreadable leaf, which would read as
503
+ // the one state documented benign (vanished), and it follows a symlink. Only ENOENT is benign;
504
+ // a non-regular leaf is classified and refused BEFORE any read, because reading a FIFO blocks.
505
+ const st = readableRegular(item.path);
506
+ if (st.kind === 'absent') continue;
507
+ if (st.kind === 'foreign') {
508
+ conflicts.push(`${item.path} ${st.reason}`);
509
+ continue;
327
510
  }
511
+ const content = (() => { try { return String(fs.readFile(item.path, 'utf8')); } catch { return null; } })();
512
+ if (content == null) conflicts.push(`${item.path} could not be read — refusing to decide whether it is ours`);
513
+ else if (!content.includes(HOOK_MARKER_SUFFIX)) conflicts.push(`${item.path} no longer carries our marker`);
328
514
  } else if (item.surface === 'gate-hook') {
329
515
  // Same AD-011 recheck as the marker hook: the file must STILL be a regular (non-symlink)
330
516
  // file, byte-identical to the bundle, AND still unwired — a symlink swapped in, a divergence,
331
517
  // or a new settings entry since the plan ⇒ zero mutations. lstat no-follow FIRST: a symlink
332
518
  // read through by fs.readFile would masquerade as the bundle and slip past this guard.
333
- const st = lstatNoFollow(item.path, fs.lstat);
334
- if (st !== null) {
335
- if (st.isSymbolicLink() || !st.isFile()) conflicts.push(`${item.path} is no longer a regular file (symlink or special file)`);
336
- else {
337
- const content = (() => { try { return String(fs.readFile(item.path, 'utf8')); } catch { return null; } })();
338
- if (content !== item.expectedContent) conflicts.push(`${item.path} no longer matches the kit bundle`);
339
- else if (gateHookWiredNow(plan.projectDir, fs)) conflicts.push(`${item.path} became wired in Claude settings since the plan`);
340
- }
519
+ //
520
+ // The CONTAINER is rechecked before the leaf, because the leaf's no-follow lstat resolves
521
+ // THROUGH it: a `.claude` swapped for a symlink since the plan makes every check below describe
522
+ // a file outside the project, and the removal would then delete that file.
523
+ if (!reachedSafely(item.path)) {
524
+ conflicts.push(`${item.path} is no longer reachable without traversing a symlink — refusing to read or remove anything through it`);
525
+ continue;
526
+ }
527
+ const cls = readableRegular(item.path);
528
+ if (cls.kind === 'foreign') {
529
+ conflicts.push(`${item.path} ${cls.reason}`);
530
+ continue;
531
+ }
532
+ if (cls.kind === 'regular') {
533
+ const content = (() => { try { return String(fs.readFile(item.path, 'utf8')); } catch { return null; } })();
534
+ // Unreadable is its own cause — reporting it as a bundle mismatch states a comparison that
535
+ // never happened.
536
+ if (content == null) conflicts.push(`${item.path} could not be read — refusing to decide whether it is the kit bundle`);
537
+ else if (content !== item.expectedContent) conflicts.push(`${item.path} no longer matches the kit bundle`);
538
+ else if (gateHookWiredNow(plan.projectDir, fs)) conflicts.push(`${item.path} became wired in Claude settings since the plan`);
341
539
  }
342
540
  } else if (item.surface === 'fence') {
343
541
  // Validate the unhide WITHOUT writing — a malformed managed block throws here, before any mutation,
@@ -359,45 +557,122 @@ export const executePlan = (plan, opts = {}, deps = {}) => {
359
557
 
360
558
  // ── MUTATE (wrappers first, then skill dirs, then project surfaces) ──
361
559
  for (const item of mutable.filter((i) => i.surface === 'wrapper')) {
362
- const realBindir = (() => { try { return fs.realpath(dirname(item.path)); } catch { return dirname(item.path); } })();
363
- const action = unlink(join(realBindir, basename(item.path)), item.expectedSrc, realBindir, deps);
364
- if (action === 'unlinked') result.unlinked.push(item.path);
560
+ attemptRemoval(item.path, () => {
561
+ const realBindir = (() => { try { return fs.realpath(dirname(item.path)); } catch { return dirname(item.path); } })();
562
+ const action = unlink(join(realBindir, basename(item.path)), item.expectedSrc, realBindir, deps);
563
+ if (action === 'unlinked') result.unlinked.push(item.path);
564
+ });
365
565
  }
366
566
  for (const item of mutable.filter((i) => i.surface === 'skill')) {
367
- const action = removeTree(item.path, dirname(item.path), deps);
368
- if (action === 'removed') result.removed.push(item.path);
567
+ attemptRemoval(item.path, () => {
568
+ // Ownership is re-established immediately before the RECURSIVE delete, not only in the conflict
569
+ // pass: a dir replaced in between would otherwise be removed whole on the strength of a check
570
+ // that described something else. The shipped contract promises exactly this.
571
+ const now = classify(FAMILY_MEMBERS.find((m) => m.name === item.member), deps);
572
+ if (!(now.installed && now.manifestState === OK && now.skillDir === item.path)) {
573
+ throw stop(`${item.path} stopped being a proven-managed ${item.member} skill after the preflight — refusing to remove it`);
574
+ }
575
+ const action = removeTree(item.path, dirname(item.path), deps);
576
+ if (action === 'removed') result.removed.push(item.path);
577
+ });
369
578
  }
370
579
  for (const item of mutable.filter((i) => i.surface === 'fence')) {
371
- const r = unhide({ dir: plan.projectDir, unhide: true }, deps);
372
- result.unhidden = r && r.action === 'unhidden';
580
+ attemptRemoval(item.path, () => {
581
+ const r = unhide({ dir: plan.projectDir, unhide: true }, deps);
582
+ result.unhidden = r && r.action === 'unhidden';
583
+ });
373
584
  }
374
585
  for (const item of mutable.filter((i) => i.surface === 'hook')) {
375
586
  // Marker-aware even at mutate time (belt-and-suspenders past the preflight): remove the hook ONLY
376
- // while it still carries our marker, so a user hook can never be deleted even under a TOCTOU race.
377
- const content = (() => { try { return fs.exists(item.path) ? String(fs.readFile(item.path, 'utf8')) : null; } catch { return null; } })();
378
- if (content != null && content.includes(HOOK_MARKER_SUFFIX)) {
379
- rmFile(item.path);
380
- result.hookRemoved = true;
587
+ // while it still carries our marker. That closes the STATIC case a user hook standing there is
588
+ // never deleted. It is NOT a race guarantee: substitution of one regular file for another between
589
+ // this read and the path-based removal stays open, for the reason named in mcp-registration.mjs.
590
+ if (!reachedSafely(item.path)) {
591
+ lateConflicts.push(`${item.path} became reachable only through a symlink after the preflight — left untouched`);
592
+ continue;
593
+ }
594
+ // A surface that merely VANISHED stays benign — the mutate is a no-op and nothing is owed. Every
595
+ // OTHER mismatch is a late conflict: passing over a hook that changed, or that could not be read,
596
+ // and then reporting `applied: true` is the silent skip this arm claims not to have.
597
+ const st = readableRegular(item.path);
598
+ if (st.kind === 'absent') continue;
599
+ if (st.kind === 'foreign') {
600
+ lateConflicts.push(`${item.path} ${st.reason}`);
601
+ continue;
602
+ }
603
+ const content = (() => { try { return String(fs.readFile(item.path, 'utf8')); } catch { return null; } })();
604
+ if (content == null) {
605
+ lateConflicts.push(`${item.path} could not be read at removal time — left untouched`);
606
+ continue;
607
+ }
608
+ if (!content.includes(HOOK_MARKER_SUFFIX)) {
609
+ lateConflicts.push(`${item.path} no longer carries our marker (it changed after the preflight) — left untouched`);
610
+ continue;
381
611
  }
612
+ if (attemptRemoval(item.path, () => rmFile(item.path, plan.projectDir))) result.hookRemoved = true;
382
613
  }
383
614
  for (const item of mutable.filter((i) => i.surface === 'gate-hook')) {
384
615
  // Bundle-identity + unwired re-verified at mutate time too (the TOCTOU posture above), lstat
385
616
  // no-follow FIRST so a symlink swapped in cannot be read-through as the bundle and removed: a
386
617
  // file that changed, turned into a symlink, or got wired between preflight and now is left
387
618
  // untouched, never removed.
388
- const st = lstatNoFollow(item.path, fs.lstat);
389
- const content = st !== null && st.isFile() && !st.isSymbolicLink()
390
- ? (() => { try { return String(fs.readFile(item.path, 'utf8')); } catch { return null; } })()
391
- : null;
392
- if (content != null && content === item.expectedContent && !gateHookWiredNow(plan.projectDir, fs)) {
393
- rmFile(item.path);
394
- result.gateHookRemoved = true;
395
- // A `.claude/hooks/` dir left EMPTY by that removal is removed too (clean footprint); a dir
396
- // with anything else in it is untouched.
397
- const hooksDir = dirname(item.path);
398
- const leftover = (() => { try { return fs.readdir(hooksDir); } catch { return null; } })();
399
- if (leftover != null && leftover.length === 0) removeTree(hooksDir, dirname(hooksDir), deps);
619
+ // The chain is re-walked here too, immediately before the removal — the conflict pass above ran
620
+ // at a different instant, and this is the call that deletes.
621
+ if (!reachedSafely(item.path)) {
622
+ lateConflicts.push(`${item.path} became reachable only through a symlink after the preflight — left untouched`);
623
+ continue;
624
+ }
625
+ const placed = readableRegular(item.path);
626
+ if (placed.kind === 'absent') continue; // vanished benign, the same documented no-op as everywhere else
627
+ if (placed.kind === 'foreign') {
628
+ lateConflicts.push(`${item.path} ${placed.reason}`);
629
+ continue;
630
+ }
631
+ const content = (() => { try { return String(fs.readFile(item.path, 'utf8')); } catch { return null; } })();
632
+ if (content !== item.expectedContent) {
633
+ lateConflicts.push(`${item.path} ${content == null ? 'could not be read at removal time' : 'no longer matches the kit bundle (it changed after the preflight)'} — left untouched`);
634
+ continue;
635
+ }
636
+ if (gateHookWiredNow(plan.projectDir, fs)) {
637
+ lateConflicts.push(`${item.path} became wired in Claude settings after the preflight — left untouched (removing it would leave a wired-but-missing hook)`);
638
+ continue;
639
+ }
640
+ {
641
+ // Containment root = the PROJECT, not the file's parent: with the parent as root a symlinked
642
+ // `.claude` sits inside the boundary by construction, so the traversal guard could never fire.
643
+ if (attemptRemoval(item.path, () => rmFile(item.path, plan.projectDir))) {
644
+ result.gateHookRemoved = true;
645
+ // A `.claude/hooks/` dir left EMPTY by that removal is removed too (clean footprint); a dir
646
+ // with anything else in it is untouched.
647
+ const hooksDir = dirname(item.path);
648
+ const leftover = (() => { try { return fs.readdir(hooksDir); } catch { return null; } })();
649
+ if (leftover != null && leftover.length === 0) {
650
+ attemptRemoval(hooksDir, () => removeTree(hooksDir, plan.projectDir ?? dirname(hooksDir), deps));
651
+ }
652
+ }
653
+ }
654
+ }
655
+ if (partialFailures.length || lateConflicts.length) {
656
+ // Unlike the preflight STOP, this one CANNOT promise zero changes — so it reports what actually
657
+ // happened, DERIVED from the result. "Earlier removals were applied" is a claim, and on a plan
658
+ // holding only the conflicted surface it is a false one.
659
+ const done = [
660
+ ...result.removed.map((p) => `removed ${p}`),
661
+ ...result.unlinked.map((p) => `unlinked ${p}`),
662
+ ...(result.unhidden ? ['unhidden the managed git-exclude block'] : []),
663
+ ...(result.hookRemoved ? ['removed the marked pre-commit hook'] : []),
664
+ ...(result.gateHookRemoved ? ['removed the placed gate-approval hook'] : []),
665
+ ];
666
+ const parts = [`the teardown is INCOMPLETE.`];
667
+ if (partialFailures.length) {
668
+ parts.push(` ${partialFailures.length} removal(s) FAILED in an unknown state, so the run stopped there:\n - ${partialFailures.join('\n - ')}`);
669
+ }
670
+ if (lateConflicts.length) {
671
+ parts.push(` ${lateConflicts.length} surface(s) could not be touched safely and were left alone:\n - ${lateConflicts.join('\n - ')}`);
400
672
  }
673
+ parts.push(` ${done.length ? `Already applied before that point:\n - ${done.join('\n - ')}` : 'nothing had been removed before that point.'}`);
674
+ parts.push(' Re-run the teardown once the tree is settled; anything listed as left untouched was not removed.');
675
+ throw stop(parts.join('\n'), { lateConflicts, partialFailures, applied: done, partial: result });
401
676
  }
402
677
  result.applied = true;
403
678
  return result;
@@ -406,9 +681,18 @@ export const executePlan = (plan, opts = {}, deps = {}) => {
406
681
  // Is the gate-approval hook wired NOW (either settings file)? Probed by the placed-path substring
407
682
  // (the same broad detectSettingsSeams posture). Fail-CLOSED: an unreadable settings file counts as
408
683
  // wired — "cannot prove unwired" must preserve the file, never remove it.
684
+ // This probe DECIDES a removal — a hook that reads as unwired gets deleted — so a settings file
685
+ // reached through a symlink would let bytes from outside the project drive that deletion. Each exact
686
+ // settings leaf is chain-validated from the project root before it is read; an unreachable one is
687
+ // answered the same way an unreadable one already was, by erring toward WIRED (preserve the file).
409
688
  const gateHookWiredNow = (projectDir, fs) => {
410
689
  if (!projectDir) return false;
411
690
  const probe = (p) => {
691
+ try {
692
+ assertContainedRealPath(projectDir, p, { lstat: fs.lstat });
693
+ } catch {
694
+ return true;
695
+ }
412
696
  try {
413
697
  return fs.exists(p) ? settingsMentionsGateHook(String(fs.readFile(p, 'utf8'))) : false;
414
698
  } catch {
@@ -457,10 +741,26 @@ const settingsMentionsGateHook = (settings) => {
457
741
  // attribution key so it is still surfaced (no silent miss). The velocity writer stores NO ownership
458
742
  // marker, so the permissions seam is reported NON-COMMITTALLY — never a false ownership claim,
459
743
  // never auto-removed.
744
+ // The MCP seam: the enabled-list membership or either derived tool allow rule. Same posture as the
745
+ // two seams beside it — over-detection is safe (REPORT_ONLY is never auto-removed), a silent miss
746
+ // would leave a registration nobody was told about.
747
+ const MCP_ALLOW_RULES = allowRulesFor();
748
+ const MCP_PRESENT = MCP_STATE.PRESENT;
749
+ const SETTINGS_REL_TXT = '.claude/settings.json';
750
+ const settingsMentionsMcp = (settings, parsed) => {
751
+ if (parsed == null || typeof parsed !== 'object') {
752
+ return settings.includes(MCP_ENABLED_KEY) || MCP_ALLOW_RULES.some((rule) => settings.includes(rule));
753
+ }
754
+ const enabled = Array.isArray(parsed[MCP_ENABLED_KEY]) ? parsed[MCP_ENABLED_KEY] : [];
755
+ const allow = Array.isArray(parsed.permissions?.allow) ? parsed.permissions.allow : [];
756
+ return enabled.includes(MCP_SERVER_NAME) || MCP_ALLOW_RULES.some((rule) => allow.includes(rule));
757
+ };
758
+
460
759
  const detectSettingsSeams = (settings) => {
461
- if (settings == null) return { attribution: false, permissions: false, gateHook: false };
760
+ if (settings == null) return { attribution: false, permissions: false, gateHook: false, mcp: false, parsed: true };
462
761
  const gateHook = settingsMentionsGateHook(settings);
463
762
  const parsed = (() => { try { return JSON.parse(settings); } catch { return null; } })();
763
+ const mcp = settingsMentionsMcp(settings, parsed);
464
764
  if (parsed == null || typeof parsed !== 'object') {
465
765
  // Malformed / JSONC settings.json (comments, trailing commas) — probe the seams by substring so
466
766
  // none is silently missed (over-reporting REPORT_ONLY is safe; it is never auto-removed).
@@ -468,23 +768,33 @@ const detectSettingsSeams = (settings) => {
468
768
  attribution: settings.includes('includeCoAuthoredBy'),
469
769
  permissions: settings.includes('"permissions"') && (settings.includes('"defaultMode"') || settings.includes('"allow"')),
470
770
  gateHook,
771
+ mcp,
772
+ parsed: false,
471
773
  };
472
774
  }
473
775
  const perms = parsed.permissions;
474
776
  const permissions = perms != null && typeof perms === 'object'
475
777
  && (Object.prototype.hasOwnProperty.call(perms, 'defaultMode') || Object.prototype.hasOwnProperty.call(perms, 'allow'));
476
- return { attribution: Object.prototype.hasOwnProperty.call(parsed, 'includeCoAuthoredBy'), permissions, gateHook };
778
+ return { attribution: Object.prototype.hasOwnProperty.call(parsed, 'includeCoAuthoredBy'), permissions, gateHook, mcp, parsed: true };
477
779
  };
478
780
 
479
781
  const ATTRIBUTION_REASON = 'we set "includeCoAuthoredBy": false here — review/remove that key by hand (the file may hold your own settings)';
480
782
  const PERMISSIONS_REASON = 'a "permissions.defaultMode" and/or "permissions.allow" key is present in this file — if the velocity profile seeded them, review/remove by hand (no ownership marker is stored); otherwise leave them';
481
783
  const GATE_HOOK_SEAM_REASON = `a PreToolUse entry wiring the kit-placed gate-approval hook (${GATE_HOOK_FILE_REL}) is present — remove that entry by hand to unwire it (the tool never edits settings)`;
482
784
 
785
+ const MCP_SEAM_REASON = `the MCP registration keys for "${MCP_SERVER_NAME}" are present in this file ("${MCP_ENABLED_KEY}" and/or the ${MCP_ALLOW_RULES.join(' / ')} allow rules) — remove them by hand to unregister the typed channel (the tool never edits settings)`;
786
+ // Over a file that could not be PARSED, the same seams are probed by substring — so any string or
787
+ // comment mentioning a managed token matches. That is deliberate (a missed seam is worse than an
788
+ // over-reported one), but the sentence must not then assert presence it never established.
789
+ const UNCERTAIN_PREFIX = 'this file could not be parsed, so the seams below are text matches rather than established keys — verify before acting. ';
790
+
483
791
  const settingsSeamReason = (seams) =>
792
+ (seams.parsed === false ? UNCERTAIN_PREFIX : '') +
484
793
  [
485
794
  ...(seams.attribution ? [ATTRIBUTION_REASON] : []),
486
795
  ...(seams.permissions ? [PERMISSIONS_REASON] : []),
487
796
  ...(seams.gateHook ? [GATE_HOOK_SEAM_REASON] : []),
797
+ ...(seams.mcp ? [MCP_SEAM_REASON] : []),
488
798
  ].join('. Also: ');
489
799
 
490
800
  const settingsSeamHand = (seams, p) => {
@@ -496,6 +806,7 @@ const settingsSeamHand = (seams, p) => {
496
806
  : 'if the velocity profile seeded "permissions.defaultMode"/"permissions.allow", review/remove them by hand']
497
807
  : []),
498
808
  ...(seams.gateHook ? [`remove the PreToolUse entry whose command runs ${GATE_HOOK_FILE_REL}`] : []),
809
+ ...(seams.mcp ? [`remove "${MCP_SERVER_NAME}" from "${MCP_ENABLED_KEY}" and the ${MCP_ALLOW_RULES.join(" / ")} allow rules`] : []),
499
810
  ];
500
811
  return `edit ${shq(p)} → ${clauses.join(' and ')} (keep the rest of your settings)`;
501
812
  };