@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.
- package/CHANGELOG.md +117 -0
- package/README.md +3 -2
- package/SKILL.md +5 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review-await-guard.test.mjs +176 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +61 -14
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +606 -467
- package/bridges/antigravity-cli-bridge/references/review-prompt.md +42 -4
- package/bridges/codex-cli-bridge/SKILL.md +18 -5
- package/bridges/codex-cli-bridge/bin/codex-await-guard.test.mjs +161 -0
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +22 -17
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +356 -363
- package/bridges/codex-cli-bridge/bin/codex-review.sh +6 -6
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +275 -286
- package/bridges/codex-cli-bridge/capability.json +1 -1
- package/bridges/codex-cli-bridge/references/driving-codex.md +4 -2
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +3 -2
- package/bridges/codex-cli-bridge/setup/README.md +3 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/gate-approve.mjs +1 -1
- package/references/modes/grounding.md +1 -1
- package/references/modes/mcp.md +37 -0
- package/references/modes/procedures.md +3 -3
- package/references/modes/recommendations.md +1 -0
- package/references/modes/uninstall.md +2 -1
- package/references/templates/agent_rules.md +4 -5
- package/tools/commands.mjs +7 -0
- package/tools/direct-run.mjs +3 -0
- package/tools/doc-parity.mjs +18 -2
- package/tools/grounding.mjs +10 -20
- package/tools/inject-methodology.mjs +2 -0
- package/tools/mcp-registration.mjs +283 -0
- package/tools/mcp-server.mjs +314 -0
- package/tools/mcp-stdio.mjs +229 -0
- package/tools/mcp.mjs +299 -0
- package/tools/procedures.mjs +7 -8
- package/tools/recommendations.mjs +90 -1
- package/tools/uninstall.mjs +356 -45
package/tools/uninstall.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
};
|