@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.
@@ -8,7 +8,7 @@
8
8
 
9
9
  Requires: ${CLAUDE_SKILL_DIR}/references/shared/command-shapes.md
10
10
 
11
- The opt-in onboarding **velocity profile** — it seeds a fixed, audited **read-only** Claude Code allowlist into `.claude/settings.json` so an agent stops idling on approval prompts for routine read-only commands while the maintainer is away. It is the family's **first programmatic `.claude/settings.json` writer** (attribution stayed an agent-driven prose merge). **In-agent, opt-in, writes only `.claude/settings.json`**, on one hard rule: **it never allowlists `commit`/`push`/`publish`** — so a direct commit/push/publish still ASKs; the only caveat is the trust-posture residual (below) — its closure is **shipped, opt-in: `${CLAUDE_SKILL_DIR}/references/modes/hook.md`**.
11
+ The opt-in onboarding **velocity profile** — it seeds a fixed, audited **read-only** Claude Code allowlist into `.claude/settings.json` so an agent stops idling on approval prompts for routine read-only commands while the maintainer is away. It is the family's **first programmatic `.claude/settings.json` writer** (attribution stayed an agent-driven prose merge). **In-agent, opt-in, writes `.claude/settings.json`** — its `--worktrees` mode alone writes `.claude/settings.local.json` plus the created parent — on one hard rule: **it never allowlists `commit`/`push`/`publish`** — so a direct commit/push/publish still ASKs; the only caveat is the trust-posture residual (below) — its closure is **shipped, opt-in: `${CLAUDE_SKILL_DIR}/references/modes/hook.md`**.
12
12
 
13
13
  **Version-status routing (like the other writer modes):** read `docs/ai/.workflow-version` first — not-deployed → bootstrap; stamp < `4.0.0` → `upgrade`; stamp > head / unparseable → STOP. The tool enforces this in code too (`--apply` STOPs unless the stamp is the lineage head).
14
14
 
@@ -17,8 +17,8 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs [--dry-run | --apply] [
17
17
  1. **`--dry-run` first, always** (the default — changes nothing). It prints: the fixed read-only core it would add; the apply ask rule it would add (step 3); a **read-only advisory** that lists your `package.json` `scripts` as **unaudited candidates you may add BY HAND** (inspect each first) to `.claude/settings.json` / `settings.local.json` — the tool **never** writes them and flags obviously-mutating names as "do not add"; any **pre-existing non-read-only `Bash(...)` entries** to consider removing by hand; and the honest residual notice (below). It STOPs (zero writes) on a symlinked `.claude` / non-regular `settings.json`, malformed settings JSON, or an unsafe `permissions.defaultMode` — `bypassPermissions` or anything outside `default`/`acceptEdits`/`plan`, present in **either** `settings.json` or `settings.local.json`.
18
18
  2. **Ask the opt-ins** via **`AskUserQuestion` where supported** — two separate choices, the safe option FIRST in each. First the **`--kit-tools` tier** (separate from `acceptEdits`; **"core only" remains the default answer** — seed the tier only on an explicit yes, then add `--kit-tools` to the `--apply` re-run). Then the `acceptEdits` opt-in:
19
19
  - **"Keep per-edit approval prompts (recommended)"** — seed only the read-only allowlist; file edits still prompt.
20
- - **"Auto-accept file edits (`defaultMode: acceptEdits`)"** — present the honest FULL posture: it auto-applies Edit/Write AND auto-runs `mkdir`/`touch`/`mv`/`cp` in the working dir, is paired with the read-only allowlist, and — stated plainly — a settings-level allow rule is a **trust posture, not a sandbox**: a read-only entry can still write a file via output redirection, and (Claude Code's allow rules do not inspect command substitution) could in principle run another command via `cmd $(…)`. velocity **never adds `commit`/`push`/`publish` as allow rules** — so a direct `git push` still ASKs — and the residual guard for the seeded core ships as the opt-in PreToolUse hook (`${CLAUDE_SKILL_DIR}/references/modes/hook.md`; probe-proven on the `--output` family — current engine builds already intercept redirection/substitution upstream). The approval **floor** (never auto-approved, with or without the tier): every writer `--apply`/`--write`/`--yes` still prompts; clobber-protection STOPs still stop; the three release asks (commit/push/publish) stay maintainer-owned. Note also that a `defaultMode` in `settings.local.json` would override this project-level write (local > project), since velocity writes only `.claude/settings.json`.
21
- 3. **Only on an explicit yes**, re-run with `--apply` (add `--accept-edits` only if they chose the second option). It merges-don't-clobber (preserves `includeCoAuthoredBy`, every key, and existing allow entries) and writes **only** `.claude/settings.json`. Every apply — flagless, `--kit-tools`, `--bridge-tier` — also appends one ask rule, `Bash(node *apply-danger-check.mjs*--apply*)`, to `permissions.ask` once, beside any ask entries already there: the rule exists so all work can stay in auto mode: without it auto mode refuses a harness apply (the `apply:` line of each harness item the advisor renders) and the user must leave auto mode for it; with it the harness shows its usual dialog. The rule allows nothing, and `--autonomy` leaves it in place. When you name the rule to the user, lead with this purpose.
20
+ - **"Auto-accept file edits (`defaultMode: acceptEdits`)"** — present the honest FULL posture: it auto-applies Edit/Write AND auto-runs `mkdir`/`touch`/`mv`/`cp` in the working dir, is paired with the read-only allowlist, and — stated plainly — a settings-level allow rule is a **trust posture, not a sandbox**: a read-only entry can still write a file via output redirection, and (Claude Code's allow rules do not inspect command substitution) could in principle run another command via `cmd $(…)`. velocity **never adds `commit`/`push`/`publish` as allow rules** — so a direct `git push` still ASKs — and the residual guard for the seeded core ships as the opt-in PreToolUse hook (`${CLAUDE_SKILL_DIR}/references/modes/hook.md`; probe-proven on the `--output` family — current engine builds already intercept redirection/substitution upstream). The approval **floor** (never auto-approved, with or without the tier): every writer `--apply`/`--write`/`--yes` still prompts; clobber-protection STOPs still stop; the three release asks (commit/push/publish) stay maintainer-owned. Note also that a `defaultMode` in `settings.local.json` would override this project-level write (local > project), since velocity writes `.claude/settings.json` here — its `--worktrees` mode alone writes `.claude/settings.local.json`, and only its one grant entry.
21
+ 3. **Only on an explicit yes**, re-run with `--apply` (add `--accept-edits` only if they chose the second option). It merges-don't-clobber (preserves `includeCoAuthoredBy`, every key, and existing allow entries) and writes **only** `.claude/settings.json` (the `--worktrees` mode below is the one exception). Every apply — flagless, `--kit-tools`, `--bridge-tier` — also appends one ask rule, `Bash(node *apply-danger-check.mjs*--apply*)`, to `permissions.ask` once, beside any ask entries already there: the rule exists so all work can stay in auto mode: without it auto mode refuses a harness apply (the `apply:` line of each harness item the advisor renders) and the user must leave auto mode for it; with it the harness shows its usual dialog. The rule allows nothing, and `--autonomy` leaves it in place. When you name the rule to the user, lead with this purpose.
22
22
  4. **Surface delegation-readiness, read-only.** If they want a step run Delegated, set it with `/agent-workflow-kit set-recipe --set plan-execution.execute=delegated` (`${CLAUDE_SKILL_DIR}/references/modes/set-recipe.md`) or by hand-editing `docs/ai/orchestration.json`; **velocity itself never writes the orchestration config.**
23
23
 
24
24
  **The `--kit-tools` tier (opt-in).** On top of the read-only core, `--kit-tools` seeds an audited kit-tool tier — every path **resolved absolute from the running skill at seed time**, so the posture is **fail-safe**: a moved or reinstalled skill leaves a stale rule that simply **prompts again** (re-run velocity to reseed; nothing widens silently). A seeded entry equals its covered dispatch line below with `${CLAUDE_SKILL_DIR}` replaced by the resolved skill dir and `${PROJECT_ROOT}` by this project's resolved root, **UNQUOTED** — a quoted path is a different byte string and will still prompt (a dead rule, not a hole). The covered dispatch lines (this list IS the documented-invocation source):
@@ -43,7 +43,7 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs [--dry-run | --apply] [
43
43
 
44
44
  Honesty notes: tier entries get **NO PreToolUse-hook residual coverage — with TWO deliberate exceptions, `repo-search.mjs` and `path-inventory.mjs`**, whose prefixes are in the hook's scanned list because they take caller-supplied argument bytes: a redirection or substitution on THEIR invocation raises the ask, whose reason names that tool's out-of-band lane (`--pattern-file` / `--paths-file`) — context for the HUMAN deciding, not a message to the caller. Neither is in the seeded core, so neither inherits the read-lane allow. Every other tier entry: the opt-in hook's residual ask-net guards only the seeded read-only CORE prefixes, so the tier rides the same settings-level residual posture as the core (redirection / command substitution are not inspectable at the settings layer; see the residual notice). A skill or project path that cannot survive UNQUOTED in a byte-exact rule (spaces, metacharacters, non-POSIX) **STOPs the tier up front with a clear error** — nothing is seeded. Anything you want covered beyond the tier — such paths, this repo's own relative-path spellings, other tools — stays a **BY-HAND add** to your settings, with the path your project actually reaches the kit by. Pre-existing `node …` allow entries that do NOT match the seeded tier byte-forms stay flagged by the advisory for hand review.
45
45
 
46
- **Invariants:** creates `.claude/` if absent and writes **only** `.claude/settings.json` (no other file); **never** allowlists commit/push/publish; **never** writes `settings.local.json`; never commits; opt-in `acceptEdits`, never silent.
46
+ **Invariants:** creates `.claude/` if absent and writes `.claude/settings.json`, and with `--worktrees` alone `.claude/settings.local.json` plus the created parent (no other file); **never** allowlists commit/push/publish; never commits; opt-in `acceptEdits`, never silent.
47
47
 
48
48
  **Every settings-derived RUNTIME claim on this mode's surfaces is host-conditional.** Whether a host applies the `sandbox.*` settings keys is **not knowable from here** — a settings-native host honors them; an IDE/session-imposed sandbox was observed ignoring `sandbox.network.allowedDomains` / `sandbox.filesystem.allowWrite` in BOTH scopes. So every statement about what a settings key DOES at run time is stated **`where the host honors the settings sandbox keys`**, never promised — the bridge tier's exclusion routing; the autonomy render's `allowedDomains` / `allowWrite` / `allowUnsandboxedCommands` / `excludedCommands` degrade lines over FOREIGN keys; and equally the render's claims about the keys it writes ITSELF (the `sandbox:` line, the `fs_outside_repo` confinement note, the network prompt-on-egress and credentials-coverage degrades), which is where a clean deployment reads a promise. The qualifier rides every such line, and one notice naming the unknown closes **every** `--autonomy` render, clean settings included. A malformed `allowWrite` (not an array, or an entry that is not a non-empty string) is reported as unreadable rather than assumed empty — as UNVERIFIABLE, never as a weakening: nothing may be claimed about what a value the tool cannot read would widen.
49
49
 
@@ -61,7 +61,9 @@ Honesty notes: tier entries get **NO PreToolUse-hook residual coverage — with
61
61
 
62
62
  **Read-side invocation shape (why routine reads prompt, and the fix).** The same rule binds EVERY prefix allow entry, seeded read-only core included: a **compound chain** (`grep … && …`, `;`/`|`-joined reads), an **env-var prefix** (`FOO=bar grep …`), a **`$`/`${…}`/`$(…)` expansion**, or an **inline `node -e '…'`** never matches a prefix rule — so read *compounds* keep prompting at the settings layer even when each command is individually seeded. The mechanism that clears them is the opt-in **read-lane** in the PreToolUse hook (`${CLAUDE_SKILL_DIR}/references/modes/hook.md`): it auto-approves a compound whose every separator-split segment is a plain seeded read-only core command with **zero shell metaprogramming**. A scripted probe stays allow-listable by being a **WRITTEN scratch file run as `node <file>`** (a byte-exact declared gate, or a kit-tool tier entry) — **never** `node -e '…'`, which is unclassifiable and always prompts.
63
63
 
64
- **Exit codes:** `0` done / dry-run; `1` a precondition STOP (stamp not current, unsafe mode, malformed settings, symlinked `.claude` / non-regular target); `2` bad arguments.
64
+ **The `--worktrees` grant (own opt-in, its own mode).** Run `node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs --worktrees [--apply] [--cwd <dir>]`. It writes ONE `sandbox.filesystem.allowWrite` entry, the worktrees parent itself (`docs/ai/worktrees.json` `parentDir`, else the repository's own parent; home-relative under the home), into this copy's `.claude/settings.local.json` alone, and creates the parent when it is missing. It never writes `.claude/settings.json`, seeds no core and no ask rule, and cannot be combined with `--autonomy`, `--kit-tools`, `--bridge-tier` or `--accept-edits`. A parent that is the home or holds it is refused (`VELOCITY_WORKTREES_HOME`), nothing written. **Where the host honors the settings sandbox keys**, every sandboxed command of this copy, and of every worktree provisioned from it, can then write the whole parent dir, sibling repositories under it included, for every later session. Narrow it BEFORE the first apply: point `parentDir` at a dedicated dir — no apply removes an entry, so an earlier one stays until you delete it. The advisor's `worktrees-dir` item runs it through the danger check on the chat yes.
65
+
66
+ **Exit codes:** `0` done / dry-run; `1` a precondition STOP (stamp not current, unsafe mode, malformed settings, symlinked `.claude` / non-regular target, a worktrees parent that is the home or holds it); `2` bad arguments.
65
67
 
66
68
  ---
67
69
 
@@ -213,16 +213,17 @@ stated skip), and the ancestor chain is verified even when the leaf is ABSENT (a
213
213
  `docs/` or `docs/ai` never reads as plain absence). `--dir` overrides per invocation.
214
214
 
215
215
  **Host-specific consent (zero-prompt only where the host honors it):** every sibling-dir mutation
216
- runs a REAL create+delete writability probe first. An unwritable parent prints both the
217
- settings-native line (`sandbox.filesystem.allowWrite` in `.claude/settings.json`) and the full
218
- terminal command. On a settings-native host that honors the key, adding the parent can make later
219
- provision/cleanup promptless. On a harness-managed host that ignores project settings, grant the
220
- narrow parent through the host/session controls; if that is unavailable, use the printed terminal
221
- command for each operation. The `recommendations` mode surfaces this lane and converges it two ways:
216
+ runs a REAL create+delete writability probe first. An unwritable parent prints the `worktrees-dir`
217
+ check line and the full terminal command. The agent runs that check line and, on the chat yes on
218
+ its result, its apply line by the consent route: one `sandbox.filesystem.allowWrite` entry naming
219
+ the parent, written to this copy's `.claude/settings.local.json` only; where the host honors the
220
+ key, later provision/cleanup then run promptless. When the check refuses, prints nothing to
221
+ change, or grants a dir that does not hold the command's target, use the printed terminal command
222
+ for each operation. The `recommendations` mode surfaces this lane and converges it two ways:
222
223
  a declared `sandbox.filesystem.allowWrite` entry covering the probed parent (either settings
223
- scope), or — on a host that ignores that key — the neutral dir-bound acknowledgement its
224
- consent-gated apply one-liner records into `docs/ai/acks.json` (`worktreesDirAck`; the dry-run
225
- preview prints the exact `--apply`). Neither is proof of write
224
+ scope), or — on a host that ignores that key — the neutral dir-bound acknowledgement
225
+ `worktreesDirAck` in `docs/ai/acks.json`, recorded by the `ack-write` line the item's `recipe:`
226
+ line carries, the fallback instead of the grant. Neither is proof of write
226
227
  capability: the create+delete probe above stays the runtime truth, and the ack binds to the
227
228
  resolved probe dir, so the item re-fires only when that resolved dir changes.
228
229
 
@@ -2,14 +2,14 @@
2
2
 
3
3
  **Hand-off contract (explicit; tested independent of agent interpretation).**
4
4
  - **Delegated** (memory valid): the kit passes the **target project dir** + the **three setup
5
- answers** (visibility / language / attribution) to `agent-workflow-memory`, which writes
5
+ answers** (visibility / language / attribution — memory is told to skip its step-4 settings write; the `attribution` item of step 11 sets it) to `agent-workflow-memory`, which writes
6
6
  `docs/ai/` + `AGENTS.md` (carrying the **three empty pointer pairs** — `workflow:methodology`,
7
7
  `workflow:orchestration` and `workflow:autonomy`) + **`.memory-version`**. The kit then **reconciles
8
8
  the bounded pointers** (below) and writes the kit-fallback **`.workflow-version`**. → **both stamps** present. In **hidden** mode the kit is the **single
9
9
  hide authority**: after the hand-off it runs `tools/hide-footprint.mjs` (bootstrap step 9 — `${CLAUDE_SKILL_DIR}/references/modes/bootstrap.md`), which **absorbs
10
10
  memory's project-local footprint lines** into the one canonical `.git/info/exclude` block and adds
11
- the external footprint — so there is **no machine-global write** at any step (a stale memory's
12
- residual global block is cleaned via the upgrade reconcile, below).
11
+ the external footprint — so the hide makes **no machine-global write** (a stale memory's
12
+ residual global block is the advisor's footprint-global item).
13
13
  - **Fallback** (memory absent/invalid): the kit runs the bootstrap procedure (`${CLAUDE_SKILL_DIR}/references/modes/bootstrap.md`) from its own
14
14
  bundled assets — whose entry-point template now ships **three empty pointer pairs** (`workflow:methodology`
15
15
  + `workflow:orchestration` + `workflow:autonomy`) the kit reconciles + fills — and writes **`.workflow-version`** only.
@@ -3,9 +3,9 @@
3
3
  The non-obvious traps — scan these before bootstrapping or upgrading. Each is also enforced inline in the bootstrap/upgrade procedures (`${CLAUDE_SKILL_DIR}/references/modes/bootstrap.md`, `${CLAUDE_SKILL_DIR}/references/modes/upgrade.md`); this is the consolidated high-signal list.
4
4
 
5
5
  - **Source vs target directory.** Templates and scripts are read from the skill's own dir (`${CLAUDE_SKILL_DIR}/` in Claude Code, the `SKILL.md` folder elsewhere). The **working directory is the target project** — never write kernel files back into the skill.
6
- - **The `Co-Authored-By` trailer is added by the harness, not by prose.** When attribution is `off`, a doc directive alone won't stop it — for Claude Code you **must** also set `"includeCoAuthoredBy": false` in the project's `.claude/settings.json` (create it if absent). Other tools: disable their equivalent co-author/footer setting.
6
+ - **The `Co-Authored-By` trailer is added by the harness, not by prose.** When attribution is `off`, a doc directive alone won't stop it — for Claude Code the advisor's `attribution` item sets the harness setting in the project's `.claude/settings.json`, on the user's yes after its check. Other tools: disable their equivalent co-author/footer setting.
7
7
  - **Hidden mode must never touch `package.json`.** Editing it is a *tracked* change and leaks the whole system. Hidden mode wires nothing into `package.json`; the pre-commit hook (untracked in `.git/hooks/`) calls `node scripts/<x>.mjs` directly. After hiding, **verify `git status` shows the artifacts as ignored**.
8
- - **Hidden mode is project-local, and the hide tool owns the known footprint.** `tools/hide-footprint.mjs` writes **one managed block** in the **project-local `.git/info/exclude`** — never the machine-global `core.excludesFile` (which would silently affect every repo on the host; **AD-014** amends **AD-006**). It hides the kit's own artifacts **and** the known external AI/agent footprint (the `KNOWN_FOOTPRINT` table in [contracts](${CLAUDE_SKILL_DIR}/references/contracts.md#visibility-contract)). A **tracked** file is **never silently un-tracked** — the tool prints the `git rm --cached` it will not run. Never leak its internal marker / asks terms to the user; translate every outcome to plain language.
8
+ - **Hidden mode is project-local, and the hide tool owns the known footprint.** `tools/hide-footprint.mjs` writes **one managed block** in the **project-local `.git/info/exclude`** — never the machine-global `core.excludesFile` (which would silently affect every repo on the host; **AD-014** amends **AD-006**; only its `--remove-global` mode, the `footprint-global` item, edits that file). It hides the kit's own artifacts **and** the known external AI/agent footprint (the `KNOWN_FOOTPRINT` table in [contracts](${CLAUDE_SKILL_DIR}/references/contracts.md#visibility-contract)). A **tracked** file is **never silently un-tracked** — the tool prints the `git rm --cached` it will not run. Never leak its internal marker / asks terms to the user; translate every outcome to plain language.
9
9
  - **`CLAUDE.md` is a symlink, not a copy.** `ln -s AGENTS.md CLAUDE.md` — single source, no duplication. A copy drifts; a symlink can't.
10
10
  - **Never overwrite an existing entry point or hook.** If `AGENTS.md` / `CLAUDE.md` already exist, **stop and ask** the user to merge vs replace — don't clobber. A non-marker hook stays; moving it aside is the user's step.
11
11
  - **Unrecognized invocations are read-only.** Only a **known** subcommand reaches its mode; the **bare** invocation bootstraps (and an existing `docs/ai/` makes it ask upgrade-vs-bootstrap first, never overwrite); **any other / ambiguous** token routes to `help` (read-only). A garbage invocation never writes. The mapping is pinned by `tools/commands.mjs` `routeInvocation` (unit-tested) — don't hand-route around it.
@@ -1,5 +1,5 @@
1
1
  {
2
- "_README": "The executor vehicle's model, effort and fallback as a project setting, read by the kit's vehicle-settings readers into one posture, which the placed executor's body follows: the agents writer and the upgrade run derive it from model and effort, and fallback names the model of the one retry; seeded once and never clobbered, hand-edited as three bare tokens with fallback differing from model.",
2
+ "_README": "The executor vehicle's model, effort and fallback as a project setting, read by the kit's vehicle-settings readers into one posture, which the placed executor's body follows: the agents writer derives it from model and effort, the upgrade reports a differing body as pending, and fallback names the model of the one retry; seeded once and never clobbered, hand-edited as three bare tokens with fallback differing from model.",
3
3
  "executor": {
4
4
  "model": "opus",
5
5
  "effort": "high",
@@ -12,7 +12,8 @@ import {
12
12
  writeAutonomyProfile, writeVelocityProfile,
13
13
  } from './velocity-profile.mjs';
14
14
  import { KIT_GROUNDING_TOOL, bundledSandboxRecipe, usedBridges } from './bridge-sandbox-recipe.mjs';
15
- import { mergeAllowWrite, stateDirsOf } from './bridge-state-dirs.mjs';
15
+ import { homeRelative, mergeAllowWrite, stateDirsOf } from './bridge-state-dirs.mjs';
16
+ import { nearestExistingDir, worktreesGrantDir, worktreesGrantHoldsHome } from './worktrees.mjs';
16
17
  import { loadConfig } from './orchestration-config.mjs';
17
18
  import { HOOK_FILE_REL, HOOKS_DIR, buildHookSettingsEntry, mergeHookEntry, writeGateHook } from './gate-hook.mjs';
18
19
  import { STATE_BLOCK_HOOK_FILE_REL, buildStopHookEntry, mergeStopEntry, writeStateBlockHook } from './state-block-hook.mjs';
@@ -24,6 +25,8 @@ import { SKILL_FILES, claudeDirOf, skillTargets } from './jev-facts.mjs';
24
25
  import { findOnPath } from './detect-backends.mjs';
25
26
  import { consoleArgv } from './write-lanes.mjs';
26
27
  import { BUNDLED_SCRIPT_NAMES, INSTALLER_REL, SCRIPTS_REL, formatPlan, planGitHooks } from './git-hooks.mjs';
28
+ import { ATTRIBUTION_HIDDEN, ATTRIBUTION_KEY, writeAttribution } from './attribution.mjs';
29
+ import { globalRemovalOf, planGlobalRemoval } from './hide-footprint.mjs';
27
30
  import { isDirectRun } from './direct-run.mjs';
28
31
  import { dirCovers as covers } from './declared-paths.mjs';
29
32
 
@@ -34,10 +37,13 @@ const RESEED_VARIANTS = ['gate-hook.marker-stale', 'read-lane.stale'];
34
37
  const HOOK_VARIANTS = ['gate-hook', 'read-lane.missing', ...RESEED_VARIANTS];
35
38
  const STOP = 'state-block';
36
39
  const MCP_VARIANTS = ['mcp-channel', 'mcp-channel.differing'];
37
- const AGENT_VARIANTS = ['agents', 'executor-vehicle'];
40
+ const AGENT_VARIANTS = ['agents', 'executor-vehicle', 'executor-vehicle.differing'];
38
41
  const GIT_VARIANTS = ['enforcement', 'commit-guard'];
42
+ const WORKTREES = 'worktrees-dir';
43
+ const ATTRIBUTION = 'attribution';
44
+ const FOOTPRINT = 'footprint-global';
39
45
  const VARIANTS = [...VELOCITY_VARIANTS, 'autonomy-render', ...HOOK_VARIANTS, STOP, ...MCP_VARIANTS, ...AGENT_VARIANTS,
40
- ...SKILL_VARIANTS, ...GIT_VARIANTS];
46
+ ...SKILL_VARIANTS, ...GIT_VARIANTS, WORKTREES, ATTRIBUTION, FOOTPRINT];
41
47
  const HOOK_KEYS = ['hooks.PreToolUse', 'hooks.Stop'];
42
48
  const CREDENTIALS = 'sandbox.credentials.envVars';
43
49
  const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -67,10 +73,12 @@ export const KEY_TABLE = Object.freeze({
67
73
  [ENABLED_KEY]: 4,
68
74
  [SERVERS_KEY]: 4,
69
75
  [CREDENTIALS]: 5,
76
+ [ATTRIBUTION_KEY]: 5,
70
77
  });
71
- const SCALARS = ['permissions.defaultMode', 'sandbox.enabled', 'sandbox.autoAllowBashIfSandboxed'];
78
+ const SCALARS = ['permissions.defaultMode', 'sandbox.enabled', 'sandbox.autoAllowBashIfSandboxed', ATTRIBUTION_KEY];
72
79
  const valid = (key, value, element = false) => {
73
80
  if (key.startsWith(`${SERVERS_KEY}.`) || key === SERVERS_KEY) return object(value);
81
+ if (key === ATTRIBUTION_KEY) return object(value) || typeof value === 'boolean';
74
82
  if (key === 'permissions.defaultMode') return typeof value === 'string';
75
83
  if (SCALARS.includes(key)) return typeof value === 'boolean';
76
84
  if (!element) return Array.isArray(value) && value.every((entry) => valid(key, entry, true));
@@ -148,6 +156,7 @@ export const judgeChange = ({ variant, files, scope }) => {
148
156
  }
149
157
  return { entries, refused };
150
158
  };
159
+ const footprintPlan = (facts, deps) => (deps.planGlobalRemoval ?? planGlobalRemoval)({ env: facts.env, home: facts.home }, facts.root, { home: facts.home });
151
160
  const writerFacts = (facts, deps) => ({
152
161
  ...deps, ...facts,
153
162
  detect: deps.detect ?? facts.detect,
@@ -181,7 +190,12 @@ export const scopeOf = (variant, facts, deps = {}) => {
181
190
  set('permissions.ask', RENDER_OWNED_REDLINE_RULES);
182
191
  set('permissions.deny', RENDER_OWNED_REDLINE_RULES);
183
192
  allowed[CREDENTIALS] = (value) => value.mode === 'deny';
184
- } else if (HOOK_VARIANTS.includes(variant)) set('hooks.PreToolUse', [buildHookSettingsEntry()]);
193
+ } else if (variant === ATTRIBUTION) set(ATTRIBUTION_KEY, [ATTRIBUTION_HIDDEN]);
194
+ const grantDir = variant === WORKTREES ? worktreesGrantDir(facts.root, facts) : null;
195
+ if (grantDir !== null && !worktreesGrantHoldsHome(grantDir, facts.home, facts)) {
196
+ set('sandbox.filesystem.allowWrite', [homeRelative(grantDir, facts.home)]);
197
+ }
198
+ if (HOOK_VARIANTS.includes(variant)) set('hooks.PreToolUse', [buildHookSettingsEntry()]);
185
199
  else if (variant === STOP) set('hooks.Stop', [buildStopHookEntry()]);
186
200
  else if (MCP_VARIANTS.includes(variant)) {
187
201
  set(`${SERVERS_KEY}.${SERVER_NAME}`, [buildServerEntry(join(facts.toolsDir, 'mcp-server.mjs'))]);
@@ -192,9 +206,13 @@ export const scopeOf = (variant, facts, deps = {}) => {
192
206
  claudeDir: facts.claudeDir ?? claudeDirOf(facts.env, facts.home, facts.platform).dir }) : [];
193
207
  const filePaths = HOOK_VARIANTS.includes(variant) ? [join(facts.root, HOOK_FILE_REL)]
194
208
  : variant === STOP ? [join(facts.root, STATE_BLOCK_HOOK_FILE_REL)] : [];
195
- const directories = SKILL_VARIANTS.includes(variant) ? [] : [join(facts.root, CLAUDE_DIR)];
209
+ const directories = SKILL_VARIANTS.includes(variant) || variant === FOOTPRINT ? [] : [join(facts.root, CLAUDE_DIR)];
210
+ const globalFile = variant === FOOTPRINT ? footprintPlan(facts, deps)?.source : undefined;
211
+ if (globalFile) filePaths.push(globalFile);
196
212
  if (HOOK_VARIANTS.includes(variant) || variant === STOP) directories.push(join(facts.root, HOOKS_DIR));
197
213
  if (AGENT_VARIANTS.includes(variant)) directories.push(join(facts.root, AGENTS_DIR));
214
+ const existingAncestor = grantDir === null ? null : nearestExistingDir(grantDir, facts);
215
+ for (let at = grantDir; at !== existingAncestor; at = dirname(at)) directories.push(at);
198
216
  for (const target of targets) {
199
217
  for (let at = target.root; at !== target.base && covers(target.base, at); at = dirname(at)) directories.push(at);
200
218
  filePaths.push(target.path);
@@ -209,10 +227,11 @@ export const scopeOf = (variant, facts, deps = {}) => {
209
227
  return {
210
228
  admitsRun: (argv) => runs !== null && equal(argv, runs),
211
229
  admits: ({ path, key, change, value }) => {
212
- const correctFile = key.startsWith(`${SERVERS_KEY}.`) ? path === MCP_JSON_REL : path === SETTINGS_FILE;
213
- const gains = variant === 'bridge-tier' || HOOK_KEYS.includes(key) || key === ENABLED_KEY || key === CREDENTIALS
230
+ const correctFile = key.startsWith(`${SERVERS_KEY}.`) ? path === MCP_JSON_REL
231
+ : path === (variant === WORKTREES ? SETTINGS_LOCAL_FILE : SETTINGS_FILE);
232
+ const gains = variant === 'bridge-tier' || variant === WORKTREES || HOOK_KEYS.includes(key) || key === ENABLED_KEY || key === CREDENTIALS
214
233
  || (VELOCITY_VARIANTS.includes(variant) && key === 'permissions.ask');
215
- return correctFile && (!gains || change === 'added') && Boolean(allowed[key]?.(value));
234
+ return correctFile && (!gains || change === 'added') && !(key === ATTRIBUTION_KEY && change === 'removed') && Boolean(allowed[key]?.(value));
216
235
  },
217
236
  admitsPath: (path, directory = false) => directory ? directories.includes(path)
218
237
  : filePaths.includes(path) || (AGENT_VARIANTS.includes(variant) && covers(join(facts.root, AGENTS_DIR), path)),
@@ -220,10 +239,11 @@ export const scopeOf = (variant, facts, deps = {}) => {
220
239
  };
221
240
 
222
241
  const jsonAt = (path) => existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
242
+ const regularBytes = (path) => lstatSync(path, { throwIfNoEntry: false })?.isFile() ? readFileSync(path) : undefined;
223
243
  const halted = (lines) => Object.assign(new Error(lines.join('\n')), { code: 'CHECK_HALTED', lines });
224
244
  const maskedFiles = (variant, facts) => {
225
245
  const paths = VELOCITY_VARIANTS.includes(variant) || variant === 'autonomy-render' || HOOK_VARIANTS.includes(variant)
226
- || variant === STOP ? [SETTINGS_FILE, SETTINGS_LOCAL_FILE] : MCP_VARIANTS.includes(variant) ? [MCP_JSON_REL, SETTINGS_FILE] : [];
246
+ || variant === STOP || variant === WORKTREES || variant === ATTRIBUTION ? [SETTINGS_FILE, SETTINGS_LOCAL_FILE] : MCP_VARIANTS.includes(variant) ? [MCP_JSON_REL, SETTINGS_FILE] : [];
227
247
  return paths.flatMap((path) => lstatSync(join(facts.root, path), { throwIfNoEntry: false })?.isCharacterDevice()
228
248
  ? [`masked: ${path}`] : []);
229
249
  };
@@ -231,6 +251,7 @@ export const plannedChange = (variant, facts, deps = {}) => {
231
251
  const files = [];
232
252
  const writes = [];
233
253
  const temporaries = [];
254
+ const created = [];
234
255
  let runs = null;
235
256
  const writerDeps = writerFacts(facts, deps);
236
257
  const addFile = (path, before, after) => {
@@ -264,13 +285,12 @@ export const plannedChange = (variant, facts, deps = {}) => {
264
285
  if (plan.wirePlanned) addFile(SETTINGS_FILE, plan.projectSettings.data, mergeHookEntry(plan.projectSettings.data));
265
286
  const path = join(facts.root, HOOK_FILE_REL);
266
287
  if (plan.placePlanned) writes.push({ path, bytes: plan.bundleContent });
267
- if (plan.reseedPlanned) writes.push({ path, bytes: plan.bundleContent, replaces: readFileSync(path) });
288
+ if (plan.reseedPlanned) writes.push({ path, bytes: plan.bundleContent });
268
289
  } else if (variant === STOP) {
269
290
  const plan = writeStateBlockHook({ cwd: facts.root, dryRun: true }, writerDeps);
270
291
  if (plan.mergePlanned) addFile(SETTINGS_FILE, plan.projectSettings.data, mergeStopEntry(plan.projectSettings.data));
271
292
  const path = join(facts.root, STATE_BLOCK_HOOK_FILE_REL);
272
- if (plan.placement === 'create') writes.push({ path, bytes: plan.bundleContent });
273
- if (plan.placement === 'replace') writes.push({ path, bytes: plan.bundleContent, replaces: readFileSync(path) });
293
+ if (['create', 'replace'].includes(plan.placement)) writes.push({ path, bytes: plan.bundleContent });
274
294
  } else if (MCP_VARIANTS.includes(variant)) {
275
295
  const result = writeMcp({ cwd: facts.root, dryRun: true, replace: variant === 'mcp-channel.differing' }, writerDeps);
276
296
  const { plan, registration } = result;
@@ -295,6 +315,22 @@ export const plannedChange = (variant, facts, deps = {}) => {
295
315
  const plan = (deps.planGitHooks ?? planGitHooks)({ root: facts.root, variant, toolsDir: facts.toolsDir });
296
316
  for (const copy of plan.copies.filter(({ action }) => action === 'created')) writes.push({ path: copy.path, bytes: copy.bytes });
297
317
  runs = { argv: plan.runs.shown, line: formatPlan(plan).at(-1) };
318
+ } else if (variant === WORKTREES) {
319
+ const plan = writeVelocityProfile({ cwd: facts.root, dryRun: true, worktrees: true }, writerDeps);
320
+ const before = plan.localSettings.data ?? null;
321
+ addFile(SETTINGS_LOCAL_FILE, before, mergeAllowWrite(before ?? {}, plan.worktreesDirToAdd));
322
+ created.push(...plan.worktreesDirsToCreate);
323
+ } else if (variant === ATTRIBUTION) {
324
+ const plan = writeAttribution({ cwd: facts.root, dryRun: true }, writerDeps);
325
+ if (plan.planned) addFile(SETTINGS_FILE, plan.before, plan.after);
326
+ } else if (variant === FOOTPRINT) {
327
+ const plan = footprintPlan(facts, deps);
328
+ if (plan !== null) {
329
+ const bytes = readFileSync(plan.source, 'latin1');
330
+ const core = globalRemovalOf(bytes);
331
+ const matches = plan.before === bytes && core !== null && equal(plan.removedLines, core.removedLines) && plan.after === core.after;
332
+ writes.push({ path: plan.source, bytes: Buffer.from(plan.after, 'latin1'), replaces: Buffer.from(plan.before, 'latin1'), removes: plan.removedLines, matches });
333
+ }
298
334
  } else throw new Error(`unsupported variant: ${variant}`);
299
335
  } catch (error) {
300
336
  throw error.code === 'CHECK_HALTED' ? error : halted([error.message]);
@@ -304,6 +340,7 @@ export const plannedChange = (variant, facts, deps = {}) => {
304
340
  for (const path of [...changedFiles.map(({ path }) => join(facts.root, path)), ...writes.map(({ path }) => path)]) {
305
341
  for (let parent = dirname(path); !existsSync(parent); parent = dirname(parent)) directories.add(parent);
306
342
  }
343
+ for (const path of created) directories.add(path);
307
344
  return { files, writes, temporaries, directories: [...directories], runs };
308
345
  };
309
346
 
@@ -328,8 +365,13 @@ export const checkApply = (variant, facts, deps = {}) => {
328
365
  reason: `beyond the declared scope of ${variant}` });
329
366
  };
330
367
  for (const write of plan.writes) {
331
- const replaces = write.replaces === undefined ? '' : ` — replaces sha256:${sha(write.replaces)}`;
368
+ const old = write.replaces ?? (write.directory ? undefined : regularBytes(write.path));
369
+ const replaces = old === undefined ? '' : ` — replaces sha256:${sha(old)}`;
332
370
  pathLine(write.path, false, `writes: ${display(write.path)} sha256:${sha(write.bytes)}${replaces}`);
371
+ for (const line of write.removes ?? []) lines.push(`removes: ${line}`);
372
+ if (write.matches === false) {
373
+ refused.push({ path: display(write.path), key: display(write.path), reason: 'the plan is not the one the file\'s own bytes give' });
374
+ }
333
375
  }
334
376
  for (const path of plan.directories) pathLine(path, true, `writes: ${display(path)}/ created`);
335
377
  lines.push(...plan.temporaries);
@@ -388,7 +430,7 @@ export const main = (argv, io = {}) => {
388
430
  platform: resolved.platform, claudeDir: args['claude-dir'] };
389
431
  let result;
390
432
  try {
391
- result = checkApply(args.variant, facts, { detect: resolved.detect, planGitHooks: resolved.planGitHooks });
433
+ result = checkApply(args.variant, facts, { detect: resolved.detect, planGitHooks: resolved.planGitHooks, planGlobalRemoval: resolved.planGlobalRemoval });
392
434
  } catch (error) {
393
435
  for (const line of error.lines ?? [error.message]) resolved.log(line);
394
436
  return 3;
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ // attribution.mjs — the writer of the advisor's `attribution` item: when the project's AGENTS.md block
3
+ // reads off, it sets Claude Code's `attribution` setting in the project .claude/settings.json so the
4
+ // harness adds no Co-Authored-By trailer, PR text or session link. Preview by default; --apply writes.
5
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
6
+ import { join, resolve } from 'node:path';
7
+ import { CLAUDE_DIR, EXPECTED_WORKFLOW_VERSION, SETTINGS_FILE, preflightVelocityProfile } from './velocity-profile.mjs';
8
+ import { formatJson } from './gate-hook.mjs';
9
+ import { isDirectRun } from './direct-run.mjs';
10
+
11
+ export const ATTRIBUTION_KEY = 'attribution';
12
+ export const LEGACY_ATTRIBUTION_KEY = 'includeCoAuthoredBy';
13
+ // The one form the writer writes: `false` would make a CLI older than v2.1.281 skip the whole file.
14
+ export const ATTRIBUTION_HIDDEN = Object.freeze({ commit: '', pr: '', sessionUrl: false });
15
+ export const ATTRIBUTION_MALFORMED = 'ATTRIBUTION_MALFORMED';
16
+ export const ATTRIBUTION_STAMP = 'ATTRIBUTION_STAMP';
17
+ export const ATTRIBUTION_SHARED_NOTICE = `notice: ${SETTINGS_FILE} is the project-scope settings file — in a visible deployment it is committed and shared with everyone on this project, as the AGENTS.md block is`;
18
+
19
+ const EXIT_OK = 0;
20
+ const EXIT_REFUSED = 1;
21
+ const EXIT_USAGE = 2;
22
+ const USAGE = 'usage: attribution [--cwd <root>] [--apply]';
23
+ const CHOICE_LINE = /^> \*\*Agent attribution: ([^*\s]+)\*\*/mu;
24
+ const CHOICES = ['off', 'on'];
25
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
26
+ const hasOwn = (object, key) => isObject(object) && Object.hasOwn(object, key);
27
+ const refusal = (code, message) => Object.assign(new Error(`${code}: ${message}`), { code, exitCode: EXIT_REFUSED });
28
+ const usageError = (message) => Object.assign(new Error(message), { exitCode: EXIT_USAGE });
29
+
30
+ // off, on, or null for an absent file, an absent line or another value; any other read failure throws.
31
+ export const readAttributionChoice = (root, deps = {}) => {
32
+ let text;
33
+ try {
34
+ text = String((deps.readFile ?? readFileSync)(join(root, 'AGENTS.md'), 'utf8'));
35
+ } catch (error) {
36
+ if (error?.code === 'ENOENT') return null;
37
+ throw error;
38
+ }
39
+ const value = text.match(CHOICE_LINE)?.[1];
40
+ return CHOICES.includes(value) ? value : null;
41
+ };
42
+
43
+ // Per key, the local file's value while it carries the key, else the project's; an object is taken whole.
44
+ export const effectiveAttribution = (project, local) => Object.fromEntries([ATTRIBUTION_KEY, LEGACY_ATTRIBUTION_KEY]
45
+ .map((key) => [key, hasOwn(local, key) ? local[key] : project?.[key]])
46
+ .filter(([, value]) => value !== undefined));
47
+
48
+ export const attributionHidesText = (effective) => {
49
+ const value = effective?.[ATTRIBUTION_KEY];
50
+ if (value === false) return true;
51
+ if (isObject(value) && value.commit === '' && value.pr === '') return true;
52
+ const setsText = hasOwn(value, 'commit') || hasOwn(value, 'pr');
53
+ return !setsText && effective?.[LEGACY_ATTRIBUTION_KEY] === false;
54
+ };
55
+
56
+ // The wrong-typed key of one settings file, or null: attribution is an object or a boolean, the legacy key a boolean.
57
+ export const wrongTypedKey = (data) => {
58
+ if (hasOwn(data, ATTRIBUTION_KEY) && !isObject(data[ATTRIBUTION_KEY]) && typeof data[ATTRIBUTION_KEY] !== 'boolean') {
59
+ return `${ATTRIBUTION_KEY} must be an object or a boolean`;
60
+ }
61
+ if (hasOwn(data, LEGACY_ATTRIBUTION_KEY) && typeof data[LEGACY_ATTRIBUTION_KEY] !== 'boolean') return `${LEGACY_ATTRIBUTION_KEY} must be a boolean`;
62
+ return null;
63
+ };
64
+
65
+ export const writeAttribution = ({ cwd, dryRun = true } = {}, deps = {}) => {
66
+ const root = resolve(cwd ?? deps.cwd ?? process.cwd());
67
+ const preflight = preflightVelocityProfile({ cwd: root }, deps);
68
+ const before = preflight.projectSettings.data ?? null;
69
+ const malformed = wrongTypedKey(before);
70
+ if (malformed) throw refusal(ATTRIBUTION_MALFORMED, `${SETTINGS_FILE}: ${malformed} — nothing written`);
71
+ const planned = !attributionHidesText(effectiveAttribution(before, preflight.localSettings.data));
72
+ const after = planned ? { ...before, [ATTRIBUTION_KEY]: { ...ATTRIBUTION_HIDDEN } } : before;
73
+ const result = { root, planned, wrote: false, before, after, claudeDirAbsent: preflight.claudeDirAbsent };
74
+ if (dryRun || !planned) return result;
75
+ if (!preflight.stampOk) {
76
+ throw refusal(ATTRIBUTION_STAMP, `not a deployed agent-workflow project at lineage ${EXPECTED_WORKFLOW_VERSION} (found ${preflight.stamp ?? 'none'}) — run /agent-workflow-kit upgrade first`);
77
+ }
78
+ if (preflight.claudeDirAbsent) (deps.mkdir ?? mkdirSync)(join(root, CLAUDE_DIR), { recursive: true });
79
+ (deps.writeFile ?? writeFileSync)(join(root, SETTINGS_FILE), formatJson(after, preflight.projectSettings.eol ?? '\n'), 'utf8');
80
+ return { ...result, wrote: true };
81
+ };
82
+
83
+ export const formatResult = (result, dryRun) => {
84
+ if (!result.planned) return ['nothing to change: the settings already hide the trailer and the PR text'];
85
+ const object = JSON.stringify(ATTRIBUTION_HIDDEN);
86
+ return dryRun ? [`would set ${ATTRIBUTION_KEY} (${SETTINGS_FILE}): ${object}`, ATTRIBUTION_SHARED_NOTICE]
87
+ : [`set ${ATTRIBUTION_KEY} (${SETTINGS_FILE}): ${object}`];
88
+ };
89
+
90
+ export const parseArgs = (argv) => {
91
+ if (argv.length === 1 && ['--help', '-h'].includes(argv[0])) return { help: true };
92
+ const args = { help: false, apply: false, cwd: undefined };
93
+ for (let index = 0; index < argv.length; index += 1) {
94
+ const flag = argv[index];
95
+ if (flag === '--apply') args.apply = true;
96
+ else if (flag === '--cwd') {
97
+ const value = argv[index + 1];
98
+ if (value === undefined || value.startsWith('-')) throw usageError('--cwd takes a value');
99
+ args.cwd = value;
100
+ index += 1;
101
+ } else throw usageError(`unknown argument: ${flag}`);
102
+ }
103
+ return args;
104
+ };
105
+
106
+ export const main = (argv = process.argv.slice(2), deps = {}) => {
107
+ const log = deps.log ?? console.log;
108
+ const errlog = deps.errlog ?? console.error;
109
+ let args;
110
+ try {
111
+ args = parseArgs(argv);
112
+ if (args.help) {
113
+ log(USAGE);
114
+ return EXIT_OK;
115
+ }
116
+ args.root = resolve(deps.cwd ?? process.cwd(), args.cwd ?? '.');
117
+ if (!statSync(args.root, { throwIfNoEntry: false })?.isDirectory()) throw usageError(`--cwd is not a directory: ${args.root}`);
118
+ } catch (error) {
119
+ errlog(error.message);
120
+ errlog(USAGE);
121
+ return EXIT_USAGE;
122
+ }
123
+ try {
124
+ const result = writeAttribution({ cwd: args.root, dryRun: !args.apply }, deps);
125
+ for (const line of formatResult(result, !args.apply)) log(line);
126
+ return EXIT_OK;
127
+ } catch (error) {
128
+ errlog(error?.message ?? String(error));
129
+ return EXIT_REFUSED;
130
+ }
131
+ };
132
+
133
+ if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(2));
@@ -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
@@ -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