@sabaiway/agent-workflow-kit 14.32.0 → 14.34.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.
@@ -1,9 +1,9 @@
1
1
  // ensure-executor.mjs — the EIGHTH upgrade ensure, fifth in the fixed order right after `vehicles`:
2
- // the placed executor vehicle (.claude/agents/executor.md) re-derived from docs/ai/vehicles.json.
3
- // The agents writer PLACES the vehicle on consent; this ensure never does — it only keeps a placed
4
- // body equal to the one derived from the settings, and a hand edit is replaced, never preserved.
2
+ // the placed executor vehicle (.claude/agents/executor.md) judged against the body derived from
3
+ // docs/ai/vehicles.json. It never writes: the agents writer PLACES the vehicle on consent, and a placed
4
+ // body that differs is reported `pending` — the advisor's executor-vehicle.differing item re-derives it.
5
5
  //
6
- // The state table, in the order the cells are proven (the write is the LAST step):
6
+ // The state table, in the order the cells are proven:
7
7
  // | cell | .claude, .claude/agents | placed path | docs/ai/vehicles.json | answer | read before answering |
8
8
  // |------|-------------------------|-------------|-----------------------|--------|-----------------------|
9
9
  // | 0 | a symlink or not a directory | any | any | failed / wrong-node-kind | nothing |
@@ -12,30 +12,27 @@
12
12
  // | 3 | directories | regular file | unreadable (malformed, read or lstat error) | failed / vehicle-settings-unreadable | the settings only |
13
13
  // | 4 | directories | regular file | present or absent | failed / bundle-unreadable when the bundled template cannot be read | settings, bundle |
14
14
  // | 5 | directories | regular file, bytes equal to the derived body | present or absent | already-current | settings, bundle, placed file |
15
- // | 6 | directories | regular file, bytes differ | present or absent | dry-run would-re-derive; apply re-derived, or failed / write-refused | settings, bundle, placed file |
15
+ // | 6 | directories | regular file, bytes differ (executorBodyDiffers) | present or absent | pending, on the dry run and the run alike | settings, bundle, placed file |
16
16
  // | 7 | an lstat of `.claude`, `.claude/agents` or the placed path throwing other than ENOENT, or the read of the placed file throwing | | | NOT caught: the error propagates to the CLI's catch-all (`unexpected-error`) | |
17
17
  //
18
18
  // Dependency-free, Node >= 22. Every fs primitive is injectable (deps.*). No side effects on import.
19
19
 
20
- import { readFileSync, lstatSync } from 'node:fs';
20
+ import { lstatSync } from 'node:fs';
21
21
  import { join } from 'node:path';
22
- import { writeContainedFileAtomic } from './atomic-write.mjs';
23
- import { composeFailure, composeOutcome, probeSeedTarget, tmpNote } from './ensure-ops.mjs';
24
- import { assertDirSafe, AGENTS_DIR, CLAUDE_DIR, CHEAP_AGENTS_SYMLINK, EXECUTOR_VEHICLE_REL, executorTemplate, readExecutorPosture } from './cheap-agents-read.mjs';
22
+ import { composeFailure, composeOutcome, probeSeedTarget } from './ensure-ops.mjs';
23
+ import { assertDirSafe, AGENTS_DIR, CLAUDE_DIR, CHEAP_AGENTS_SYMLINK, EXECUTOR_VEHICLE_REL, executorBodyDiffers, executorTemplate, readExecutorPosture } from './cheap-agents-read.mjs';
25
24
  import { VEHICLES_REL } from './vehicle-settings.mjs';
26
25
 
27
26
  export const EXECUTOR_OP = 'executor';
28
27
 
29
28
  const BUNDLE_DIR = ['references', 'agents'];
30
- const ENCODING = 'utf8';
31
29
 
32
30
  const ok = (token, lines) => composeOutcome(EXECUTOR_OP, token, lines, false);
33
31
  const loud = (cause, ...lines) => composeFailure(EXECUTOR_OP, cause, ...lines);
34
32
  const causeOf = (err) => String((err && err.message) || err);
35
33
 
36
- export const ensureExecutor = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
34
+ export const ensureExecutor = ({ cwd, kitRoot, deps = {} }) => {
37
35
  const lstat = deps.lstat ?? lstatSync;
38
- const read = deps.readFile ?? readFileSync;
39
36
 
40
37
  try {
41
38
  assertDirSafe(join(cwd, CLAUDE_DIR), CLAUDE_DIR, { lstat });
@@ -59,29 +56,15 @@ export const ensureExecutor = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
59
56
  return loud('vehicle-settings-unreadable', `${EXECUTOR_VEHICLE_REL}: ${reason} — nothing written; fix ${VEHICLES_REL} by hand, then re-run`);
60
57
  }
61
58
 
62
- let body;
59
+ const bundleDeps = { ...deps, bundleDir: join(kitRoot, ...BUNDLE_DIR) };
63
60
  try {
64
- body = executorTemplate(posture, { ...deps, bundleDir: join(kitRoot, ...BUNDLE_DIR) }).content;
61
+ executorTemplate(posture, bundleDeps);
65
62
  } catch (err) {
66
63
  return loud('bundle-unreadable', `${EXECUTOR_VEHICLE_REL}: the bundled executor template could not be read, so nothing was written — reinstall the kit. ${causeOf(err)}`);
67
64
  }
68
65
 
69
- const existing = read(target, ENCODING);
70
- if (existing === body) {
66
+ if (!executorBodyDiffers(cwd, bundleDeps)) {
71
67
  return ok('already-current', [`${EXECUTOR_VEHICLE_REL}: the body derived from ${VEHICLES_REL} (model=${posture.model}, effort=${posture.effort}, source ${posture.source}) — nothing written`]);
72
68
  }
73
- if (dryRun) {
74
- return ok('would-re-derive', [`${EXECUTOR_VEHICLE_REL}: differs from the body derived from ${VEHICLES_REL} — would be rewritten (a hand edit is replaced, never preserved)`]);
75
- }
76
-
77
- let result;
78
- try {
79
- result = writeContainedFileAtomic(cwd, target, body, deps, { label: EXECUTOR_VEHICLE_REL });
80
- } catch (err) {
81
- return loud('write-refused', `${EXECUTOR_VEHICLE_REL}: ${causeOf(err)}`);
82
- }
83
- return ok('re-derived', [
84
- `${EXECUTOR_VEHICLE_REL}: rewritten to the body derived from ${VEHICLES_REL} (model=${posture.model}, effort=${posture.effort}) — a hand edit was replaced; set the model in ${VEHICLES_REL}`,
85
- ...tmpNote(EXECUTOR_VEHICLE_REL, result.tmpLeftBehind),
86
- ]);
69
+ return ok('pending', [`${EXECUTOR_VEHICLE_REL}: differs from the body derived from ${VEHICLES_REL} — nothing written; the advisor's executor-vehicle.differing item re-derives it on your yes`]);
87
70
  };
@@ -18,9 +18,8 @@ export const ENSURE_OPS = Object.freeze(['orchestration', 'gates', 'autonomy', '
18
18
  // ensure's pair refresh (reader or checker) — a deployed script on a body a release shipped,
19
19
  // rewritten to the bundled one; a custom body is rewritten only by the replace run the user's yes
20
20
  // starts (--replace-custom), never by a plain run.
21
- // `re-derived` is the executor ensure rewriting a placed executor whose bytes differ from the body derived from docs/ai/vehicles.json.
22
- export const WRITE_TOKENS = Object.freeze(['seeded', 'note-refreshed', 'refreshed', 'regenerated', 're-derived']);
23
- export const DRY_RUN_TOKENS = Object.freeze(['would-seed', 'would-refresh-note', 'would-refresh', 'would-regenerate', 'would-re-derive']);
21
+ export const WRITE_TOKENS = Object.freeze(['seeded', 'note-refreshed', 'refreshed', 'regenerated']);
22
+ export const DRY_RUN_TOKENS = Object.freeze(['would-seed', 'would-refresh-note', 'would-refresh', 'would-regenerate']);
24
23
 
25
24
  // The CLOSED outcome vocabulary. Closed at RUNTIME, not by convention: composing an outcome with a
26
25
  // token outside this list throws, so an op cannot quietly invent a word the mode doc has never heard
@@ -33,6 +32,7 @@ export const ENSURE_TOKENS = Object.freeze([
33
32
  'already-current',
34
33
  'already-present',
35
34
  'not-placed',
35
+ 'pending',
36
36
  'customized-preserved',
37
37
  'malformed-preserved',
38
38
  'skipped-no-node-evidence',
@@ -80,7 +80,7 @@ export const RELAYED_ENSURE_TOKENS = Object.freeze([
80
80
  'note-refreshed',
81
81
  'refreshed',
82
82
  'regenerated',
83
- 're-derived',
83
+ 'pending',
84
84
  'already-current',
85
85
  'customized-preserved',
86
86
  'malformed-preserved',
@@ -2,7 +2,8 @@
2
2
  // git-hooks.mjs — the thin writer of the two git-hook items (`enforcement`, `commit-guard`): it copies
3
3
  // the bundled scripts create-only and spawns the project's own scripts/install-git-hooks.mjs, which
4
4
  // decides the hook at the write. The leaf decides nothing about the hook: the danger check binds the
5
- // copies and the installer bytes this dry run answers, never git's hooks dir.
5
+ // copies and the installer bytes this dry run answers, never git's hooks dir. It is also the home of the
6
+ // one pre-commit reader, which the commit-guard apply runs after the installer to verify the arm.
6
7
  import { lstatSync, readFileSync, readdirSync, rmdirSync, statSync, unlinkSync } from 'node:fs';
7
8
  import { createHash } from 'node:crypto';
8
9
  import { spawnSync } from 'node:child_process';
@@ -11,6 +12,7 @@ import { fileURLToPath } from 'node:url';
11
12
  import { writeProjectFileCreateOnly } from './atomic-write.mjs';
12
13
  import { NODE_EVIDENCE_SCRIPTS } from './node-evidence.mjs';
13
14
  import { shellQuoteArg } from './repo-lex.mjs';
15
+ import { GIT_MAX_BUFFER } from './git-env.mjs';
14
16
  import { isDirectRun } from './direct-run.mjs';
15
17
 
16
18
  const TOOLS = dirname(fileURLToPath(import.meta.url));
@@ -39,6 +41,34 @@ const nodeAt = (path, lstat) => {
39
41
  const isPlainDir = (node) => node !== null && node.isDirectory() && !node.isSymbolicLink();
40
42
  const isFile = (node) => node !== null && node.isFile() && !node.isSymbolicLink();
41
43
 
44
+ // The absolute hooks dir git itself reports (a linked worktree's own, core.hooksPath), or null outside a git tree.
45
+ export const resolveGitHooksPath = (projectDir, env = process.env) => {
46
+ const result = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], { cwd: projectDir, env, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
47
+ if (result.error || result.status !== 0) return null;
48
+ return resolve(projectDir, result.stdout.toString('utf8').replace(/\r?\n$/, ''));
49
+ };
50
+
51
+ export const INSTALLER_MARKER_RE = /:install-git-hooks\.mjs$/m;
52
+ const escapeRe = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
53
+ // Armed means the exact line the installer writes on the kit's own guard path; a commented or lookalike line is not.
54
+ export const canonicalGuardLineRe = (toolsDir = TOOLS) => new RegExp(`^node "${escapeRe(join(toolsDir, 'commit-guard.mjs'))}" --check$`, 'm');
55
+
56
+ // The one reader of git's pre-commit: whose hook it is, and whether the canonical guard line arms it.
57
+ export const readPreCommitState = (root, deps = {}) => {
58
+ const hooksPath = (deps.gitHooksPath ?? resolveGitHooksPath)(root, deps.env);
59
+ if (hooksPath === null) return { state: 'no-hooks-dir', armed: false };
60
+ const path = join(hooksPath, 'pre-commit');
61
+ let hook;
62
+ try {
63
+ (deps.lstat ?? lstatSync)(path);
64
+ hook = String((deps.readFile ?? readFileSync)(path, 'utf8'));
65
+ } catch (error) {
66
+ if (error?.code === 'ENOENT') return { state: 'absent', armed: false };
67
+ return { state: 'unreadable', code: error?.code ?? String(error?.message ?? error), path, armed: false };
68
+ }
69
+ return { state: INSTALLER_MARKER_RE.test(hook) ? 'managed' : 'unmanaged', armed: canonicalGuardLineRe(deps.toolsDir).test(hook) };
70
+ };
71
+
42
72
  // The dry run: what the apply copies and the installer it runs, with the bytes of each.
43
73
  export const planGitHooks = ({ root, variant, toolsDir = TOOLS, lstat = lstatSync, readFile = readFileSync }) => {
44
74
  const scripts = join(root, SCRIPTS_REL);
@@ -115,7 +145,7 @@ const rollBack = (plan, created, io) => {
115
145
 
116
146
  export const applyGitHooks = (plan, io = {}) => {
117
147
  const resolved = { lstat: lstatSync, unlink: unlinkSync, rmdir: rmdirSync, readdir: readdirSync, env: process.env,
118
- log: console.log, error: console.error, writeFile: writeProjectFileCreateOnly,
148
+ log: console.log, error: console.error, writeFile: writeProjectFileCreateOnly, toolsDir: TOOLS,
119
149
  spawn: (argv, options) => spawnSync(argv[0], argv.slice(1), options), ...io };
120
150
  const created = [];
121
151
  const leftovers = [];
@@ -145,7 +175,15 @@ export const applyGitHooks = (plan, io = {}) => {
145
175
  const child = resolved.spawn(plan.runs.argv, { cwd: plan.root, env: resolved.env, stdio: 'inherit' });
146
176
  if (child.status !== 0) return fail([`installer exited ${child.status ?? child.signal ?? child.error?.code}`]);
147
177
  for (const path of leftovers) resolved.error(`not removed: ${path}`);
148
- return EXIT_OK;
178
+ if (plan.variant !== 'commit-guard') return EXIT_OK;
179
+ // An installer older than the arm ignores --commit-guard and still exits 0, so the arm is read back under its env.
180
+ const hooksDir = resolveGitHooksPath(plan.root, resolved.env);
181
+ const hook = readPreCommitState(plan.root, { env: resolved.env, toolsDir: resolved.toolsDir, gitHooksPath: () => hooksDir });
182
+ if (hook.state === 'no-hooks-dir' || hook.armed) return EXIT_OK;
183
+ const path = join(hooksDir, 'pre-commit');
184
+ resolved.error(hook.state === 'unreadable' ? `${path} is unreadable (${hook.code}) after the installer exited 0`
185
+ : `${path} carries no commit-guard line after the installer exited 0 — an installer older than the arm ignores --commit-guard; refreshing ${INSTALLER_REL} from the kit's references/scripts/ copy is your own step`);
186
+ return EXIT_INCOMPLETE;
149
187
  };
150
188
 
151
189
  export const parseArgs = (argv) => {
@@ -14,9 +14,9 @@
14
14
  // Decisions (plan D1–D16): one managed fence, re-derived wholesale → re-run is byte-identical (zero
15
15
  // diff); tracked → ASK; present-but-untracked high-risk (generic names) → ASK; everything else → HIDE;
16
16
  // asks are excluded from the block unless opted in via `--include` (an asks-only, traversal/glob-free
17
- // path). Add-local-first, then DETECT + REPORT the residual legacy global block — removal is the
18
- // explicit, ASK-gated `--remove-global` (prints a restorable backup), never a default (an arbitrary
19
- // host's OTHER hidden repo may rely on the same root-anchored global lines). Windows is supported (text
17
+ // path). Add-local-first, then DETECT + REPORT the residual legacy global block — the hide never writes
18
+ // the global file; removal is the `--remove-global` mode alone, the advisor's footprint-global item
19
+ // (prints a restorable backup; another of the host's hidden repos may rely on the same lines). Windows is supported (text
20
20
  // edit; forward-slash patterns; CRLF preserved via EOL detection). Dependency-free, Node >= 22.
21
21
 
22
22
  import {
@@ -90,10 +90,13 @@ const canonicalize = (pattern) => {
90
90
  // canonicalize alone misses glob children, so the consented line would be dropped → a silent un-hide.
91
91
  const recognizeHideRule = (pattern) => canonicalize(pattern) ?? (matchesKnownGlob(pattern) ? normalizeSlashes(pattern) : null);
92
92
 
93
- // Slash-tolerant membership in the HISTORICAL global set.
94
- const historicalMatch = (pattern) => {
93
+ // The historical lines any repository's own ignore may carry: a removal plan needs one kit-specific line besides them.
94
+ const GENERIC_GLOBAL_PATHS = new Set(['/AGENTS.md', '/CLAUDE.md', '/docs/ai/', '/docs/plans/', '/.claude/skills/']);
95
+
96
+ // Slash-tolerant membership in a global path set (the HISTORICAL one by default).
97
+ const historicalMatch = (pattern, set = HISTORICAL_GLOBAL_PATHS) => {
95
98
  const p = normalizeSlashes(pattern);
96
- return HISTORICAL_GLOBAL_PATHS.has(p) || HISTORICAL_GLOBAL_PATHS.has(`${p}/`) || (p.endsWith('/') && HISTORICAL_GLOBAL_PATHS.has(p.slice(0, -1)));
99
+ return set.has(p) || set.has(`${p}/`) || (p.endsWith('/') && set.has(p.slice(0, -1)));
97
100
  };
98
101
 
99
102
  // ── EOL + line model ────────────────────────────────────────────────────────────
@@ -144,8 +147,8 @@ const defaultGit = (args, { cwd, env }) => {
144
147
  const runGit = (deps, dir, args) => (deps.git ?? defaultGit)(args, { cwd: dir, env: deps.env ?? process.env });
145
148
 
146
149
  const fsOf = (deps = {}) => ({
147
- readFile: deps.readFile ?? ((p) => readFileSync(p, 'utf8')),
148
- writeFile: deps.writeFile ?? ((p, c) => writeFileSync(p, c, 'utf8')),
150
+ readFile: deps.readFile ?? ((p, encoding = 'utf8') => readFileSync(p, encoding)),
151
+ writeFile: deps.writeFile ?? ((p, c, encoding = 'utf8') => writeFileSync(p, c, encoding)),
149
152
  stat: deps.stat ?? statSync,
150
153
  readdir: deps.readdir ?? readdirSync,
151
154
  });
@@ -362,45 +365,60 @@ const globalExcludesPath = (deps, dir, home) => {
362
365
  if (r.status === 1) return null; // unset = NORMAL "nothing to migrate" (not a STOP)
363
366
  if (r.status !== 0) throw stop(`git config --get core.excludesFile failed (exit ${r.status}): ${r.stderr.trim()}`);
364
367
  const p = r.stdout.trim();
365
- return p ? expandTilde(p, home) : null;
368
+ return p ? resolve(dir, expandTilde(p, home)) : null;
366
369
  };
367
370
 
368
- // Detect + REPORT a residual legacy global block; remove ONLY contiguous recognized runs (header +
369
- // historical paths) and ONLY when removeGlobal — printing the removed lines as a restorable backup.
370
- export const migrateFromGlobal = (deps, dir, { home, removeGlobal, dryRun }) => {
371
- const fsx = fsOf(deps);
372
- const gp = globalExcludesPath(deps, dir, home);
373
- if (!gp) return { found: false, source: null, removedLines: [], action: 'none' };
374
- let content;
371
+ // The pure core over the global file read as latin1: each recognised legacy line goes with its own
372
+ // terminator and every other byte stays; null without one kit-specific line among them.
373
+ export const globalRemovalOf = (text) => {
374
+ const parts = text.split(/(\r\n|\n|\r)/);
375
+ const removedLines = [];
376
+ let after = '';
377
+ let specific = false;
378
+ for (let i = 0; i < parts.length; i += 2) {
379
+ const t = parts[i].trim();
380
+ const pattern = lineToPattern(parts[i]);
381
+ const marker = isLegacyGlobalHeader(t) || isAd013Comment(t);
382
+ if (marker || (pattern && historicalMatch(pattern))) {
383
+ removedLines.push(parts[i]);
384
+ specific ||= marker || !historicalMatch(pattern, GENERIC_GLOBAL_PATHS);
385
+ } else after += parts[i] + (parts[i + 1] ?? '');
386
+ }
387
+ return specific ? { removedLines, after } : null;
388
+ };
389
+
390
+ // The one answer the hide's kept line, the advisor's probe, the danger check and --remove-global read.
391
+ export const planGlobalRemoval = (deps, root, { home }) => {
392
+ const source = globalExcludesPath(deps, root, home);
393
+ if (!source) return null;
394
+ let before;
375
395
  try {
376
- content = fsx.readFile(gp);
396
+ before = fsOf(deps).readFile(source, 'latin1');
377
397
  } catch (err) {
378
- if (err && err.code === 'ENOENT') return { found: false, source: gp, removedLines: [], action: 'none' };
379
- throw stop(`cannot read global excludes ${gp} (${err.code ?? 'fs error'})`);
398
+ if (err && err.code === 'ENOENT') return null;
399
+ throw stop(`cannot read global excludes ${source} (${err.code ?? 'fs error'})`);
380
400
  }
381
- const eol = detectEol(content);
382
- const lines = splitLines(content);
383
- const recognized = (raw) => {
384
- const t = raw.trim();
385
- if (isLegacyGlobalHeader(t) || isAd013Comment(t)) return true;
386
- const pat = lineToPattern(raw);
387
- return pat ? historicalMatch(pat) : false;
388
- };
389
- const kept = [];
390
- const removed = [];
391
- let i = 0;
392
- while (i < lines.length) {
393
- if (recognized(lines[i])) {
394
- while (i < lines.length && recognized(lines[i])) { removed.push(lines[i]); i += 1; }
395
- } else {
396
- kept.push(lines[i]);
397
- i += 1;
398
- }
401
+ const removal = globalRemovalOf(before);
402
+ return removal && { source, before, ...removal };
403
+ };
404
+
405
+ // Detect + REPORT a residual legacy global block; the hide never writes the global file.
406
+ export const migrateFromGlobal = (deps, dir, { home }) => {
407
+ const plan = planGlobalRemoval(deps, dir, { home });
408
+ if (!plan) return { found: false, source: null, removedLines: [], action: 'none' };
409
+ return { found: true, source: plan.source, removedLines: plan.removedLines, action: 'kept' };
410
+ };
411
+
412
+ // The --remove-global mode: no exclude file read, no local block written; a refused write is the lane's STOP.
413
+ const removeGlobal = (opts, deps) => {
414
+ const plan = planGlobalRemoval(deps, resolve(opts.dir ?? process.cwd()), { home: deps.home ?? os.homedir() });
415
+ if (!plan || opts.dryRun) return { plan, action: plan ? 'would-remove' : 'none' };
416
+ try {
417
+ fsOf(deps).writeFile(plan.source, plan.after, 'latin1');
418
+ } catch (err) {
419
+ throw stop(`cannot write the global excludes ${plan.source} (${err?.code ?? err?.message ?? err}) — the block stays`);
399
420
  }
400
- if (removed.length === 0) return { found: false, source: gp, removedLines: [], action: 'none' };
401
- if (!removeGlobal) return { found: true, source: gp, removedLines: removed, action: 'kept' };
402
- if (!dryRun) fsx.writeFile(gp, joinLines(kept, eol));
403
- return { found: true, source: gp, removedLines: removed, action: 'removed', backup: removed.join('\n') };
421
+ return { plan, action: 'removed' };
404
422
  };
405
423
 
406
424
  // ── --include validation (D4 — only an asks[] path; no traversal/glob) ───────────
@@ -452,9 +470,9 @@ export const hideFootprint = (opts = {}, deps = {}) => {
452
470
  const fenceBodyLines = fence.state === 'ok' ? lines.slice(fence.startIdx + 1, fence.endIdx) : [];
453
471
  const after = fence.state === 'ok' ? lines.slice(fence.endIdx + 1) : [];
454
472
 
455
- // ── unhide (D12): drop our fence; report/remove the residual global block ──────
473
+ // ── unhide (D12): drop our fence; report the residual global block ──────
456
474
  if (opts.unhide) {
457
- const global = migrateFromGlobal(deps, dir, { home, removeGlobal: opts.removeGlobal, dryRun });
475
+ const global = migrateFromGlobal(deps, dir, { home });
458
476
  const newLines = [...before, ...after];
459
477
  const newContent = joinLines(newLines, eol);
460
478
  const changed = newContent !== content;
@@ -520,8 +538,8 @@ export const hideFootprint = (opts = {}, deps = {}) => {
520
538
  throw stop(`hide verification failed — these were written but are not hidden by the project-local exclude: ${notHidden.map((v) => v.path).join(', ')}`);
521
539
  }
522
540
 
523
- // ── then detect/report (or remove) the residual legacy global block (D6) ───────
524
- const global = migrateFromGlobal(deps, dir, { home, removeGlobal: opts.removeGlobal, dryRun });
541
+ // ── then detect + report the residual legacy global block (D6) ─────────────────
542
+ const global = migrateFromGlobal(deps, dir, { home });
525
543
 
526
544
  const action = !changed ? 'noop' : fence.state === 'ok' ? 'updated' : 'created';
527
545
  return {
@@ -545,29 +563,29 @@ export const hideFootprint = (opts = {}, deps = {}) => {
545
563
 
546
564
  // ── CLI ───────────────────────────────────────────────────────────────────────────
547
565
 
548
- const USAGE = `usage: hide-footprint [--dir <project>] [--reconcile] [--include=<path>]... [--keep-global | --remove-global] [--unhide] [--dry-run] [--help]
566
+ const USAGE = `usage: hide-footprint [--dir <project>] [--reconcile] [--include=<path>]... [--unhide] [--dry-run] [--help]
567
+ hide-footprint --remove-global [--dir <project>] [--dry-run]
549
568
 
550
569
  --dir <project> the target project (default: cwd)
551
570
  --reconcile upgrade mode — infer visibility first; write zero bytes when VISIBLE, ASK when AMBIGUOUS
552
571
  --include=<path> opt a surfaced ASK path into the hidden set (only an asks[] path; no glob/traversal)
553
- --keep-global keep + report the residual legacy machine-global block (DEFAULT)
554
- --remove-global remove the recognized legacy machine-global block (prints a restorable backup)
555
- --unhide remove the project-local managed block (residual global only with --remove-global)
572
+ --remove-global its own mode: remove the recognized legacy machine-global block (prints a restorable backup)
573
+ --unhide remove the project-local managed block
556
574
  --dry-run print the plan; change nothing
557
575
  --help, -h this help
558
576
 
559
- Writes ONE managed block in the PROJECT-LOCAL .git/info/exclude (never the machine-global excludes).
577
+ Writes ONE managed block in the PROJECT-LOCAL .git/info/exclude; a residual legacy machine-global block is
578
+ kept and reported, and only --remove-global (the advisor's footprint-global item) removes it.
560
579
  Tracked files are never silently un-tracked — the tool prints the \`git rm --cached\` it will not run.`;
561
580
 
562
581
  const parseArgs = (argv) => {
563
- const out = { dir: undefined, reconcile: false, include: [], removeGlobal: false, keepGlobal: false, unhide: false, dryRun: false, help: false, bad: null };
582
+ const out = { dir: undefined, reconcile: false, include: [], removeGlobal: false, unhide: false, dryRun: false, help: false, bad: null };
564
583
  for (let i = 0; i < argv.length; i += 1) {
565
584
  const a = argv[i];
566
585
  if (a === '--help' || a === '-h') out.help = true;
567
586
  else if (a === '--dry-run') out.dryRun = true;
568
587
  else if (a === '--reconcile') out.reconcile = true;
569
588
  else if (a === '--unhide') out.unhide = true;
570
- else if (a === '--keep-global') out.keepGlobal = true;
571
589
  else if (a === '--remove-global') out.removeGlobal = true;
572
590
  else if (a === '--dir') {
573
591
  const next = argv[i + 1];
@@ -580,15 +598,22 @@ const parseArgs = (argv) => {
580
598
  else { out.include.push(next); i += 1; }
581
599
  } else out.bad = `unknown argument: ${a}`;
582
600
  }
583
- if (out.keepGlobal && out.removeGlobal) out.bad = '--keep-global and --remove-global are mutually exclusive';
601
+ if (out.removeGlobal && !out.bad && (out.reconcile || out.unhide || out.include.length)) out.bad = '--remove-global is its own mode — it cannot be combined with --reconcile, --unhide or --include';
584
602
  return out;
585
603
  };
586
604
 
587
605
  const fmtGlobal = (g) => {
588
- if (!g || g.action === 'none' || g.action === 'skipped') return [];
589
- if (g.action === 'kept') return [` • residual legacy machine-global block in ${g.source} (${g.removedLines.filter((l) => l.trim() && !l.trim().startsWith('#')).length} path line(s)) — KEPT + reported; pass --remove-global to remove (with a printed backup)`];
590
- if (g.action === 'removed') return [` • removed the legacy machine-global block from ${g.source} (backup of ${g.removedLines.length} line(s) printed below)`, ...g.removedLines.map((l) => ` | ${l}`)];
591
- return [];
606
+ if (!g || g.action !== 'kept') return [];
607
+ return [` • residual legacy machine-global block in ${g.source} (${g.removedLines.filter((l) => l.trim() && !l.trim().startsWith('#')).length} path line(s)) — KEPT + reported; the advisor's footprint-global item removes it (with a printed backup)`];
608
+ };
609
+
610
+ const formatRemoval = ({ plan, action }, dryRun) => {
611
+ const lines = [dryRun ? 'hide-footprint — DRY RUN (no changes)' : 'hide-footprint'];
612
+ if (action === 'none') return [...lines, ' • no legacy machine-global block to remove'].join('\n');
613
+ const n = plan.removedLines.length;
614
+ lines.push(action === 'removed' ? ` • removed the legacy machine-global block from ${plan.source} (backup of ${n} line(s) printed below)`
615
+ : ` • would remove the legacy machine-global block from ${plan.source} (${n} line(s)):`);
616
+ return [...lines, ...plan.removedLines.map((l) => ` | ${l}`)].join('\n');
592
617
  };
593
618
 
594
619
  export const formatReport = (r, dryRun) => {
@@ -624,10 +649,11 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
624
649
  if (args.help) { log(USAGE); return 0; }
625
650
  if (args.bad) { errlog(args.bad); errlog(USAGE); return 2; }
626
651
  try {
627
- const result = hideFootprint(
628
- { dir: args.dir, dryRun: args.dryRun, reconcile: args.reconcile, include: args.include, removeGlobal: args.removeGlobal, unhide: args.unhide },
629
- deps,
630
- );
652
+ if (args.removeGlobal) {
653
+ log(formatRemoval(removeGlobal({ dir: args.dir, dryRun: args.dryRun }, deps), args.dryRun));
654
+ return 0;
655
+ }
656
+ const result = hideFootprint({ dir: args.dir, dryRun: args.dryRun, reconcile: args.reconcile, include: args.include, unhide: args.unhide }, deps);
631
657
  log(formatReport(result, args.dryRun));
632
658
  return 0;
633
659
  } catch (err) {
@@ -13,7 +13,7 @@ import { preflightInit } from './init-preflight.mjs';
13
13
  import { buildRecommendations, hostFacts } from './recommendations.mjs';
14
14
  import { compareSemver } from './semver-lite.mjs';
15
15
  import { EXPECTED_WORKFLOW_VERSION } from './velocity-profile.mjs';
16
- import { LANE_HARNESS, VARIANT_LANES, consoleArgv, isEnvDependent, isProtectedPath, laneOf, writesOf } from './write-lanes.mjs';
16
+ import { LANE_HARNESS, VARIANT_LANES, consoleArgv, isEnvDependent, isProtectedPath, isRejudged, laneOf, writesOf } from './write-lanes.mjs';
17
17
 
18
18
  const INSIDE_LINE = 'you are inside an agent; run this in your own terminal';
19
19
  const APPLY_PROMPT = 'apply? y/N: ';
@@ -216,18 +216,21 @@ const previewQueue = async (context, queue) => {
216
216
  }
217
217
  context.print(result.stdout);
218
218
  const tuple = result.stdout.match(/--apply\s+(\S+)/)?.[1];
219
- const facts = { ...context.facts, tuple };
219
+ const facts = { ...context.facts, env: context.spawnEnv, tuple };
220
+ let checkDigest = null;
220
221
  if (laneOf(item.variant) === LANE_HARNESS) {
221
222
  try {
222
- const { lines, refused } = context.checkApply(item.variant, facts, {});
223
+ const { lines, refused, digest } = context.checkApply(item.variant, facts, {});
223
224
  for (const line of lines) context.print(line);
224
225
  if (refused.length > 0) throw new Error(refused.map(({ key }) => key).join(', '));
226
+ checkDigest = digest;
225
227
  } catch (error) {
226
228
  context.print(`not applied: ${item.variant} — the danger check refused: ${error.message}`);
227
229
  continue;
228
230
  }
229
231
  }
230
- batch.push({ ...item, applyArgv: consoleArgv(item.variant, facts).apply, writes: writesOf(item.variant, facts) });
232
+ batch.push({ ...item, checkFacts: facts, checkDigest, applyArgv: consoleArgv(item.variant, facts).apply,
233
+ writes: writesOf(item.variant, facts) });
231
234
  printNote(context, item.key);
232
235
  }
233
236
  for (const item of queue.filter(({ variant }) => consoleArgv(variant, context.facts) === null)) {
@@ -254,11 +257,24 @@ const takeRecord = (context, queue) => {
254
257
  }
255
258
  };
256
259
 
260
+ // A rejudged item applies only while its check, run again now, answers the digest its preview showed; else the reason.
261
+ const rejudgeFailure = (context, item) => {
262
+ try {
263
+ const { refused, digest } = context.checkApply(item.variant, item.checkFacts, {});
264
+ if (refused.length > 0) return `it refuses ${refused.map(({ key }) => key).join(', ')}`;
265
+ return digest === item.checkDigest ? null : 'its digest moved';
266
+ } catch (error) {
267
+ return String(error?.message || error);
268
+ }
269
+ };
270
+
257
271
  const runPass = async (context, queue) => {
258
272
  const record = takeRecord(context, queue);
259
273
  if (record === null) return { code: 1, successes: 0, variants: [] };
260
- const batch = await previewQueue(context, queue);
261
- if (batch.length === 0) return { code: 0, successes: 0, variants: [] };
274
+ const previewed = await previewQueue(context, queue);
275
+ if (previewed.length === 0) return { code: 0, successes: 0, variants: [] };
276
+ // Rejudged items go first: an earlier writer of the batch (one creating .claude/) would move their check.
277
+ const batch = [...previewed.filter(({ variant }) => isRejudged(variant)), ...previewed.filter(({ variant }) => !isRejudged(variant))];
262
278
  const before = new Map(record);
263
279
  const outcome = { code: 0, successes: 0, variants: batch.map((item) => item.variant) };
264
280
  context.print(RUN_NONE);
@@ -271,6 +287,15 @@ const runPass = async (context, queue) => {
271
287
  outcome.code = 1;
272
288
  break;
273
289
  }
290
+ const failure = isRejudged(item.variant) ? rejudgeFailure(context, item) : null;
291
+ if (failure !== null) {
292
+ context.print(`not applied: ${item.variant} — its danger check no longer answers the previewed result: ${failure}`);
293
+ for (const later of batch.slice(index + 1)) {
294
+ context.print(`not applied: ${later.variant} — an earlier item's danger check no longer answers the previewed result`);
295
+ }
296
+ outcome.code = 1;
297
+ break;
298
+ }
274
299
  const result = await launch(context, item.applyArgv, ['inherit', 'pipe', 'inherit']);
275
300
  if (result.stdout) context.print(result.stdout);
276
301
  const success = result.valid && (result.status === 0 || (result.status === 5 && result.stdout.includes('install completed')));