@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.
- package/CHANGELOG.md +81 -0
- package/README.md +3 -3
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/migrations/1.2.0-agent-attribution.md +10 -8
- package/migrations/3.0.0-hardened-core-loop.md +9 -13
- package/package.json +1 -1
- package/references/contracts.md +2 -2
- package/references/modes/bootstrap.md +5 -3
- package/references/modes/recommendations.md +7 -5
- package/references/modes/upgrade.md +5 -5
- package/references/modes/velocity.md +7 -5
- package/references/modes/worktrees.md +10 -9
- package/references/shared/composition-handoff.md +3 -3
- package/references/shared/deploy-tail.md +2 -2
- package/references/templates/vehicles.json +1 -1
- package/tools/apply-danger-check.mjs +57 -15
- package/tools/attribution.mjs +133 -0
- package/tools/cheap-agents-read.mjs +12 -0
- package/tools/commit-guard.mjs +3 -16
- package/tools/ensure-configs.mjs +6 -6
- package/tools/ensure-executor.mjs +13 -30
- package/tools/ensure-vocabulary.mjs +4 -4
- package/tools/git-hooks.mjs +41 -3
- package/tools/hide-footprint.mjs +87 -61
- package/tools/init-project.mjs +31 -6
- package/tools/recommendations.mjs +112 -65
- package/tools/upgrade-runlist.mjs +1 -1
- package/tools/velocity-profile.mjs +118 -10
- package/tools/worktrees.mjs +28 -10
- package/tools/write-lanes.mjs +23 -2
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
**
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
12
|
-
residual global block is
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
213
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
package/tools/commit-guard.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
64
|
-
|
|
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.
|
package/tools/ensure-configs.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
|