@sabaiway/agent-workflow-kit 1.47.0 → 1.49.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 +69 -0
- package/README.md +2 -2
- package/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +21 -3
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +3 -2
- package/bridges/antigravity-cli-bridge/bin/agy.sh +20 -2
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +3 -2
- package/bridges/antigravity-cli-bridge/capability.json +1 -1
- package/bridges/codex-cli-bridge/SKILL.md +1 -1
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +20 -2
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +3 -2
- package/bridges/codex-cli-bridge/bin/codex-review.sh +21 -3
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +3 -2
- package/bridges/codex-cli-bridge/capability.json +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/agents/changelog-skeleton.md +1 -0
- package/references/agents/gate-triage.md +1 -0
- package/references/agents/mechanical-sweep.md +1 -0
- package/references/hooks/gate-approve.mjs +120 -18
- package/references/modes/hook.md +6 -1
- package/references/modes/recommendations.md +8 -6
- package/references/modes/setup.md +7 -2
- package/references/modes/upgrade.md +9 -9
- package/references/modes/velocity.md +4 -2
- package/tools/ack-write.mjs +186 -0
- package/tools/doc-parity.mjs +14 -0
- package/tools/fs-safe.mjs +28 -5
- package/tools/gate-hook.mjs +193 -4
- package/tools/recommendations.mjs +171 -17
- package/tools/setup-backends.mjs +48 -3
- package/tools/velocity-profile.mjs +13 -2
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
// RUNTIME_RESIDUAL_FORMS — drift-guarded by the kit's test/gate-hook-core-parity.test.mjs.
|
|
12
12
|
//
|
|
13
13
|
// It reads <project root>/docs/ai/gates.json LIVE on every invocation (AD-035: one declaration,
|
|
14
|
-
// two consumers — editing gates.json never requires re-wiring)
|
|
15
|
-
//
|
|
16
|
-
//
|
|
14
|
+
// two consumers — editing gates.json never requires re-wiring) and, for the opt-in read-lane
|
|
15
|
+
// (rung c), <project root>/docs/ai/lanes.json LIVE as well (AD-055 Part II — a SEPARATE kit-owned
|
|
16
|
+
// file; gates.json, both its validators and the byte-mirrored template stay untouched). The project
|
|
17
|
+
// root resolves from $CLAUDE_PROJECT_DIR (the stdin `cwd` may be a subdirectory), falling back to
|
|
18
|
+
// the stdin `cwd` only when the env is absent.
|
|
17
19
|
//
|
|
18
20
|
// Decision ladder for every Bash PreToolUse call — first match wins:
|
|
19
21
|
// (a) declared-gate EXACT match → allow. Byte-identical (leading/trailing trim only — no
|
|
@@ -31,13 +33,20 @@
|
|
|
31
33
|
// and conservative (no shell parsing in a dependency-free hook): a quoted metacharacter
|
|
32
34
|
// may over-ASK, never under-allow. Covers the kit-SEEDED core only, never arbitrary
|
|
33
35
|
// user-added rules.
|
|
34
|
-
// (c)
|
|
36
|
+
// (c) read-lane allow (OPT-IN) → allow. Only when docs/ai/lanes.json enables it
|
|
37
|
+
// (`{ "readLane": true }`, read LIVE per call, fail-closed): a command every separator-split
|
|
38
|
+
// segment of which is a plain frozen read-only core prefix, with ZERO shell metaprogramming
|
|
39
|
+
// anywhere — a conservative CLOSED-WORLD allow (any doubt → fall through, never a widening).
|
|
40
|
+
// Mode-fenced like (a); cwd-agnostic (a read is a read from any directory). Runs AFTER (b),
|
|
41
|
+
// so a residual-carrying core command still ASKs (most-restrictive-wins by ladder order).
|
|
42
|
+
// (d) everything else → NO decision: exit 0, no output — the normal permission flow proceeds
|
|
35
43
|
// unchanged. The hook NEVER emits `deny`.
|
|
36
44
|
//
|
|
37
45
|
// Fail-safe invariant, decoupled per function: a DECLARATION anomaly (missing / unreadable /
|
|
38
46
|
// malformed / schema-invalid gates.json) disables ONLY exact-gate approval (a) — the residual
|
|
39
47
|
// guard (b) needs no declaration and keeps running (a broken gates.json must not silently
|
|
40
|
-
// reopen the seeded-allowlist hole).
|
|
48
|
+
// reopen the seeded-allowlist hole). A lanes.json anomaly (absent / malformed / non-boolean) merely
|
|
49
|
+
// leaves rung (c) DARK (fail-closed) — rungs (a)/(b) are unaffected. Only an INPUT anomaly (unparseable stdin, non-Bash
|
|
41
50
|
// tool_name) disables the whole hook. EVERY anomaly path exits 0 — never exit 2 (exit 2 is an
|
|
42
51
|
// immediate block; a hook that fires on every Bash call must never become the blocker or the
|
|
43
52
|
// noise — run-gates.mjs already yells about a broken declaration at its own point of use).
|
|
@@ -54,6 +63,10 @@ import { pathToFileURL } from 'node:url';
|
|
|
54
63
|
export const HOOK_EVENT_NAME = 'PreToolUse';
|
|
55
64
|
export const BASH_TOOL_NAME = 'Bash';
|
|
56
65
|
export const GATES_REL = 'docs/ai/gates.json';
|
|
66
|
+
// The opt-in read-lane toggle (rung c) — a SEPARATE kit-owned strict-JSON file (AD-055 Part II);
|
|
67
|
+
// gates.json's schema/validators/template are untouched by design.
|
|
68
|
+
export const LANES_REL = 'docs/ai/lanes.json';
|
|
69
|
+
export const READ_LANE_KEY = 'readLane';
|
|
57
70
|
export const PROJECT_DIR_ENV = 'CLAUDE_PROJECT_DIR';
|
|
58
71
|
export const DECISION_ALLOW = 'allow';
|
|
59
72
|
export const DECISION_ASK = 'ask';
|
|
@@ -103,8 +116,16 @@ export const RESIDUAL_FORMS = Object.freeze({
|
|
|
103
116
|
writeRedirections: Object.freeze(['>', '>>', '1>', '2>', '&>', '>|']),
|
|
104
117
|
// `$(…)` + backtick + process substitution `<(…)` all RUN a nested command (`>(…)` is caught by
|
|
105
118
|
// the `>` redirection scan). Bare `<` is input redirection (reads a file — read-only commands may
|
|
106
|
-
// already do that), so it is deliberately NOT here.
|
|
107
|
-
|
|
119
|
+
// already do that), so it is deliberately NOT here. The bash-5.3 function substitutions `${ cmd; }`
|
|
120
|
+
// (a blank — space/tab/newline/CR — right after `${`) and `${| cmd; }` also RUN a nested command —
|
|
121
|
+
// matched as the literal openers `${ ` / `${\t` / `${\n` / `${\r` / `${|` (AD-055 Part II). Ordinary
|
|
122
|
+
// `${VAR}` has no blank after `${`, so it trips none of these (kept rung-(b)-silent).
|
|
123
|
+
commandSubstitutions: Object.freeze(['$(', '`', '<(', '${ ', '${\t', '${\n', '${\r', '${|']),
|
|
124
|
+
// A backslash immediately before a newline/CR is a bash LINE CONTINUATION: bash removes it and
|
|
125
|
+
// splices the two lines into ONE word, reconstructing a residual token (`--output`, `$(`, `${ …; }`)
|
|
126
|
+
// a raw substring scan on the pre-splice string misses. Guards a settings-allowed SINGLE (rung c
|
|
127
|
+
// already forbids backslash in every segment). AD-055 Part II council fold.
|
|
128
|
+
lineContinuations: Object.freeze(['\\\n', '\\\r']),
|
|
108
129
|
// The `--output` write-flag family, matched as a raw SUBSTRING of the whole command (never a
|
|
109
130
|
// whitespace-token check): the hook sees the pre-shell command string, so `--output=f`,
|
|
110
131
|
// `"--output=f"`, `'--output' f`, and `\--output` must all trip it — over-asking on a benign
|
|
@@ -115,6 +136,7 @@ export const RESIDUAL_FORMS = Object.freeze({
|
|
|
115
136
|
export const RESIDUAL_CLASS_REDIRECTION = 'output redirection';
|
|
116
137
|
export const RESIDUAL_CLASS_SUBSTITUTION = 'command substitution';
|
|
117
138
|
export const RESIDUAL_CLASS_OUTPUT_FLAG = 'a bounded --output write flag';
|
|
139
|
+
export const RESIDUAL_CLASS_CONTINUATION = 'a line-continuation splice';
|
|
118
140
|
|
|
119
141
|
const EXIT_OK = 0;
|
|
120
142
|
const UTF8 = 'utf8';
|
|
@@ -170,6 +192,25 @@ export const readDeclarationGates = (projectRoot, deps = {}) => {
|
|
|
170
192
|
}
|
|
171
193
|
};
|
|
172
194
|
|
|
195
|
+
// Read the opt-in read-lane toggle LIVE per call — the gates.json live-read pattern, over the
|
|
196
|
+
// SEPARATE kit-owned docs/ai/lanes.json (Decisions 5). Fail-closed: an absent / unreadable /
|
|
197
|
+
// malformed / non-object file, or a `readLane` that is not the boolean literal `true`, leaves the
|
|
198
|
+
// lane DARK (returns false → rung (c) never fires; rungs (a)/(b) unaffected). Only an explicit
|
|
199
|
+
// `{ "readLane": true }` opens the lane. A PRE-1.48 placed hook never calls this, so enabling the
|
|
200
|
+
// lane against a stale placed copy is simply a no-op (the gate-hook writer's currency check makes
|
|
201
|
+
// that loud at consent time).
|
|
202
|
+
export const readReadLaneEnabled = (projectRoot, deps = {}) => {
|
|
203
|
+
const readFile = deps.readFile ?? readFileSync;
|
|
204
|
+
if (typeof projectRoot !== 'string' || projectRoot === '') return false;
|
|
205
|
+
try {
|
|
206
|
+
const parsed = JSON.parse(readFile(join(projectRoot, LANES_REL), UTF8));
|
|
207
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
|
|
208
|
+
return parsed[READ_LANE_KEY] === true;
|
|
209
|
+
} catch {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
173
214
|
// ── seeded-core prefix + residual detection (ladder b) ────────────────────────────────
|
|
174
215
|
|
|
175
216
|
const getAllowCommandPrefix = (pattern) => {
|
|
@@ -200,23 +241,75 @@ export const matchSeededCorePrefix = (command) => {
|
|
|
200
241
|
// substring scan (never a whitespace-token check — a token check misses `"--output=f"` / `'>' f`
|
|
201
242
|
// where the quotes are still in the string but the shell will strip them). A quoted metacharacter
|
|
202
243
|
// or write flag may over-ASK, never under-allow (no shell parsing in a dependency-free hook).
|
|
244
|
+
// Word-construction — quoting (`"`/`'`), backslash, brace, glob (`[]`/`{}`/`*`/`?`) — can splice a
|
|
245
|
+
// residual token back together AFTER a raw substring scan: `--out"put"=f` / `--outpu[t]=f` /
|
|
246
|
+
// `--out{put}=f` all collapse to `--output=f` when Bash strips the construction (council R2-M1). The
|
|
247
|
+
// scan re-runs against a DE-SPLICED copy (those characters removed) so a settings-allowed SINGLE
|
|
248
|
+
// carrying such a reconstruction still trips. De-splicing only ever ADDS matches — over-ASK, the safe
|
|
249
|
+
// direction (rung (c) forbids every construction character per segment, so this guards rung (b) singles).
|
|
250
|
+
const WORD_CONSTRUCTION_CHARS = /["'\\[\]{}*?]/gu;
|
|
251
|
+
|
|
203
252
|
export const detectResidualClasses = (command) => {
|
|
253
|
+
const deSpliced = command.replace(WORD_CONSTRUCTION_CHARS, '');
|
|
254
|
+
const scan = (form) => command.includes(form) || deSpliced.includes(form);
|
|
204
255
|
const classes = [];
|
|
205
|
-
if (RESIDUAL_FORMS.writeRedirections.some(
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (RESIDUAL_FORMS.
|
|
209
|
-
classes.push(RESIDUAL_CLASS_SUBSTITUTION);
|
|
210
|
-
}
|
|
211
|
-
if (RESIDUAL_FORMS.boundedWriteFlags.some((flag) => command.includes(flag))) {
|
|
212
|
-
classes.push(RESIDUAL_CLASS_OUTPUT_FLAG);
|
|
213
|
-
}
|
|
256
|
+
if (RESIDUAL_FORMS.writeRedirections.some(scan)) classes.push(RESIDUAL_CLASS_REDIRECTION);
|
|
257
|
+
if (RESIDUAL_FORMS.commandSubstitutions.some(scan)) classes.push(RESIDUAL_CLASS_SUBSTITUTION);
|
|
258
|
+
if (RESIDUAL_FORMS.boundedWriteFlags.some(scan)) classes.push(RESIDUAL_CLASS_OUTPUT_FLAG);
|
|
259
|
+
if (RESIDUAL_FORMS.lineContinuations.some(scan)) classes.push(RESIDUAL_CLASS_CONTINUATION);
|
|
214
260
|
return classes;
|
|
215
261
|
};
|
|
216
262
|
|
|
263
|
+
// ── read-lane classifier (ladder c; Decisions 6-8) ────────────────────────────────────
|
|
264
|
+
// The opt-in read-lane's closed-world allow: a command is lane-safe ONLY when it is a compound (or
|
|
265
|
+
// single) of frozen read-only core commands with ZERO shell metaprogramming anywhere. Conservative
|
|
266
|
+
// by construction — any doubt returns false (no decision; the command keeps today's flow, the hook
|
|
267
|
+
// still never denies). The lane is BOUNDED BY THE FROZEN AUDITED read-only core (the set velocity
|
|
268
|
+
// seeds) — a standalone opt-in grant, never a command OUTSIDE that audited core. Enabling it
|
|
269
|
+
// auto-approves compounds (and singles) of that audited core REGARDLESS of which of those commands
|
|
270
|
+
// the user seeded as individual settings rules (the opt-in consent covers exactly the audited core,
|
|
271
|
+
// not strictly a subset of the current settings).
|
|
272
|
+
|
|
273
|
+
// Documented command separators (bash / Claude Code): the split points between independently-run
|
|
274
|
+
// segments. Longest-first so `&&`/`||`/`|&` win over the bare `|`; newline + CR included. No capture
|
|
275
|
+
// groups → `String.split` drops the separators. A bare `&` (backgrounding) is deliberately ABSENT
|
|
276
|
+
// here — it is caught as a forbidden per-segment character (so `ls & grep x` never rides the lane).
|
|
277
|
+
const LANE_SEPARATOR_PATTERN = /&&|\|\||\|&|;|\||\n|\r/u;
|
|
278
|
+
|
|
279
|
+
// Any of these characters in a post-split SEGMENT puts the whole command OUTSIDE the lane:
|
|
280
|
+
// expansion (`$`), substitution (backtick), quoting (`"` `'`), backslash splice (`\`), brace
|
|
281
|
+
// expansion (`{` `}`), glob (`*` `?` `[` `]`), redirection/subshell (`<` `>` `(` `)`),
|
|
282
|
+
// home/history/comment (`~` `!` `#`), and a leftover backgrounding/pipe operator (`&` `|`). The
|
|
283
|
+
// word-construction vectors (quote/backslash/brace/glob) are closed CONSERVATIVELY here — each can
|
|
284
|
+
// reconstruct a write/exec token after a raw scan (Decisions 7; the council B1 fold added the glob
|
|
285
|
+
// character-class brackets `[`/`]` after `--outpu[t]=f` was shown to reconstruct `--output`). The
|
|
286
|
+
// multi-char `--output` write flag is caught by detectResidualClasses over the raw string, not here.
|
|
287
|
+
const LANE_FORBIDDEN_SEGMENT_CHARS = Object.freeze([
|
|
288
|
+
'$', '`', '"', "'", '\\', '{', '}', '*', '?', '[', ']', '<', '>', '(', ')', '~', '!', '#', '&', '|',
|
|
289
|
+
]);
|
|
290
|
+
|
|
291
|
+
const hasForbiddenSegmentChar = (segment) => LANE_FORBIDDEN_SEGMENT_CHARS.some((ch) => segment.includes(ch));
|
|
292
|
+
|
|
293
|
+
// True iff every separator-split segment is a non-empty, metacharacter-free, frozen read-only core
|
|
294
|
+
// command. Quote-BLIND by design (the splitter never interprets quotes): over-splitting can only
|
|
295
|
+
// REDUCE the set of allowed commands, never widen it. An empty segment (adjacent/leading/trailing
|
|
296
|
+
// separator) never allows; a residual anywhere over the raw string (redirection, `$(`/backtick/`<(`,
|
|
297
|
+
// funsub, `--output`) takes the command out of the lane before the split even runs.
|
|
298
|
+
export const isReadLaneCommand = (command) => {
|
|
299
|
+
const trimmed = command.trim();
|
|
300
|
+
if (trimmed === '') return false;
|
|
301
|
+
if (detectResidualClasses(trimmed).length > 0) return false;
|
|
302
|
+
return trimmed.split(LANE_SEPARATOR_PATTERN).every((segment) => {
|
|
303
|
+
const seg = segment.trim();
|
|
304
|
+
if (seg === '') return false;
|
|
305
|
+
if (hasForbiddenSegmentChar(seg)) return false;
|
|
306
|
+
return matchSeededCorePrefix(seg) !== null;
|
|
307
|
+
});
|
|
308
|
+
};
|
|
309
|
+
|
|
217
310
|
// ── the decision ladder (pure core) ───────────────────────────────────────────────────
|
|
218
311
|
|
|
219
|
-
export const decideBashCall = ({ command, permissionMode, cwdIsProjectRoot, gates }) => {
|
|
312
|
+
export const decideBashCall = ({ command, permissionMode, cwdIsProjectRoot, gates, readLaneOn = false }) => {
|
|
220
313
|
const trimmed = command.trim();
|
|
221
314
|
// (a) declared-gate exact match — all three invariants (declaration valid, cwd = project
|
|
222
315
|
// root, mode in the allow fence) must hold; otherwise fall through, never error.
|
|
@@ -240,7 +333,15 @@ export const decideBashCall = ({ command, permissionMode, cwdIsProjectRoot, gate
|
|
|
240
333
|
};
|
|
241
334
|
}
|
|
242
335
|
}
|
|
243
|
-
// (c)
|
|
336
|
+
// (c) read-lane allow — opt-in (lanes.json), mode-fenced like (a) but cwd-agnostic (a read is a
|
|
337
|
+
// read from any directory). Runs AFTER (b), so a residual-carrying core command still ASKs.
|
|
338
|
+
if (readLaneOn === true && ALLOW_PERMISSION_MODES.includes(permissionMode) && isReadLaneCommand(trimmed)) {
|
|
339
|
+
return {
|
|
340
|
+
permissionDecision: DECISION_ALLOW,
|
|
341
|
+
permissionDecisionReason: `agent-workflow read-lane: every command segment is a frozen read-only core prefix with no shell metaprogramming — auto-approved (opt-in ${LANES_REL})`,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
// (d) no decision — the normal permission flow proceeds unchanged.
|
|
244
345
|
return null;
|
|
245
346
|
};
|
|
246
347
|
|
|
@@ -285,6 +386,7 @@ export const runHook = (rawInput, deps = {}) => {
|
|
|
285
386
|
permissionMode: input.permission_mode,
|
|
286
387
|
cwdIsProjectRoot: rootReal !== null && cwdReal !== null && rootReal === cwdReal,
|
|
287
388
|
gates: readDeclarationGates(projectRoot, deps),
|
|
389
|
+
readLaneOn: readReadLaneEnabled(projectRoot, deps),
|
|
288
390
|
});
|
|
289
391
|
};
|
|
290
392
|
|
package/references/modes/hook.md
CHANGED
|
@@ -4,12 +4,15 @@ The opt-in **gate-approval PreToolUse hook** — the family's third `.claude/` w
|
|
|
4
4
|
|
|
5
5
|
- **Auto-approve** a command **byte-identical** (leading/trailing trim only — no whitespace collapsing, no quote/glob/variable interpretation, no prefix or pattern matching, ever: patterns are what made AD-021 auto-seeding rejected) to a gate `cmd` declared in `docs/ai/gates.json` — read **LIVE on every call** (editing gates.json never needs re-wiring; one declaration, two consumers with `${CLAUDE_SKILL_DIR}/references/modes/gates.md`) — invoked **from the project root** (gates run from the root by contract; the same bytes from a subdirectory are NOT approved) and under `default`/`acceptEdits` permission mode (an approval never loosens `plan`/`bypassPermissions`).
|
|
6
6
|
- **Ask** on a command whose leading tokens match the velocity **seeded read-only core** when it carries the documented runtime residual — output redirection, command substitution, or the bounded `--output` write-flag family — surfacing a human prompt even where a seeded allow rule would have silently approved (**hook `ask` overrides an allow rule — proven live**: on Claude Code 2.1.185 a seeded `Bash(git log:*)` silently wrote a file via `git log --output=…`; with the hook wired the same call prompts). Detection is string-level and conservative: a quoted metacharacter may over-ask, never under-allow.
|
|
7
|
+
- **Auto-approve a read-only COMPOUND** (opt-in — dark unless enabled): when `docs/ai/lanes.json` sets `{ "readLane": true }` (read **LIVE on every call**), a command whose EVERY separator-split segment is a plain seeded read-only core command, carrying **zero shell metaprogramming** anywhere (no `$`/expansion, quoting, backslash, brace, glob, redirection, substitution, `--output`, env-assignment prefix, or backgrounding), is auto-approved — a conservative **closed-world** allow (any doubt falls through, never a widening). Mode-fenced like gate auto-approval; **cwd-agnostic** (a read is a read from any directory). It runs AFTER the residual ask, so a core command that carries a residual still prompts (most-restrictive-wins). The lane is **bounded by the frozen audited read-only core** (the set velocity seeds) — a **standalone opt-in grant**, never a command OUTSIDE that audited core; enabling it auto-approves compounds (and singles) of that audited core **regardless of which of those commands you seeded** as individual settings rules (that is the trust the opt-in consent covers — not strictly a subset of your current settings).
|
|
7
8
|
- **Stay silent otherwise** — the normal permission flow proceeds unchanged. The hook **never emits `deny`**; nothing is hard-blocked.
|
|
8
9
|
|
|
9
10
|
**Honest residual status (AD-037):** current engine builds already intercept `>` redirection and `$()` substitution upstream (observed headless on 2.1.185); the **`--output` family was proven open** and is the seam this hook demonstrably closes. The guard still covers all three documented classes (defense-in-depth — engine behavior may vary across surfaces/versions). **Fail-safe, decoupled:** a missing/broken/invalid `gates.json` disables ONLY gate auto-approval — the residual guard keeps running; every anomaly path exits 0 (the hook is never the blocker or the noise — the `gates` runner reports a broken declaration at its own point of use). **Not a sandbox:** it closes the named residual for the seeded core and auto-approves declared gates; it does not police arbitrary commands or user-added rules.
|
|
10
11
|
|
|
11
12
|
**Trust posture (state it plainly when asking consent):** the hook removes the PROMPT only for commands the human already declared in `docs/ai/gates.json` — the same trust boundary as the `gates` runner, which executes them with the caller's privileges. **gates.json thereby becomes a privileged file**: whoever can edit it can get its commands auto-approved. An invalid declaration approves NOTHING (strict parse, exact validation parity with the runner).
|
|
12
13
|
|
|
14
|
+
**The read-lane (opt-in, AD-055 Part II).** The toggle lives in `docs/ai/lanes.json` — a **SEPARATE kit-owned strict-JSON file** (`{ "readLane": true }`); `docs/ai/gates.json`, both its validators and the byte-mirrored template are **untouched** (the gates schema has no lane/model/routing fields — that claim stays true). The hook reads `lanes.json` LIVE per call, **fail-closed**: an absent / malformed / non-object file, or a `readLane` that is not the boolean `true`, leaves the lane dark (gate auto-approval and the residual ask are unaffected). `lanes.json` becomes a **privileged file** exactly like `gates.json` — an auto-approved read chain runs **unattended** and can read any file you can (the same trust boundary as the audited read-only core velocity seeds, extended to compounds (and singles) of that core; **prompt-bypass only, never a sandbox bypass**). Enable it with `--read-lane` (below), which runs a **currency check** first: it refuses unless the PLACED hook is byte-identical to the current bundle — a **pre-1.48 hook never reads `lanes.json`**, so enabling the lane against a stale placed copy would be a silent no-op the user paid consent for. The stale-hook silent-blackout class is dead **by construction** (an old hook merely lacks the rung; nothing existing changes behavior); the currency refusal names the **delete-to-reseed** recovery (`rm .claude/hooks/agent-workflow-gates.mjs` + the `--apply` one-liner) — there is **no refresh-only hook lane** this release (a diverged wired copy stays preserved, unchanged).
|
|
15
|
+
|
|
13
16
|
**Version-status routing** like the other writer modes (stamp head `2.0.0`; `--apply` enforces it in code).
|
|
14
17
|
|
|
15
18
|
Run `node ${CLAUDE_SKILL_DIR}/tools/gate-hook.mjs [--dry-run | --apply] [--cwd <dir>]`:
|
|
@@ -19,6 +22,8 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/gate-hook.mjs [--dry-run | --apply] [--cwd <
|
|
|
19
22
|
3. **Only on an explicit yes**, re-run with `--apply`. It places the file FIRST, then wires settings (a wired-but-missing entry would error on every Bash call); merge-don't-clobber (foreign hooks/matchers/keys and existing permissions preserved; re-apply never duplicates); **settings hot-reload — the hook is active for new Bash calls, no session restart needed**. An identical existing file is *already current*; a diverged-but-already-wired file is reported, never clobbered or unwired.
|
|
20
23
|
4. **Hidden-mode deployments:** after apply, run the hide-footprint reconcile (`node ${CLAUDE_SKILL_DIR}/tools/hide-footprint.mjs --dir <project> --reconcile`) — `/.claude/hooks/` is in the known-footprint registry; the apply report reminds you.
|
|
21
24
|
|
|
22
|
-
**
|
|
25
|
+
**Enabling the read-lane:** `node ${CLAUDE_SKILL_DIR}/tools/gate-hook.mjs --read-lane [--dry-run | --apply] [--cwd <dir>]`. `--dry-run` (the default) previews the `lanes.json` edit, the hook-currency status, and the trust posture; `--apply --read-lane` runs the **currency check** (above), then writes `{ "readLane": true }` into `docs/ai/lanes.json` — create-if-absent, merge-preserve every key, symlink / non-regular refusal, malformed-JSON fail-closed, atomic. It **never touches** `.claude/settings.json` or `docs/ai/gates.json`. The upgrade Recommendations advisor surfaces this as the `read-lane` offer once the hook is placed **and** wired.
|
|
26
|
+
|
|
27
|
+
**Invariants:** the base flow writes ONLY `.claude/hooks/agent-workflow-gates.mjs` + `.claude/settings.json`; `--read-lane` writes ONLY `docs/ai/lanes.json` (never settings, never `gates.json`) · never `settings.local.json` · never commits · exact-match approval only (no patterns) · never `deny` · never auto-wired by `init`/`upgrade` (placement stays opt-in — the AD-011/AD-034 boundary: init/upgrade may refresh placed things, never place new ones).
|
|
23
28
|
|
|
24
29
|
**Exit codes:** `0` done / dry-run (incl. the reported diverged-but-wired state); `1` a precondition STOP; `2` bad arguments.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md
|
|
4
4
|
|
|
5
|
-
The **read-only deployment advisor** — the deterministic section every `upgrade` run ends with, also invocable on its own. It computes what in THIS deployment is configured sub-optimally (allowlist not seeded, autonomy render drifted, sandbox unavailable, gates undeclared, bridge friction, sandbox-mask clutter, an unacknowledged sandbox recipe) and renders **verdict-first**: one composed verdict line, then each item as **{severity · what is sub-optimal · the benefit in ONE plain line · the exact consent-gated apply one-liner}**. The tool computes deterministic English DATA; **you PRESENT the section in the user's conversational language** — every fact, count and item from the tool, nothing added or dropped; commands, paths, hosts and rule strings stay **byte-exact**; show the raw tool block on request (the AD-032 report-contract lane — the tool cannot know the dialogue language, so the language rendering is your presentation layer).
|
|
5
|
+
The **read-only deployment advisor** — the deterministic section every `upgrade` run ends with, also invocable on its own. It computes what in THIS deployment is configured sub-optimally (allowlist not seeded, autonomy render drifted, sandbox unavailable, gates undeclared, bridge friction, sandbox-mask clutter, an unacknowledged sandbox recipe) and renders **verdict-first**: one composed verdict line, then each item as **{severity · what is sub-optimal · the benefit in ONE plain line · an optional `recipe:` line (the `sandbox-lane` live recipe — egress hosts + resolved writable dirs — only) · the exact consent-gated apply one-liner}**. The tool computes deterministic English DATA; **you PRESENT the section in the user's conversational language** — every fact, count and item from the tool, nothing added or dropped; commands, paths, hosts and rule strings stay **byte-exact**; show the raw tool block on request (the AD-032 report-contract lane — the tool cannot know the dialogue language, so the language rendering is your presentation layer).
|
|
6
6
|
|
|
7
7
|
**Live host/session facts are tool-composed only.** Every fact this section states about the current
|
|
8
8
|
host or session — prompts fired, sandbox scope, whether a bypass was needed, network reachability,
|
|
@@ -15,20 +15,22 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root> [--
|
|
|
15
15
|
|
|
16
16
|
1. **`--cwd` is REQUIRED** — the target project is explicit, never inferred from the shell's current directory (a subdirectory invocation still advises on the named root).
|
|
17
17
|
2. **Verdict first (present-even-when-empty, opening at the `## Recommendations (agent-workflow)` header).** With everything optimal the body is exactly `no recommendations — flow optimal.` — the empty-state line ALONE is the verdict, zero added lines. Every other state opens the body with ONE verdict line composed from the frozen templates: `{K} item(s) need attention` leads when any item's severity is `attention` (a CONFIGURED declaration that is broken, drifted, degrading or invalid — the deployment needs review); `{N} optional recommendation(s), apply any you want` covers the `optional` class (offers to enable an unconfigured capability), led by `nothing is broken` ONLY when no item needs attention AND no probe check was skipped (a skipped probe could hide an attention-class problem — the claim never overreaches); `optimality NOT attested — {M} probe check(s) skipped` is appended last whenever probe checks were skipped. (The `(s)` invariant form IS the pinned pluralization — no singular/plural branching.) Items render attention-first, each tagged `needs attention:` / `optional:`. A failed probe renders as a stated `⚠ skipped item …` line — never a crash, never a fabricated item.
|
|
18
|
-
3. **The apply-through-agent lane — an explicit informed-consent checkpoint:** after presenting, OFFER to apply; the user selects items in plain language; for each selected item you **surface its posture note inline** where one exists (the per-item notes below cover exactly the risk-marked keys; for `sandbox-lane` the note INCLUDES the sandbox-lanes ladder — present the whole ladder inline at the consent moment, never as a bare pointer).
|
|
18
|
+
3. **The apply-through-agent lane — an explicit informed-consent checkpoint:** after presenting, OFFER to apply; the user selects items in plain language; for each selected item you **surface its posture note inline** where one exists (the per-item notes below cover exactly the risk-marked keys; for `sandbox-lane` the note INCLUDES the sandbox-lanes ladder — present the whole ladder inline at the consent moment, never as a bare pointer). The user **explicitly confirms**, and **only then** — **no command runs before confirmation** — do you run the rendered command. Do NOT infer safety from the presence or absence of an `--apply` flag: most items' rendered command IS the mutation and completes on that one run, **including a no-`--apply` mutation** such as `family-freshness`'s `npx … init`. Some items instead render a **dry-run preview** that changes nothing and prints an explicit follow-up `--apply`/mutating command to run NEXT (e.g. `sandbox-lane`'s ack-write — a NEUTRAL recipe fingerprint into the family-owned `docs/ai/acks.json`, never a security key — and the `gates-declaration` seeder): after the SAME confirmation you run that printed follow-up command — no second ask, no improvisation (each writer keeps its own consent semantics: previews, `--apply` flags, refusals). An item marked **HAND-APPLY** (e.g. `agy-adddir`) is **never run by you and never written by the kit** — hand the user its rendered line together with its posture note.
|
|
19
19
|
4. Registry strings (benefits + item texts) are frozen tool data, fact-true, ONE line under the pinned shape cap — posture/risk detail lives in the notes below at the consent moment, never inline in the overview. The dual velocity+security wording (`safer — blast radius bounded by the OS sandbox, not human attention`) rides ONLY the items with a real security delta (the autonomy render, the sandbox provisioning); the bridge-wrappers item claims **velocity only**.
|
|
20
20
|
|
|
21
21
|
**Per-item posture notes (the consent moment — surface BEFORE running or handing over the apply):**
|
|
22
22
|
|
|
23
23
|
- `agy-adddir` — enabling the add-dir offload re-enables the Issue-001 stall risk (the wrapper's hard timeout bounds it); with the knob off an oversized agy code review refuses instead. Risk profile: availability only, no security delta.
|
|
24
|
-
- `sandbox-lane` — surface this note TOGETHER with the sandbox-lanes ladder below (the ladder IS the practical half of the note — inline, never a pointer). Pure DISCOVERABILITY: it surfaces the manifest-declared observed session-sandbox recipe (egress hosts ∪ resolved writable state dirs — `networkHosts` ∪ `writableDirs` of the wired bridges' `capability.json`, the single documentation source) and converges on a NEUTRAL fingerprint acknowledgement (`"agentWorkflow": { "sandboxLaneAck": … }
|
|
24
|
+
- `sandbox-lane` — surface this note TOGETHER with the sandbox-lanes ladder below (the ladder IS the practical half of the note — inline, never a pointer). Pure DISCOVERABILITY: it surfaces the manifest-declared observed session-sandbox recipe (egress hosts ∪ resolved writable state dirs — `networkHosts` ∪ `writableDirs` of the wired bridges' `capability.json`, the single documentation source) and converges on a NEUTRAL fingerprint acknowledgement recorded by the consent-gated **ack writer** into the family-owned `docs/ai/acks.json` (`sandboxLaneAck`; a changed recipe re-fires the item). The store is family-owned so no host settings validator guards it (AD-055 relocated the ack off the Claude Code settings schema, which rejected the unknown key); the legacy `"agentWorkflow": { "sandboxLaneAck": … }` settings-scope key is still READ for one deprecation window (until the next kit MAJOR). It never claims the settings security keys take effect on any host class, never recommends writing them, and the kit never seeds `sandbox.network.allowedDomains` / `sandbox.filesystem.allowWrite` (bridge council 2026-07-11, both backends concur: a network pre-allow widens egress for EVERY sandboxed command; a write allowance on CLI state dirs would expose credential dirs). Posture history: an IDE-managed session sandbox was live-observed (2026-07-11/12) ignoring hand-applied settings security keys in BOTH scopes, and codex needs a writable HOME (EROFS `~/.codex` in-sandbox); whether a session's sandbox honors the settings keys is runtime-unknowable from the advisor (a denial-only signal) — which is exactly why the item states only detectable facts and no zero-prompt promise on any host class.
|
|
25
|
+
|
|
26
|
+
- `read-lane` — enabling the opt-in read-only compound lane auto-approves *compounds* (and singles) of the seeded read-only core that carry ZERO shell metaprogramming: an UNATTENDED trust extension, bounded by the audited read-only core (never a command outside it; prompt-bypass only, never a sandbox bypass) and applied regardless of which of those core commands you seeded as individual settings rules. It is a PROJECT-PERSISTENT declaration in `docs/ai/lanes.json` — every future session, subagents' Bash too where the host fires hooks on subagent Bash, and (committed) every checkout. The apply depends on state: when the lane is OFF, it is the `gate-hook --read-lane` preview (whose own currency check refuses a stale hook — a pre-1.48 hook never reads `lanes.json`); when the placed hook is STALE (an enabled lane over an old hook) or MISSING, the item instead surfaces a **delete-to-reseed** / re-place recovery (a destructive `rm` + `--apply`, an attention item — never the safe preview). Risk profile: a bounded read-only trust-posture extension — no write/exec exposure beyond the audited core.
|
|
25
27
|
|
|
26
28
|
**Sandbox lanes (what to DO with the `sandbox-lane` recipe, per host class):**
|
|
27
29
|
|
|
28
|
-
- **Settings-native sandbox** (the harness reads the `.claude/settings.json` sandbox keys): nothing beyond the `--bridge-tier` wiring — the tier's `excludedCommands` already routes the wrappers OUTSIDE the sandbox, so they never consult `allowedDomains`/`allowWrite`. Record the ack once the tier is confirmed.
|
|
29
|
-
- **Harness-managed sandbox** (an IDE/session-imposed sandbox that ignores the settings keys) — a NARROWEST-SCOPE ladder: (1) prefer wrapper/command-SCOPED sandbox rules where the harness offers them (only the review wrappers gain the egress + state-dir writes); (2) a SESSION-scoped allowance (the manifest hosts + the resolved state dirs for the whole session) is an INFORMED WIDENING — it carries the same blast-radius class the settings security keys were rejected for (every sandboxed command in the session gains that egress and those writable credential dirs), acceptable only as the maintainer's deliberate choice for a review-heavy session; (3)
|
|
30
|
+
- **Settings-native sandbox** (the harness reads the `.claude/settings.json` sandbox keys): nothing beyond the `--bridge-tier` wiring — the tier's `excludedCommands` already routes the wrappers OUTSIDE the sandbox, so they never consult `allowedDomains`/`allowWrite`. Record the ack (the ack-write one-liner) once the tier is confirmed.
|
|
31
|
+
- **Harness-managed sandbox** (an IDE/session-imposed sandbox that ignores the settings keys) — a NARROWEST-SCOPE ladder: (1) prefer wrapper/command-SCOPED sandbox rules where the harness offers them (only the review wrappers gain the egress + state-dir writes); (2) a SESSION-scoped allowance (the manifest hosts + the resolved state dirs for the whole session) is an INFORMED WIDENING — it carries the same blast-radius class the settings security keys were rejected for (every sandboxed command in the session gains that egress and those writable credential dirs), acceptable only as the maintainer's deliberate choice for a review-heavy session. Its **paste-ready hand-apply shape** (the kit never writes these keys) is, in `.claude/settings.json`, `"sandbox": { "network": { "allowedDomains": ["<egress host>", …] }, "filesystem": { "allowWrite": ["<writable state dir>", …] } }` — fill `<egress host>` / `<writable state dir>` from THIS recipe (the wired bridges' `capability.json` `networkHosts` ∪ resolved `writableDirs`); one consent then lands durably in config. (3) Else the per-run consented bypass. Record the ack (the ack-write one-liner) once a lane is chosen — choosing the bypass, or consciously declining, also counts.
|
|
30
32
|
- Either way the recipe is **observed-minimal, honestly incomplete** — a blocked host names itself at run time; extend by hand and re-ack (the fingerprint moves with the recipe, not with your extensions).
|
|
31
33
|
|
|
32
|
-
**Invariants:** read-only (never writes, never commits, never runs a subscription CLI) · `--cwd` required · present-even-when-empty · verdict-first from frozen templates · probe failures degrade to stated skip lines · apply one-liners are cwd-independent (absolute tool paths + a pinned `--cwd`; the sandbox-provision item pins via a `cd <root> &&` prefix — the doctor reads its cwd; the ONE exception is the `set-autonomy` item, a conversational skill invocation explicitly labeled *run IN the target project*) · the kit never seeds `sandbox.network.allowedDomains` / `sandbox.filesystem.allowWrite` (hand-apply territory
|
|
34
|
+
**Invariants:** read-only (never writes, never commits, never runs a subscription CLI) · `--cwd` required · present-even-when-empty · verdict-first from frozen templates · probe failures degrade to stated skip lines · apply one-liners are cwd-independent (absolute tool paths + a pinned `--cwd`; the sandbox-provision item pins via a `cd <root> &&` prefix — the doctor reads its cwd; the ONE exception is the `set-autonomy` item, a conversational skill invocation explicitly labeled *run IN the target project*) · the kit never seeds `sandbox.network.allowedDomains` / `sandbox.filesystem.allowWrite` (hand-apply territory) · the sandbox-lane convergence is a neutral acknowledgement recorded into `docs/ai/acks.json` by the consent-gated ack writer (the legacy settings-scope key read for one deprecation window), never a security key.
|
|
33
35
|
|
|
34
36
|
**Exit codes:** `0` report rendered (items or the empty state); `1` error (e.g. `--cwd` is not a directory); `2` usage.
|
|
@@ -11,8 +11,13 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/setup-backends.mjs [<backend>] [--bindir <pa
|
|
|
11
11
|
runs as its fourth stamp-independent reconcile): refresh every bridge `setup` **already placed**
|
|
12
12
|
from the kit's bundled copies + re-link its wrappers; an absent bridge is a stated skip (**never**
|
|
13
13
|
placed), a placed bridge newer than the bundle is a stated skip naming the kit update (**never**
|
|
14
|
-
downgraded), and every outcome line is composed by the tool — paste verbatim.
|
|
15
|
-
|
|
14
|
+
downgraded), and every outcome line is composed by the tool — paste verbatim. When the skills dir
|
|
15
|
+
is **read-only this session** and the placed bridge is already at the bundled version, the
|
|
16
|
+
equal-version re-sync it would run cannot write: that outcome is `skipped-readonly` — a **stated
|
|
17
|
+
skip** (exit 0, not a failure) naming the current version, the skipped/incomplete re-sync, and the
|
|
18
|
+
read-only cause; it never claims a re-sync ran, and any local drift persists until a writable rerun.
|
|
19
|
+
(A version-**behind** refresh blocked by the same read-only dir stays a loud `could not refresh`,
|
|
20
|
+
its recovery pointing at a writable rerun.) Does not combine with `--dry-run`.
|
|
16
21
|
- `--help`, `-h` — usage.
|
|
17
22
|
|
|
18
23
|
For each backend it:
|
|
@@ -31,14 +31,14 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
31
31
|
**Placed-bridge refresh — stamp-independent, same gate, BEFORE the equal-head short-circuit.** Run
|
|
32
32
|
`node ${CLAUDE_SKILL_DIR}/tools/setup-backends.mjs --refresh-placed` and **paste its per-bridge
|
|
33
33
|
output lines verbatim** — every outcome line is composed by the tool (*refreshed* / *already
|
|
34
|
-
current* / *skipped — not placed* / *could not refresh* + its recovery)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
non-fatal
|
|
34
|
+
current* / *skipped — not placed* / `skipped-readonly` / *could not refresh* + its recovery). It
|
|
35
|
+
is **refresh-only**: it refreshes a bridge **`setup` already placed** from this kit's bundled copies
|
|
36
|
+
and re-links its wrappers; an **absent** bridge is a stated skip, **never a first placement**
|
|
37
|
+
(placement stays the opt-in `${CLAUDE_SKILL_DIR}/references/modes/setup.md` — AD-009/AD-011 honesty intact), a placed bridge
|
|
38
|
+
**newer** than the bundle is a stated skip naming the kit update (**never a downgrade**), and
|
|
39
|
+
`skipped-readonly` is an equal-version re-sync a **read-only** skills dir blocked this session (a
|
|
40
|
+
stated skip, exit 0 — not a failure). Runs on **every** upgrade (equal-head too), no lineage-head
|
|
41
|
+
bump; a *could not refresh* line is non-fatal — relay it plainly with its recovery.
|
|
42
42
|
|
|
43
43
|
**Agent-rules lens refresh — stamp-independent, same gate, BEFORE the equal-head short-circuit.**
|
|
44
44
|
Run `node ${CLAUDE_SKILL_DIR}/tools/lens-region.mjs reconcile <project>/docs/ai/agent_rules.md`
|
|
@@ -56,7 +56,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
56
56
|
**NEVER writes** it (the file lives outside every kit tree — D2), so an unknown/retired key is
|
|
57
57
|
flagged + preserved, never edited. Runs on **every** upgrade; exit 0 covers every outcome.
|
|
58
58
|
4. **Equal-head exit — a real successful-exit report, not a bare stop.** If the stamp **equals** the head, the lineage is up to date — but step 3 (the stamp-independent reconciles) ran first and may have changed things, so this is a proper exit report, not a no-op:
|
|
59
|
-
- **Report step 3's outcome in plain language** — for **each** pointer (workflow-methodology, orchestration-recipes and autonomy-policy) whether it was *added*, was *already present* (nothing changed), or was *skipped* (the soft-skip from step 3, with its reason — over the line limit / engine too old / the autonomy pointer's anchor absent); whether the `docs/ai/orchestration.json` config was *seeded* (created from the template), had its onboarding note *refreshed*, was *already current*, or carried a *customized note that was preserved* (a user edit is never clobbered); whether the `docs/ai/gates.json` gate declaration was *seeded* or was *already present* (preserved byte-for-byte); whether the `docs/ai/verification-profile.json` profile was *seeded* or was *already present* (preserved byte-for-byte); whether the `docs/ai/autonomy.json` declaration was *seeded* (the sparse defaults-equivalent note) or was *already present* (preserved byte-for-byte); whether the enforcement-script ensure *added* the `archive-decisions` pair to `scripts/`, found it *already present*, or found an *old ADR layout — migration instructed*; the **placed-bridge refresh** outcome — paste the tool's per-bridge lines verbatim (they are already plain: *refreshed* / *already current* / *skipped — not placed* / *could not refresh* + recovery); the **agent-rules lens** outcome (*refreshed* / *already current* / *custom edit preserved + note* / *file absent* / *engine too old* / *over the line cap*); the **bridge-settings reconcile** outcome (paste the tool's line verbatim); and, for a hidden deployment, whether the hidden-mode footprint was *moved to project-local*, was *already project-local* (nothing changed), or needed a question (ambiguous visibility / a leftover machine-wide block). Plain wording only — never the reconcile/slot/anchor/marker terms (the never-leak-kit-internals Gotcha — `${CLAUDE_SKILL_DIR}/references/shared/deploy-tail.md`).
|
|
59
|
+
- **Report step 3's outcome in plain language** — for **each** pointer (workflow-methodology, orchestration-recipes and autonomy-policy) whether it was *added*, was *already present* (nothing changed), or was *skipped* (the soft-skip from step 3, with its reason — over the line limit / engine too old / the autonomy pointer's anchor absent); whether the `docs/ai/orchestration.json` config was *seeded* (created from the template), had its onboarding note *refreshed*, was *already current*, or carried a *customized note that was preserved* (a user edit is never clobbered); whether the `docs/ai/gates.json` gate declaration was *seeded* or was *already present* (preserved byte-for-byte); whether the `docs/ai/verification-profile.json` profile was *seeded* or was *already present* (preserved byte-for-byte); whether the `docs/ai/autonomy.json` declaration was *seeded* (the sparse defaults-equivalent note) or was *already present* (preserved byte-for-byte); whether the enforcement-script ensure *added* the `archive-decisions` pair to `scripts/`, found it *already present*, or found an *old ADR layout — migration instructed*; the **placed-bridge refresh** outcome — paste the tool's per-bridge lines verbatim (they are already plain: *refreshed* / *already current* / *skipped — not placed* / `skipped-readonly` / *could not refresh* + recovery); the **agent-rules lens** outcome (*refreshed* / *already current* / *custom edit preserved + note* / *file absent* / *engine too old* / *over the line cap*); the **bridge-settings reconcile** outcome (paste the tool's line verbatim); and, for a hidden deployment, whether the hidden-mode footprint was *moved to project-local*, was *already project-local* (nothing changed), or needed a question (ambiguous visibility / a leftover machine-wide block). Plain wording only — never the reconcile/slot/anchor/marker terms (the never-leak-kit-internals Gotcha — `${CLAUDE_SKILL_DIR}/references/shared/deploy-tail.md`).
|
|
60
60
|
- **Never surface the structure number on this exit.** Whatever step 3 did, do **not** recite the `docs/ai` structure version, the internal versioning vocabulary, or the two-axes note here — the number is inert on an equal-head exit; it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md` (shown at the never-downgrade STOP, the explicit status view, or on an explicit ask). Frame the success itself per the final bullet: if step 3 changed anything, say **what changed** in plain human terms; only a pure zero-diff no-op is *settings already current — no update needed*.
|
|
61
61
|
- **Render the mandatory Recommendations section — on this exit too, BEFORE the footer.** Run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root>` and PRESENT its output — from the `## Recommendations (agent-workflow)` header — in the user's conversational language: every fact, count and item from the tool, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; show the raw tool block on request. The section is present-even-when-empty (with everything optimal the body is exactly `no recommendations — flow optimal.`) and VERDICT-FIRST — the composed verdict line renders from the frozen templates `{K} item(s) need attention` / `nothing is broken` / `{N} optional recommendation(s), apply any you want` / `optimality NOT attested — {M} probe check(s) skipped`. Then OFFER the consent-gated applies: the user picks items in plain language; surface each picked item's posture note, get the explicit confirm, then run EXACTLY the rendered one-liners (a HAND-APPLY item is never run by you) — the full lane in `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`. Pinned order on this exit: Recommendations block → optional applies → report footer → the commit ask (the advisor/apply lane never lands after the commit ask).
|
|
62
62
|
- **Live host/session facts are tool-composed only.** Any claim this report makes about the current host or session state — prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts — must trace to **live tool output** from **this session** (the lines you just composed, or a probe you ran this run); a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection. Full clause: *Live host/session facts* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`.
|
|
@@ -25,13 +25,13 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs [--dry-run | --apply] [
|
|
|
25
25
|
- `node ${CLAUDE_SKILL_DIR}/tools/manifest/validate.mjs --strict <skill-dir>` (wildcard)
|
|
26
26
|
- `node ${CLAUDE_SKILL_DIR}/tools/release-scan.mjs <path>` (wildcard)
|
|
27
27
|
- `node ${CLAUDE_SKILL_DIR}/tools/run-gates.mjs --cwd ${PROJECT_ROOT}` — **EXACT byte-string only**, and honestly **project-exec, not read-only**: it runs YOUR declared `docs/ai/gates.json` commands — the same trust boundary the opt-in hook grants byte-exact per-cmd. A wildcard would be BROADER than that boundary (`--cwd <dir>` executes another project's declared gates), so the bare cwd-defaulting form, any other `--cwd`, `--only`, and **`--record`** forms all still prompt (`--record` WRITES a gate-run record into the review ledger — a recording run is never auto-approved, AD-048).
|
|
28
|
-
- Writer previews, **exact arg-free dry-run byte-strings only** (
|
|
28
|
+
- Writer previews, **exact arg-free dry-run byte-strings only** (the SEEDED tier byte-string is the arg-free preview of each): `node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs` · `node ${CLAUDE_SKILL_DIR}/tools/cheap-agents.mjs` · `node ${CLAUDE_SKILL_DIR}/tools/gate-hook.mjs` — every `--apply`/`--write`/`--yes` still prompts, always. (`gate-hook` also has a **`--read-lane`** flagged preview — the opt-in read-only compound lane, `${CLAUDE_SKILL_DIR}/references/modes/hook.md`; that flagged form is NOT the seeded arg-free byte-string, so it may **prompt once** — it IS a consent flow, stated, no silent cap.)
|
|
29
29
|
|
|
30
30
|
Honesty notes: tier entries get **NO PreToolUse-hook residual coverage** — 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.
|
|
31
31
|
|
|
32
32
|
**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.
|
|
33
33
|
|
|
34
|
-
**The `--bridge-tier` (own opt-in, AD-044).** Seeds what a promptless council review run needs — BOTH surfaces: `permissions.allow` prefix rules AND the wrapper names in `sandbox.excludedCommands` (the harness runs an excluded command OUTSIDE the sandbox — the wrappers need network — so a plain allowlisted invocation triggers no sandbox-bypass approval). Both land in the **project** `.claude/settings.json` — the file this writer owns; an exclusion placed only in `settings.local.json` was live-observed NOT to route the command outside the sandbox (2026-07-11: the wrapper then starts sandboxed and dies on a read-only HOME + a network prompt), so hand-wiring the local file is not a working substitute for this tier. **Honesty note:** a session whose sandbox is imposed by the harness runtime itself (e.g. an IDE-managed session sandbox) may ignore settings-level exclusions entirely — there the wrappers need the session-level sandbox config (or a per-run consented bypass); the tier's seeded posture is correct for the settings-native sandbox and simply prompts again elsewhere (fail-safe, never a silent widening). Each bridge's observed egress hosts are declared in its `capability.json` `networkHosts` — the single documentation source (observed-minimal — a blocked host names itself at run time; read the manifests, this doc deliberately retypes no host list). The kit **never seeds** `sandbox.network.allowedDomains` or `sandbox.filesystem.allowWrite` (bridge council 2026-07-11, both backends concur): a network pre-allow widens egress for EVERY sandboxed command, and a write allowance on CLI state dirs (`~/.codex`, `~/.gemini/…`) would make credential dirs writable to every sandboxed command. Per-bridge picture under a harness-managed sandbox (live-observed 2026-07-11/12): an IDE-managed session sandbox ignores hand-applied `sandbox.network.allowedDomains` / `sandbox.filesystem.allowWrite` in BOTH settings scopes — its own per-host network consents govern egress; the durable zero-prompt lanes there are the session/host sandbox config (hosts from `networkHosts` + the CLI state-dir writes) or the per-run consented bypass. codex additionally needs a writable HOME (EROFS `~/.codex`); note the apex-vs-wildcard nuance — an apex domain is NOT covered by its `*.`-wildcard form, so the manifests carry both forms where observed (the blocked host names itself at run time). The upgrade Recommendations advisor surfaces exactly this recipe (hosts ∪ resolved `writableDirs`) as the
|
|
34
|
+
**The `--bridge-tier` (own opt-in, AD-044).** Seeds what a promptless council review run needs — BOTH surfaces: `permissions.allow` prefix rules AND the wrapper names in `sandbox.excludedCommands` (the harness runs an excluded command OUTSIDE the sandbox — the wrappers need network — so a plain allowlisted invocation triggers no sandbox-bypass approval). Both land in the **project** `.claude/settings.json` — the file this writer owns; an exclusion placed only in `settings.local.json` was live-observed NOT to route the command outside the sandbox (2026-07-11: the wrapper then starts sandboxed and dies on a read-only HOME + a network prompt), so hand-wiring the local file is not a working substitute for this tier. **Honesty note:** a session whose sandbox is imposed by the harness runtime itself (e.g. an IDE-managed session sandbox) may ignore settings-level exclusions entirely — there the wrappers need the session-level sandbox config (or a per-run consented bypass); the tier's seeded posture is correct for the settings-native sandbox and simply prompts again elsewhere (fail-safe, never a silent widening). Each bridge's observed egress hosts are declared in its `capability.json` `networkHosts` — the single documentation source (observed-minimal — a blocked host names itself at run time; read the manifests, this doc deliberately retypes no host list). The kit **never seeds** `sandbox.network.allowedDomains` or `sandbox.filesystem.allowWrite` (bridge council 2026-07-11, both backends concur): a network pre-allow widens egress for EVERY sandboxed command, and a write allowance on CLI state dirs (`~/.codex`, `~/.gemini/…`) would make credential dirs writable to every sandboxed command. Per-bridge picture under a harness-managed sandbox (live-observed 2026-07-11/12): an IDE-managed session sandbox ignores hand-applied `sandbox.network.allowedDomains` / `sandbox.filesystem.allowWrite` in BOTH settings scopes — its own per-host network consents govern egress; the durable zero-prompt lanes there are the session/host sandbox config (hosts from `networkHosts` + the CLI state-dir writes) or the per-run consented bypass. codex additionally needs a writable HOME (EROFS `~/.codex`); note the apex-vs-wildcard nuance — an apex domain is NOT covered by its `*.`-wildcard form, so the manifests carry both forms where observed (the blocked host names itself at run time). The upgrade Recommendations advisor surfaces exactly this recipe (hosts ∪ resolved `writableDirs`) as the `sandbox-lane` discoverability item — a consent-gated **ack writer** converging on a neutral fingerprint acknowledgement recorded into the family-owned `docs/ai/acks.json` (the kit still never seeds the security keys — those stay hand-apply), with the posture notes at the consent moment (`${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`). Membership is the FROZEN review-wrapper constant, **never** the execution/probe wrappers (`codex-exec`, `agy-run` keep their human prompt — delegated execution is not covered by this consent; codex-exec's nested-sandbox recovery is the canon's observed-failure lane, not a preemptive tier seed), and only the **`code` review mode** — a `plan`/`diff` invocation takes a file argument that can point OUTSIDE the repo, so those modes keep their prompt; each wrapper entry derives ONLY when its bridge is **placed on PATH** (an absent bridge is a stated skip). The seeded byte-forms (this list IS the documented-invocation source for the bridge tier):
|
|
35
35
|
|
|
36
36
|
- `Bash(codex-review code:*)` — the code-mode prefix, args wildcard; plus `codex-review` in `sandbox.excludedCommands`
|
|
37
37
|
- `Bash(agy-review code:*)` — the code-mode prefix, args wildcard; plus `agy-review` in `sandbox.excludedCommands`
|
|
@@ -41,6 +41,8 @@ Honesty notes: tier entries get **NO PreToolUse-hook residual coverage** — the
|
|
|
41
41
|
|
|
42
42
|
**Invocation shape (why a run still prompts):** a prefix allow rule matches only a **PLAIN invocation starting with the wrapper name** — `codex-review code`, `agy-review code --facts @f`. An env-var prefix (`AGY_PROBE=1 agy-review …`) or a compound chain (`agy-review … && …`; `;`-joined statements) can NEVER match a prefix rule (observed live); output redirects are fine. Drive the wrappers as plain single commands.
|
|
43
43
|
|
|
44
|
+
**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.
|
|
45
|
+
|
|
44
46
|
**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.
|
|
45
47
|
|
|
46
48
|
---
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ack-write.mjs — the consent-gated writer for the family-owned neutral ack store
|
|
3
|
+
// (docs/ai/acks.json, AD-055 Part I). The upgrade Recommendations `sandbox-lane` item renders THIS
|
|
4
|
+
// tool's PREVIEW one-liner; the preview prints the exact `--apply` command; the agent runs `--apply`
|
|
5
|
+
// only after the mode doc's §3 informed-consent confirmation. It records a NEUTRAL recipe fingerprint
|
|
6
|
+
// acknowledgement — never a security key; the kit never writes sandbox network/filesystem allowances.
|
|
7
|
+
//
|
|
8
|
+
// Family writer discipline (velocity / orchestration-write / gate-hook), verbatim:
|
|
9
|
+
// • preview-then-mutate — `--dry-run` is the DEFAULT and writes nothing; `--apply` writes;
|
|
10
|
+
// • deployment-gated — REFUSES an absent docs/ai with a named recovery pointer (run init/bootstrap);
|
|
11
|
+
// • creates docs/ai/acks.json (and nothing else) if absent;
|
|
12
|
+
// • merge-preserve — every existing key (a hand-authored `_README`, future sibling acks) is kept;
|
|
13
|
+
// only the `sandboxLaneAck` key is set;
|
|
14
|
+
// • symlink / non-regular target → STOP (never write through a link or clobber a device/dir);
|
|
15
|
+
// • fail-closed on malformed existing JSON (never overwrite an unparseable store);
|
|
16
|
+
// • atomic — exclusive-create *.tmp + rename (no partial-write state); last-writer-wins;
|
|
17
|
+
// • never commits.
|
|
18
|
+
//
|
|
19
|
+
// Dependency-free beyond the kit's own atomic-write core + the shared ack constants, Node >= 18. No
|
|
20
|
+
// side effects on import (the isDirectRun idiom).
|
|
21
|
+
|
|
22
|
+
import { lstatSync, readFileSync } from 'node:fs';
|
|
23
|
+
import { dirname, join, resolve } from 'node:path';
|
|
24
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
25
|
+
import { ACKS_FILE, ACKS_LANE_KEY } from './recommendations.mjs';
|
|
26
|
+
import { assertDocsAiDeployment, writeDocsAiFileAtomic, lstatNoFollow } from './atomic-write.mjs';
|
|
27
|
+
import { shellQuoteArg } from './review-state.mjs';
|
|
28
|
+
|
|
29
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
export const ACK_WRITE_TOOL = join(HERE, 'ack-write.mjs');
|
|
31
|
+
|
|
32
|
+
const ERROR_PREFIX = '[agent-workflow-kit]';
|
|
33
|
+
const EXIT_OK = 0;
|
|
34
|
+
const EXIT_PRECONDITION = 1;
|
|
35
|
+
const EXIT_USAGE = 2;
|
|
36
|
+
const JSON_INDENT = 2;
|
|
37
|
+
// The recipeFingerprint shape (recommendations.mjs: sha256 hex sliced to 16) — a fail-closed guard so
|
|
38
|
+
// the store never records a malformed or injected value.
|
|
39
|
+
export const FINGERPRINT_PATTERN = /^[0-9a-f]{16}$/u;
|
|
40
|
+
|
|
41
|
+
export const ACK_WRITE_STOP = 'ACK_WRITE_STOP';
|
|
42
|
+
export const stop = (message) =>
|
|
43
|
+
Object.assign(new Error(`${ERROR_PREFIX} ${message}`), { name: 'AckWriteStop', code: ACK_WRITE_STOP, exitCode: EXIT_PRECONDITION });
|
|
44
|
+
const usageFail = (message) => Object.assign(new Error(message), { exitCode: EXIT_USAGE });
|
|
45
|
+
|
|
46
|
+
const q = shellQuoteArg;
|
|
47
|
+
|
|
48
|
+
// Read + parse the existing store (already known to be a regular file). ENOENT (a TOCTOU vanish) is
|
|
49
|
+
// treated as absent; malformed JSON / a non-object root FAILS CLOSED — never overwrite an unparseable
|
|
50
|
+
// store.
|
|
51
|
+
const readExistingStore = (absPath, deps) => {
|
|
52
|
+
const readFile = deps.readFile ?? readFileSync;
|
|
53
|
+
let text;
|
|
54
|
+
try {
|
|
55
|
+
text = readFile(absPath, 'utf8');
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (err?.code === 'ENOENT') return {};
|
|
58
|
+
throw stop(`cannot read ${ACKS_FILE} (${err?.code ?? err?.message}) — refusing to overwrite it`);
|
|
59
|
+
}
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(text);
|
|
63
|
+
} catch {
|
|
64
|
+
throw stop(`${ACKS_FILE} is not valid JSON — refusing to overwrite it (fix or delete it, then re-run)`);
|
|
65
|
+
}
|
|
66
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
67
|
+
throw stop(`${ACKS_FILE} is not a JSON object — refusing to overwrite it`);
|
|
68
|
+
}
|
|
69
|
+
return parsed;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Target gate: a symlinked / non-regular acks.json is a STOP (checked BEFORE any read so a FIFO can
|
|
73
|
+
// never block the reader). Returns { existed, existing } — the merge base.
|
|
74
|
+
const preflightTarget = (absPath, deps) => {
|
|
75
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
76
|
+
const st = lstatNoFollow(absPath, lstat);
|
|
77
|
+
if (st === null) return { existed: false, existing: {} };
|
|
78
|
+
if (st.isSymbolicLink()) throw stop(`${ACKS_FILE} is a symlink — refusing to write through it`);
|
|
79
|
+
if (!st.isFile()) throw stop(`${ACKS_FILE} exists but is not a regular file — refusing to touch it`);
|
|
80
|
+
return { existed: true, existing: readExistingStore(absPath, deps) };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Pure preflight (both dry-run and apply). Validates the fingerprint, refuses an absent deployment,
|
|
84
|
+
// gates the target, and computes the merge — no writes.
|
|
85
|
+
export const planAckWrite = ({ cwd, fingerprint }, deps = {}) => {
|
|
86
|
+
// typeof BEFORE RegExp.test — .test() coerces its arg to a string, so a number or a single-element
|
|
87
|
+
// array of 16 hex chars would otherwise pass the guard and be written as a non-string ack the
|
|
88
|
+
// reader then ignores.
|
|
89
|
+
if (typeof fingerprint !== 'string' || !FINGERPRINT_PATTERN.test(fingerprint)) {
|
|
90
|
+
throw usageFail(`--fingerprint must be a 16-char lowercase hex fingerprint (got ${JSON.stringify(fingerprint ?? null)})`);
|
|
91
|
+
}
|
|
92
|
+
const root = resolve(cwd);
|
|
93
|
+
assertDocsAiDeployment(root, deps, { stop, noun: 'the neutral sandbox-lane ack', rel: ACKS_FILE });
|
|
94
|
+
const absPath = join(root, ACKS_FILE);
|
|
95
|
+
const { existed, existing } = preflightTarget(absPath, deps);
|
|
96
|
+
const alreadyAcked = existing[ACKS_LANE_KEY] === fingerprint;
|
|
97
|
+
const merged = { ...existing, [ACKS_LANE_KEY]: fingerprint };
|
|
98
|
+
const otherKeys = Object.keys(existing).filter((k) => k !== ACKS_LANE_KEY);
|
|
99
|
+
return { root, absPath, fingerprint, existed, existing, merged, alreadyAcked, otherKeys };
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const writeAck = ({ cwd, fingerprint, dryRun = true } = {}, deps = {}) => {
|
|
103
|
+
const plan = planAckWrite({ cwd, fingerprint }, deps);
|
|
104
|
+
if (dryRun) return { wrote: false, dryRun: true, ...plan };
|
|
105
|
+
const body = `${JSON.stringify(plan.merged, null, JSON_INDENT)}\n`;
|
|
106
|
+
writeDocsAiFileAtomic(plan.root, ACKS_FILE, body, deps, { stop, noun: 'the neutral sandbox-lane ack' });
|
|
107
|
+
return { wrote: true, dryRun: false, ...plan };
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// ── report ──────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
export const applyCommand = (root, fingerprint) =>
|
|
112
|
+
`node ${q(ACK_WRITE_TOOL)} --fingerprint ${fingerprint} --cwd ${q(root)} --apply`;
|
|
113
|
+
|
|
114
|
+
export const formatResult = (result) => {
|
|
115
|
+
const merge = result.otherKeys.length > 0 ? ` (merge-preserving ${result.otherKeys.length} existing key(s))` : '';
|
|
116
|
+
if (result.dryRun) {
|
|
117
|
+
if (result.alreadyAcked) {
|
|
118
|
+
return [
|
|
119
|
+
`agent-workflow ack — DRY RUN: ${ACKS_FILE} already records this recipe fingerprint (${result.fingerprint}) — nothing to do.`,
|
|
120
|
+
].join('\n');
|
|
121
|
+
}
|
|
122
|
+
return [
|
|
123
|
+
`agent-workflow ack — DRY RUN (no changes; re-run with --apply)`,
|
|
124
|
+
` - would ${result.existed ? 'set' : 'create'} ${ACKS_FILE} "${ACKS_LANE_KEY}" = "${result.fingerprint}"${merge}`,
|
|
125
|
+
` - this is a NEUTRAL recipe acknowledgement, never a security key.`,
|
|
126
|
+
` to apply: ${applyCommand(result.root, result.fingerprint)}`,
|
|
127
|
+
].join('\n');
|
|
128
|
+
}
|
|
129
|
+
return [
|
|
130
|
+
`agent-workflow ack — APPLY`,
|
|
131
|
+
` - ${ACKS_FILE}: "${ACKS_LANE_KEY}" = "${result.fingerprint}"${merge}`,
|
|
132
|
+
].join('\n');
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// ── CLI ─────────────────────────────────────────────────────────────────────────────────
|
|
136
|
+
const USAGE = `usage: ack-write --fingerprint <16-hex> [--dry-run | --apply] [--cwd <dir>] [--help]
|
|
137
|
+
|
|
138
|
+
Records the NEUTRAL sandbox-lane recipe acknowledgement into ${ACKS_FILE} (the family-owned ack
|
|
139
|
+
store — no host settings validator guards it). Default is --dry-run (a preview; writes nothing) and
|
|
140
|
+
prints the exact --apply command. --apply merges "${ACKS_LANE_KEY}" into ${ACKS_FILE}, preserving
|
|
141
|
+
every existing key. Refuses an absent docs/ai deployment; never a security key; never commits.`;
|
|
142
|
+
|
|
143
|
+
export const parseArgs = (argv) => {
|
|
144
|
+
const opts = { fingerprint: undefined, dryRunFlag: false, apply: false, cwd: undefined, help: false };
|
|
145
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
146
|
+
const arg = argv[i];
|
|
147
|
+
if (arg === '--help' || arg === '-h') opts.help = true;
|
|
148
|
+
else if (arg === '--dry-run') opts.dryRunFlag = true;
|
|
149
|
+
else if (arg === '--apply') opts.apply = true;
|
|
150
|
+
else if (arg === '--fingerprint') {
|
|
151
|
+
i += 1;
|
|
152
|
+
if (argv[i] === undefined || argv[i].startsWith('-')) throw usageFail('--fingerprint needs a value');
|
|
153
|
+
opts.fingerprint = argv[i];
|
|
154
|
+
} else if (arg === '--cwd') {
|
|
155
|
+
i += 1;
|
|
156
|
+
if (argv[i] === undefined || argv[i].startsWith('-')) throw usageFail('--cwd needs a directory argument');
|
|
157
|
+
opts.cwd = argv[i];
|
|
158
|
+
} else {
|
|
159
|
+
throw usageFail(`unknown argument: ${arg}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (opts.dryRunFlag && opts.apply) throw usageFail('--dry-run and --apply cannot be used together');
|
|
163
|
+
return { help: opts.help, fingerprint: opts.fingerprint, dryRun: !opts.apply, cwd: opts.cwd };
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
167
|
+
const log = deps.log ?? console.log;
|
|
168
|
+
const errlog = deps.errlog ?? console.error;
|
|
169
|
+
try {
|
|
170
|
+
const args = parseArgs(argv);
|
|
171
|
+
if (args.help) {
|
|
172
|
+
log(USAGE);
|
|
173
|
+
return EXIT_OK;
|
|
174
|
+
}
|
|
175
|
+
const result = writeAck({ cwd: args.cwd ?? deps.cwd ?? process.cwd(), fingerprint: args.fingerprint, dryRun: args.dryRun }, deps);
|
|
176
|
+
log(formatResult(result));
|
|
177
|
+
return EXIT_OK;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
errlog(err?.message ?? String(err));
|
|
180
|
+
if (err?.exitCode === EXIT_USAGE) errlog(USAGE);
|
|
181
|
+
return err?.exitCode ?? EXIT_PRECONDITION;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
186
|
+
if (isDirectRun) process.exit(main(process.argv.slice(2)));
|