@sabaiway/agent-workflow-kit 14.33.0 → 14.35.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.
@@ -135,6 +135,18 @@ export const executorTemplate = (posture, deps = {}) => {
135
135
  return { name: EXECUTOR_VEHICLE, content: deriveExecutorBody(bundled.content, posture), rederive: true };
136
136
  };
137
137
 
138
+ // The executor ensure's cell 6: a regular placed executor whose bytes differ from the body derived from
139
+ // the settings; the placed file's read failure throws.
140
+ export const executorBodyDiffers = (projectDir, deps = {}) => {
141
+ const fs = readFsDeps(deps);
142
+ const abs = join(projectDir, EXECUTOR_VEHICLE_REL);
143
+ const stat = lstatNoFollow(abs, fs);
144
+ if (stat === null || stat.isSymbolicLink() || !stat.isFile()) return false;
145
+ const { posture } = readExecutorPosture(projectDir, deps);
146
+ if (posture === null) return false;
147
+ return fs.readFile(abs, UTF8) !== executorTemplate(posture, deps).content;
148
+ };
149
+
138
150
  const READ_ONLY_TOOLS = new Set(['Read', 'Grep', 'Glob']);
139
151
 
140
152
  // The YAML subset a vehicle's frontmatter is read with: a bare scalar, a single- or double-quoted
@@ -194,7 +194,7 @@ const CATALOG = [
194
194
  invocation: invocationOf('mcp'),
195
195
  group: 'Configure',
196
196
  kind: GUARDED,
197
- oneLine: 'Register the kit’s read-only MCP server in this project, so path questions and literal searches run as typed tool calls instead of shell command strings — previews the exact entry it would declare, writes only on your yes, and where a sandbox hides the file it hands you the text to paste instead.',
197
+ oneLine: 'Register the kit’s read-only MCP server in this project, so path questions and literal searches run as typed tool calls instead of shell command strings — previews the exact entry it would declare, writes only on your yes, and where a sandbox hides the file it writes nothing and names the advisor’s mcp-channel item, which applies it by the consent route.',
198
198
  },
199
199
  {
200
200
  key: 'recipes',
@@ -41,9 +41,7 @@
41
41
  // boundary). Read-only; dependency-free; Node >= 22. No side effects on import.
42
42
 
43
43
  import { readFileSync, lstatSync } from 'node:fs';
44
- import { resolve } from 'node:path';
45
44
  import { fileURLToPath } from 'node:url';
46
- import { spawnSync } from 'node:child_process';
47
45
  import { createHash } from 'node:crypto';
48
46
  import { isDirectRun } from './direct-run.mjs';
49
47
  import { computeTreeFingerprint, buildState, decideCheck, quoteReportName, shellQuoteArg } from './review-state.mjs';
@@ -54,25 +52,14 @@ import {
54
52
  import { resolveLcovPath } from './coverage-check.mjs';
55
53
  import { GATES_REL, loadDeclaration } from './run-gates.mjs';
56
54
  import { computeFlowDecision } from './flow-check.mjs';
57
- import { GIT_MAX_BUFFER, resolveGitLocation } from './git-env.mjs';
55
+ import { resolveGitLocation } from './git-env.mjs';
58
56
  import { escapeForDisplay } from './repo-lex.mjs';
59
57
 
60
58
  const usageFail = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { exitCode: 2 });
61
59
  const sha = (text) => createHash('sha256').update(text).digest('hex');
62
60
 
63
- const gitLine = (args, cwd) => {
64
- const r = spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
65
- if (r.error || r.status !== 0) return null;
66
- return r.stdout.toString('utf8').replace(/\r?\n$/, '');
67
- };
68
-
69
- // resolveGitHooksPath(projectDir) → the ABSOLUTE hooks dir git itself reports (a linked worktree
70
- // answers with ITS OWN hooks path — never a hardcoded `.git/hooks`), or null outside a git tree.
71
- // The one home consumers (the recommendations guard-install probe) read instead of re-deriving it.
72
- export const resolveGitHooksPath = (projectDir) => {
73
- const line = gitLine(['rev-parse', '--git-path', 'hooks'], projectDir);
74
- return line == null ? null : resolve(projectDir, line);
75
- };
61
+ // The hooks-path question and the pre-commit reader live in git-hooks.mjs, their writer's home.
62
+ export { resolveGitHooksPath, readPreCommitState, canonicalGuardLineRe, INSTALLER_MARKER_RE } from './git-hooks.mjs';
76
63
 
77
64
  // How many offending paths the index-lag refusal names before it states a remainder count: enough
78
65
  // to act on, bounded so a wide lag cannot bury a pre-commit hook's output.
@@ -5,7 +5,7 @@
5
5
  // gates docs/ai/gates.json seed-if-missing (an existing declaration is authored content)
6
6
  // autonomy docs/ai/autonomy.json seed-if-missing (same)
7
7
  // vehicles docs/ai/vehicles.json seed-if-missing (same)
8
- // executor .claude/agents/executor.md re-derived from docs/ai/vehicles.json when its bytes differ; absent stays absent
8
+ // executor .claude/agents/executor.md judged against docs/ai/vehicles.json, never written: a differing body is pending
9
9
  // scripts scripts/<ADR enforcement> seed-if-missing, ADR-layout detect FIRST
10
10
  // specs scripts/<spec layer> reader + checker pairs seeded when absent, refreshed only on
11
11
  // + docs/ai/specs/index.md a shipped prior body, an edited one replaced only on the
@@ -80,16 +80,16 @@ Usage:
80
80
  --help, -h this help
81
81
 
82
82
  Every SEED is CREATE-ONLY: an existing file is preserved byte-for-byte, never clobbered and never
83
- refreshed in place. Four ops refresh instead: the orchestration onboarding note, only while it still
83
+ refreshed in place. Three ops refresh instead: the orchestration onboarding note, only while it still
84
84
  matches a canonical the kit shipped (your own wording is preserved verbatim); the spec-layer reader
85
85
  and checker pairs, only while a file's bytes are a body a release shipped (an edited file is
86
86
  preserved, the writes that depend on it — the spec store root included — are withheld behind it,
87
- and ONE printed replace line replaces every edited file only on the user's yes); the placed executor
88
- vehicle (.claude/agents/executor.md), re-derived from docs/ai/vehicles.json
89
- whenever its bytes differ from the derived body (a hand edit is replaced, never preserved; an absent
90
- file is never placed here); and the navigator index — a GENERATED artifact, regenerated
87
+ and ONE printed replace line replaces every edited file only on the user's yes); and the navigator index — a GENERATED artifact, regenerated
91
88
  whenever it is missing or stale; where a module the project's hook loads is not the bundled one,
92
89
  the project's own checker judges the navigator and a write it rejects is put back byte for byte.
90
+ The executor ensure writes nothing: a placed .claude/agents/executor.md whose bytes differ from the
91
+ body derived from docs/ai/vehicles.json is pending — the advisor's executor-vehicle.differing item
92
+ changes it on your yes; an absent file is never placed here.
93
93
  The enforcement-script ensure detects an older ADR-store layout FIRST and instructs the opt-in
94
94
  migration instead of seeding.
95
95
 
@@ -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) {
@@ -216,7 +216,7 @@ 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
220
  let checkDigest = null;
221
221
  if (laneOf(item.variant) === LANE_HARNESS) {
222
222
  try {
@@ -311,7 +311,7 @@ const classifyDir = (abs, io) => {
311
311
  return { state: STATE.FOREIGN, className: describeNonRegular(st) };
312
312
  };
313
313
 
314
- // ── the merges (ONE definition, shared by the writer's bodies and the paste-ready fragments) ──
314
+ // ── the merges (ONE definition, shared by the writer's bodies) ──
315
315
  // Merge-don't-clobber in both: every foreign server, key and allow rule is carried over untouched,
316
316
  // and our own entry/rules are added idempotently.
317
317
 
@@ -335,19 +335,6 @@ export const mergeSettings = (registration) => {
335
335
 
336
336
  export const formatJson = (data, eol) => `${JSON.stringify(data, null, JSON_INDENT).replaceAll(LF, eol)}${eol}`;
337
337
 
338
- // The two hand-apply bodies — the whole output of the MASKED arm.
339
- //
340
- // They are deliberately DIFFERENT shapes, because what the kit knows about each file differs. The
341
- // settings file was OBSERVABLE — and read where present — so its body is a real merge: it already
342
- // carries every foreign key it had. The masked `.mcp.json` was not observable at all, so a
343
- // whole-file body would name only our server and, pasted as
344
- // instructed, would delete every foreign server the mask hid. `mcpEntry` is therefore the ENTRY
345
- // ALONE, to be merged under `mcpServers` by a human who can see what is actually in that file.
346
- export const renderFragments = (registration) => ({
347
- mcpEntry: formatJson(registration.entry, registration.mcpJson.eol),
348
- settings: formatJson(mergeSettings(registration), registration.settings.eol),
349
- });
350
-
351
338
  // A LIBRARY module the mode doc names by path — so someone can try to run it. A no-op on import;
352
339
  // on a direct run it points at the command that acts on what this module only reports.
353
340
  refuseDirectRun(import.meta.url);