@sabaiway/agent-workflow-kit 1.46.0 → 1.48.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 +76 -0
- package/README.md +3 -3
- package/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +6 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +6 -0
- package/bridges/antigravity-cli-bridge/capability.json +5 -2
- package/bridges/codex-cli-bridge/SKILL.md +1 -1
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +20 -0
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +50 -0
- package/bridges/codex-cli-bridge/bin/codex-review.sh +1 -1
- package/bridges/codex-cli-bridge/capability.json +5 -2
- 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 +17 -6
- package/references/modes/review-ledger.md +2 -1
- package/references/modes/upgrade.md +2 -1
- package/references/modes/velocity.md +4 -2
- package/references/shared/report-footer.md +14 -0
- package/references/templates/agent_rules.md +1 -1
- package/tools/ack-write.mjs +186 -0
- package/tools/detect-backends.mjs +6 -0
- package/tools/doc-parity.mjs +7 -0
- package/tools/gate-hook.mjs +193 -4
- package/tools/procedures.mjs +6 -1
- package/tools/recommendations.mjs +171 -17
- package/tools/review-ledger-write.mjs +112 -6
- package/tools/review-ledger.mjs +1 -1
- package/tools/velocity-profile.mjs +25 -12
|
@@ -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)));
|
|
@@ -80,6 +80,9 @@ const RAW_BACKENDS = [
|
|
|
80
80
|
blocked: ['-c*', '--config*', '-s*', '--sandbox*', '--dangerously-bypass-approvals-and-sandbox', '--dangerously-bypass-hook-trust', '--full-auto', '--oss', '--local-provider*', '-p*', '--profile*', '-m*', '--model*', '-o*', '--output-last-message*', '--json*', '--color*', '--output-schema*', '--ephemeral*'],
|
|
81
81
|
probeRelaxed: ['--add-dir*', '-C*', '--cd*', '--skip-git-repo-check', '--ignore-rules', '--enable*', '--disable*'],
|
|
82
82
|
},
|
|
83
|
+
notes: [
|
|
84
|
+
'nested-sandbox limit: codex-exec ships its OWN OS sandbox (bwrap workspace-write) and cannot run nested inside a harness sandbox (the FS turns read-only) — route it OUTSIDE the harness sandbox (excludedCommands / a per-run consented bypass) on the OBSERVED bwrap/EPERM failure, never a preemptive blanket',
|
|
85
|
+
],
|
|
83
86
|
},
|
|
84
87
|
review: {
|
|
85
88
|
invocations: [
|
|
@@ -122,6 +125,9 @@ const RAW_BACKENDS = [
|
|
|
122
125
|
'agy-review --conversation <id> [--decided @f] [--focus "…"]',
|
|
123
126
|
],
|
|
124
127
|
receipt: "side effect — a successful review appends one JSON receipt line to <git dir>/agent-workflow-review-receipts.jsonl (AW_REVIEW_RECEIPTS overrides; plan/diff outside a git tree: warn + skip unless overridden): fingerprint = sha256 over the canonical uncommitted-state payload (staged diff + unstaged diff + untracked-not-ignored contents — the review-payload domain; never-committable untracked paths — character/block devices, FIFOs, sockets — are excluded from the domain entirely, untracked symlinks/directories ride as name-only notes) in code mode, the artifact-file sha256 in plan/diff mode; verdict recorded verbatim from the mandated '### Verdict' section (SHIP / SHIP WITH NITS / REWORK); grounded = whether a NON-EMPTY --facts payload was supplied (an empty payload records grounded:false — fail-closed, the state gate rejects it), factsHash = sha256 of the facts payload; a continuation receipt is fresh:false (informational-only — it cannot attest the folded tree); a write failure warns, never fails the review",
|
|
128
|
+
notes: [
|
|
129
|
+
'pre-dispatch host-diff: before the FIRST dispatch of this bridge, diff its declared networkHosts against the live sandbox allow-list — a missing host is surfaced to the maintainer BEFORE dispatching, never fired into a known prompt',
|
|
130
|
+
],
|
|
125
131
|
},
|
|
126
132
|
},
|
|
127
133
|
bin: 'agy',
|
package/tools/doc-parity.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
VERDICT_NOTHING_BROKEN,
|
|
35
35
|
VERDICT_OPTIONAL_TEMPLATE,
|
|
36
36
|
VERDICT_SKIPS_TEMPLATE,
|
|
37
|
+
ACKS_FILE,
|
|
37
38
|
} from './recommendations.mjs';
|
|
38
39
|
|
|
39
40
|
const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -43,6 +44,7 @@ const FOLD_DOC = 'references/modes/fold-completeness.md';
|
|
|
43
44
|
const AUTONOMY_DOCTOR_DOC = 'references/modes/autonomy-doctor.md';
|
|
44
45
|
const RECOMMENDATIONS_DOC = 'references/modes/recommendations.md';
|
|
45
46
|
const UPGRADE_DOC = 'references/modes/upgrade.md';
|
|
47
|
+
const VELOCITY_DOC = 'references/modes/velocity.md';
|
|
46
48
|
|
|
47
49
|
// A typed usage failure (exit 2) for the CLI parser — the codebase's typed-error idiom (no classes).
|
|
48
50
|
const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 });
|
|
@@ -92,6 +94,11 @@ export const BINDINGS = Object.freeze([
|
|
|
92
94
|
valueBinding('verdict-nothing-broken', VERDICT_NOTHING_BROKEN, VERDICT_NOTHING_BROKEN, [RECOMMENDATIONS_DOC, UPGRADE_DOC]),
|
|
93
95
|
valueBinding('verdict-optional', VERDICT_OPTIONAL_TEMPLATE, VERDICT_OPTIONAL_TEMPLATE, [RECOMMENDATIONS_DOC, UPGRADE_DOC]),
|
|
94
96
|
valueBinding('verdict-skips', VERDICT_SKIPS_TEMPLATE, VERDICT_SKIPS_TEMPLATE, [RECOMMENDATIONS_DOC, UPGRADE_DOC]),
|
|
97
|
+
// The ack-store apply target (AD-055 Part I): the family-owned docs/ai/acks.json path — a
|
|
98
|
+
// drift-guarded constant so the mode docs' ack-store references cannot silently outdate the code
|
|
99
|
+
// (the incident's "mode-doc apply text stays in lockstep" acceptance as a mechanism, not prose).
|
|
100
|
+
// Bound in BOTH docs that name the path (recommendations.md + velocity.md).
|
|
101
|
+
valueBinding('acks-file', ACKS_FILE, ACKS_FILE, [RECOMMENDATIONS_DOC, VELOCITY_DOC]),
|
|
95
102
|
].map((b) => Object.freeze(b)));
|
|
96
103
|
|
|
97
104
|
// ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
|
package/tools/gate-hook.mjs
CHANGED
|
@@ -31,8 +31,12 @@
|
|
|
31
31
|
// preserved-customization discipline — no refresh-in-place, no marker/checksum machinery);
|
|
32
32
|
// • never writes settings.local.json; never commits.
|
|
33
33
|
//
|
|
34
|
+
// The `--read-lane` flag is a SEPARATE operation (AD-055 Part II): it enables the opt-in read-only
|
|
35
|
+
// compound lane in docs/ai/lanes.json after a currency check, and never touches settings or
|
|
36
|
+
// gates.json — see the read-lane writer section below.
|
|
37
|
+
//
|
|
34
38
|
// Exit codes: 0 done / dry-run (incl. the report-only diverged-but-wired state); 1 precondition
|
|
35
|
-
// STOP; 2 usage. Dependency-free beyond the kit's own
|
|
39
|
+
// STOP; 2 usage. Dependency-free beyond the kit's own internal exports, Node >= 18. No side
|
|
36
40
|
// effects on import.
|
|
37
41
|
|
|
38
42
|
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -49,8 +53,13 @@ import {
|
|
|
49
53
|
readSettingsFile,
|
|
50
54
|
resolveEffectiveMode,
|
|
51
55
|
} from './velocity-profile.mjs';
|
|
56
|
+
import { assertDocsAiDeployment, writeDocsAiFileAtomic } from './atomic-write.mjs';
|
|
57
|
+
import { shellQuoteArg } from './review-state.mjs';
|
|
52
58
|
|
|
53
59
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
60
|
+
// This tool's own absolute path — the recovery / apply one-liners the read-lane writer prints.
|
|
61
|
+
export const GATE_HOOK_TOOL = fileURLToPath(import.meta.url);
|
|
62
|
+
const q = shellQuoteArg;
|
|
54
63
|
|
|
55
64
|
export const BUNDLED_HOOK_PATH = resolve(HERE, '..', 'references', 'hooks', 'gate-approve.mjs');
|
|
56
65
|
export const HOOKS_DIR = '.claude/hooks';
|
|
@@ -68,6 +77,11 @@ export const GATE_HOOK_UNSAFE_MODE = 'GATE_HOOK_UNSAFE_MODE';
|
|
|
68
77
|
export const GATE_HOOK_MALFORMED = 'GATE_HOOK_MALFORMED';
|
|
69
78
|
export const GATE_HOOK_DIVERGED = 'GATE_HOOK_DIVERGED';
|
|
70
79
|
export const GATE_HOOK_BUNDLE = 'GATE_HOOK_BUNDLE';
|
|
80
|
+
// --read-lane (AD-055 Part II): the opt-in read-only compound lane toggle + its currency guard.
|
|
81
|
+
export const LANES_REL = 'docs/ai/lanes.json';
|
|
82
|
+
export const READ_LANE_KEY = 'readLane';
|
|
83
|
+
export const GATE_HOOK_STALE = 'GATE_HOOK_STALE'; // the placed hook is absent/stale — the lane cannot fire
|
|
84
|
+
export const GATE_HOOK_LANE = 'GATE_HOOK_LANE'; // a lanes.json write refusal (deployment/malformed)
|
|
71
85
|
|
|
72
86
|
const EXIT_OK = 0;
|
|
73
87
|
const EXIT_PRECONDITION = 1;
|
|
@@ -84,12 +98,21 @@ const TARGET_CURRENT = 'already-current';
|
|
|
84
98
|
const TARGET_DIVERGED = 'diverged';
|
|
85
99
|
|
|
86
100
|
const USAGE = `usage: gate-hook [--dry-run | --apply] [--cwd <dir>] [--help]
|
|
101
|
+
gate-hook --read-lane [--dry-run | --apply] [--cwd <dir>] (enable the opt-in read-only compound lane)
|
|
87
102
|
|
|
88
103
|
Places the bundled PreToolUse gate-approval hook to ${HOOK_FILE_REL} and wires ONE
|
|
89
104
|
PreToolUse "Bash" entry into ${SETTINGS_FILE}. Default is --dry-run (a preview; writes
|
|
90
105
|
nothing). --apply writes. The hook auto-approves byte-exact declared gate cmds
|
|
91
106
|
(docs/ai/gates.json, project root) and asks on seeded-read-only commands carrying the
|
|
92
|
-
documented runtime residual.
|
|
107
|
+
documented runtime residual.
|
|
108
|
+
|
|
109
|
+
--read-lane enables the opt-in read-only COMPOUND lane in ${LANES_REL} ("${READ_LANE_KEY}": true) — the
|
|
110
|
+
hook then auto-approves compounds of the seeded read-only core carrying zero shell metaprogramming.
|
|
111
|
+
--apply --read-lane verifies the PLACED hook is the CURRENT bundle first (a pre-1.48 hook never reads
|
|
112
|
+
${LANES_REL}) and refuses with the delete-to-reseed recovery otherwise. Never touches ${SETTINGS_FILE}
|
|
113
|
+
or docs/ai/gates.json.
|
|
114
|
+
|
|
115
|
+
Never writes ${SETTINGS_LOCAL_FILE}; never commits.`;
|
|
93
116
|
|
|
94
117
|
export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
|
|
95
118
|
|
|
@@ -307,6 +330,166 @@ export const writeGateHook = ({ cwd, dryRun = true } = {}, deps = {}) => {
|
|
|
307
330
|
return { wrote: placePlanned || wirePlanned, dryRun: false, ...base };
|
|
308
331
|
};
|
|
309
332
|
|
|
333
|
+
// ── the read-lane writer (--read-lane; AD-055 Part II, Decisions 5/9) ──────────────────
|
|
334
|
+
// Enables the opt-in read-only COMPOUND lane by writing { "readLane": true } into the kit-owned
|
|
335
|
+
// docs/ai/lanes.json (a SEPARATE file — gates.json is never touched). Same family writer discipline
|
|
336
|
+
// as the base flow + ack-write (preview-then-mutate, deployment-gated, create-if-absent,
|
|
337
|
+
// merge-preserve, symlink/non-regular refusal, malformed-JSON fail-closed, atomic), PLUS a CURRENCY
|
|
338
|
+
// CHECK (Decisions 9): --apply refuses unless the PLACED hook is byte-identical to the current
|
|
339
|
+
// bundle — enabling a lane a pre-1.48 hook can never read would be a silent no-op the user paid
|
|
340
|
+
// consent for. The refusal names the delete-to-reseed recovery.
|
|
341
|
+
|
|
342
|
+
const laneStop = (message) => makeGateHookError(GATE_HOOK_LANE, message);
|
|
343
|
+
|
|
344
|
+
// Read the existing lanes.json (already gated as a regular file). ENOENT (a TOCTOU vanish) → absent
|
|
345
|
+
// {}; malformed JSON / a non-object root FAILS CLOSED — never overwrite an unparseable toggle file.
|
|
346
|
+
const readExistingLanes = (absPath, deps) => {
|
|
347
|
+
const readFile = deps.readFile ?? readFileSync;
|
|
348
|
+
let text;
|
|
349
|
+
try {
|
|
350
|
+
text = readFile(absPath, UTF8);
|
|
351
|
+
} catch (err) {
|
|
352
|
+
if (err?.code === 'ENOENT') return {};
|
|
353
|
+
throw laneStop(`cannot read ${LANES_REL} (${err?.code ?? err?.message}) — refusing to overwrite it`);
|
|
354
|
+
}
|
|
355
|
+
let parsed;
|
|
356
|
+
try {
|
|
357
|
+
parsed = JSON.parse(text);
|
|
358
|
+
} catch {
|
|
359
|
+
throw laneStop(`${LANES_REL} is not valid JSON — refusing to overwrite it (fix or delete it, then re-run)`);
|
|
360
|
+
}
|
|
361
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
362
|
+
throw laneStop(`${LANES_REL} is not a JSON object — refusing to overwrite it`);
|
|
363
|
+
}
|
|
364
|
+
return parsed;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// Target gate: a symlinked / non-regular lanes.json is a STOP checked BEFORE any read (a FIFO can
|
|
368
|
+
// never block the reader). Returns { existed, existing } — the merge base.
|
|
369
|
+
const preflightLaneTarget = (absPath, deps) => {
|
|
370
|
+
const fs = fsDeps(deps);
|
|
371
|
+
const st = lstatNoFollow(absPath, fs);
|
|
372
|
+
if (st === null) return { existed: false, existing: {} };
|
|
373
|
+
if (st.isSymbolicLink()) throw makeGateHookError(GATE_HOOK_SYMLINK, `${LANES_REL} is a symlink — refusing to write through it`);
|
|
374
|
+
if (!st.isFile()) throw makeGateHookError(GATE_HOOK_SYMLINK, `${LANES_REL} exists but is not a regular file — refusing to touch it`);
|
|
375
|
+
return { existed: true, existing: readExistingLanes(absPath, deps) };
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
export const applyGateHookCommand = (root) => `node ${q(GATE_HOOK_TOOL)} --apply --cwd ${q(root)}`;
|
|
379
|
+
export const applyReadLaneCommand = (root) => `node ${q(GATE_HOOK_TOOL)} --read-lane --cwd ${q(root)} --apply`;
|
|
380
|
+
|
|
381
|
+
// The currency check (Decisions 9): the PLACED hook must be byte-identical to the current bundle,
|
|
382
|
+
// else enabling the lane is a no-op. Returns { current, reason, refusal? }; a symlinked / non-regular
|
|
383
|
+
// placed hook is a hard STOP.
|
|
384
|
+
const checkHookCurrency = (root, bundleContent, fs) => {
|
|
385
|
+
const hookAbs = join(root, HOOK_FILE_REL);
|
|
386
|
+
const st = lstatNoFollow(hookAbs, fs);
|
|
387
|
+
if (st === null) {
|
|
388
|
+
return {
|
|
389
|
+
current: false,
|
|
390
|
+
reason: `${HOOK_FILE_REL} is not placed`,
|
|
391
|
+
refusal: `${HOOK_FILE_REL} is not placed — the read-lane cannot fire. Place the hook first:\n ${applyGateHookCommand(root)}\nthen re-run with --read-lane.`,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
if (st.isSymbolicLink() || !st.isFile()) {
|
|
395
|
+
throw makeGateHookError(GATE_HOOK_SYMLINK, `${HOOK_FILE_REL} is not a regular file — refusing to touch it`);
|
|
396
|
+
}
|
|
397
|
+
if (fs.readFile(hookAbs, UTF8) !== bundleContent) {
|
|
398
|
+
return {
|
|
399
|
+
current: false,
|
|
400
|
+
reason: `${HOOK_FILE_REL} is not the current bundle`,
|
|
401
|
+
refusal: `${HOOK_FILE_REL} is NOT the current bundle — enabling the read-lane now would be a silent no-op (a pre-1.48 hook never reads ${LANES_REL}). Reseed it, then re-run with --read-lane:\n rm ${q(join(root, HOOK_FILE_REL))}\n ${applyGateHookCommand(root)}`,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
return { current: true, reason: 'the placed hook is the current bundle' };
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
// Pure preflight (both dry-run and apply): deployment gate + the merge over the existing toggle file.
|
|
408
|
+
export const planReadLane = ({ cwd }, deps = {}) => {
|
|
409
|
+
const root = resolve(cwd);
|
|
410
|
+
assertDocsAiDeployment(root, deps, { stop: laneStop, noun: 'the read-lane toggle', rel: LANES_REL });
|
|
411
|
+
const absPath = join(root, LANES_REL);
|
|
412
|
+
const { existed, existing } = preflightLaneTarget(absPath, deps);
|
|
413
|
+
const already = existing[READ_LANE_KEY] === true;
|
|
414
|
+
const merged = { ...existing, [READ_LANE_KEY]: true };
|
|
415
|
+
const otherKeys = Object.keys(existing).filter((k) => k !== READ_LANE_KEY);
|
|
416
|
+
return { root, absPath, existed, existing, merged, already, otherKeys };
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
// A byte-current hook that is not WIRED never fires — enabling the lane against it is the same
|
|
420
|
+
// silent no-op the currency check guards (council B5). Wired-detection reuses the writer's own
|
|
421
|
+
// isHookWired over BOTH settings scopes (the hooks contract merges both).
|
|
422
|
+
const checkHookWired = (root, deps) => {
|
|
423
|
+
const project = readSettingsFile(join(root, SETTINGS_FILE), { ...deps, cwd: root });
|
|
424
|
+
const local = readSettingsFile(join(root, SETTINGS_LOCAL_FILE), { ...deps, cwd: root });
|
|
425
|
+
return isHookWired(project.data) || isHookWired(local.data);
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
export const writeReadLane = ({ cwd, dryRun = true } = {}, deps = {}) => {
|
|
429
|
+
const fs = fsDeps(deps);
|
|
430
|
+
const bundleContent = readBundledHook(deps);
|
|
431
|
+
const plan = planReadLane({ cwd: cwd ?? deps.cwd ?? process.cwd() }, deps);
|
|
432
|
+
const currency = checkHookCurrency(plan.root, bundleContent, fs);
|
|
433
|
+
const wired = checkHookWired(plan.root, deps);
|
|
434
|
+
const stamp = readStamp(join(plan.root, WORKFLOW_STAMP), fs);
|
|
435
|
+
const stampOk = stamp === EXPECTED_WORKFLOW_VERSION;
|
|
436
|
+
if (dryRun) return { wrote: false, dryRun: true, currency, wired, stamp, stampOk, ...plan };
|
|
437
|
+
if (!currency.current) throw makeGateHookError(GATE_HOOK_STALE, currency.refusal);
|
|
438
|
+
if (!wired) {
|
|
439
|
+
throw makeGateHookError(
|
|
440
|
+
GATE_HOOK_STALE,
|
|
441
|
+
`${HOOK_FILE_REL} is placed and current but NOT wired into ${SETTINGS_FILE} — the read-lane cannot fire. Wire it first:\n ${applyGateHookCommand(plan.root)}\nthen re-run with --read-lane.`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
if (!stampOk) {
|
|
445
|
+
throw makeGateHookError(
|
|
446
|
+
GATE_HOOK_STAMP,
|
|
447
|
+
`not a deployed agent-workflow project at lineage ${EXPECTED_WORKFLOW_VERSION} (found ${stamp ?? 'none'}) — run init/upgrade first`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
if (plan.already) return { wrote: false, dryRun: false, currency, wired, stamp, stampOk, ...plan };
|
|
451
|
+
const body = `${JSON.stringify(plan.merged, null, SETTINGS_JSON_INDENT)}\n`;
|
|
452
|
+
writeDocsAiFileAtomic(plan.root, LANES_REL, body, deps, { stop: laneStop, noun: 'the read-lane toggle' });
|
|
453
|
+
return { wrote: true, dryRun: false, currency, wired, stamp, stampOk, ...plan };
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const READ_LANE_POSTURE =
|
|
457
|
+
"posture: an auto-allowed 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. The subset invariant holds against that AUDITED CORE (every segment is an audited-core read), so the lane is a standalone opt-in grant BOUNDED BY the audited core — never a command outside it; it auto-approves those core commands REGARDLESS of which you seeded as individual settings rules (not strictly a subset of your current settings). This consent is a PROJECT-PERSISTENT declaration in docs/ai/lanes.json — it applies to every future session and to subagents' Bash, and where docs/ai is committed, to every checkout. It bypasses the PROMPT only, never the OS sandbox; on engine builds where a hook allow supersedes an ask rule, the opt-in covers EXACTLY the audited read-only core — nothing else.";
|
|
458
|
+
|
|
459
|
+
export const formatReadLaneResult = (result) => {
|
|
460
|
+
const merge = result.otherKeys.length > 0 ? ` (merge-preserving ${result.otherKeys.length} existing key(s))` : '';
|
|
461
|
+
if (!result.dryRun) {
|
|
462
|
+
if (result.already) {
|
|
463
|
+
return `agent-workflow read-lane — APPLY: ${LANES_REL} already enables the read-lane — nothing to do (hook currency verified).`;
|
|
464
|
+
}
|
|
465
|
+
return [
|
|
466
|
+
'agent-workflow read-lane — APPLY',
|
|
467
|
+
` - ${LANES_REL}: "${READ_LANE_KEY}" = true${merge}`,
|
|
468
|
+
' - hook currency verified: the placed hook is the current bundle — the lane is active for new Bash calls.',
|
|
469
|
+
].join(LF);
|
|
470
|
+
}
|
|
471
|
+
const lines = [
|
|
472
|
+
result.already
|
|
473
|
+
? `agent-workflow read-lane — DRY RUN: ${LANES_REL} already enables the read-lane ("${READ_LANE_KEY}": true) — nothing to do.`
|
|
474
|
+
: 'agent-workflow read-lane — DRY RUN (no changes; re-run with --apply --read-lane)',
|
|
475
|
+
];
|
|
476
|
+
if (!result.already) lines.push(` - would ${result.existed ? 'set' : 'create'} ${LANES_REL} "${READ_LANE_KEY}" = true${merge}`);
|
|
477
|
+
lines.push(
|
|
478
|
+
result.currency.current
|
|
479
|
+
? ' hook currency: current — the lane will fire once enabled.'
|
|
480
|
+
: ` hook currency: STALE — ${result.currency.reason}; --apply will REFUSE until you reseed the placed hook.`,
|
|
481
|
+
);
|
|
482
|
+
if (!result.wired) {
|
|
483
|
+
lines.push(` hook wiring: NOT wired into ${SETTINGS_FILE}; --apply will REFUSE until you wire it (${applyGateHookCommand(result.root)}).`);
|
|
484
|
+
}
|
|
485
|
+
if (!result.stampOk) {
|
|
486
|
+
lines.push(` deployment stamp: ${result.stamp ?? 'none'} ≠ lineage head ${EXPECTED_WORKFLOW_VERSION}; --apply will REFUSE until init/upgrade runs.`);
|
|
487
|
+
}
|
|
488
|
+
lines.push(READ_LANE_POSTURE);
|
|
489
|
+
if (!result.already) lines.push(` to apply: ${applyReadLaneCommand(result.root)}`);
|
|
490
|
+
return lines.join(LF);
|
|
491
|
+
};
|
|
492
|
+
|
|
310
493
|
// ── report ────────────────────────────────────────────────────────────────────────────
|
|
311
494
|
|
|
312
495
|
const TRUST_POSTURE_LINE =
|
|
@@ -346,12 +529,13 @@ export const formatResult = (result) => {
|
|
|
346
529
|
// ── CLI ───────────────────────────────────────────────────────────────────────────────
|
|
347
530
|
|
|
348
531
|
export const parseArgs = (argv) => {
|
|
349
|
-
const opts = { dryRunFlag: false, apply: false, cwd: undefined, help: false };
|
|
532
|
+
const opts = { dryRunFlag: false, apply: false, cwd: undefined, help: false, readLane: false };
|
|
350
533
|
for (let i = 0; i < argv.length; i += 1) {
|
|
351
534
|
const arg = argv[i];
|
|
352
535
|
if (arg === '--help' || arg === '-h') opts.help = true;
|
|
353
536
|
else if (arg === '--dry-run') opts.dryRunFlag = true;
|
|
354
537
|
else if (arg === '--apply') opts.apply = true;
|
|
538
|
+
else if (arg === '--read-lane') opts.readLane = true;
|
|
355
539
|
else if (arg === '--cwd') {
|
|
356
540
|
i += 1;
|
|
357
541
|
if (argv[i] === undefined || argv[i].startsWith('-')) throw fail(EXIT_USAGE, '--cwd needs a directory argument');
|
|
@@ -361,7 +545,7 @@ export const parseArgs = (argv) => {
|
|
|
361
545
|
}
|
|
362
546
|
}
|
|
363
547
|
if (opts.dryRunFlag && opts.apply) throw fail(EXIT_USAGE, '--dry-run and --apply cannot be used together');
|
|
364
|
-
return { help: opts.help, dryRun: !opts.apply, cwd: opts.cwd };
|
|
548
|
+
return { help: opts.help, dryRun: !opts.apply, cwd: opts.cwd, readLane: opts.readLane };
|
|
365
549
|
};
|
|
366
550
|
|
|
367
551
|
export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
@@ -373,6 +557,11 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
|
373
557
|
log(USAGE);
|
|
374
558
|
return EXIT_OK;
|
|
375
559
|
}
|
|
560
|
+
if (args.readLane) {
|
|
561
|
+
const laneResult = writeReadLane({ cwd: args.cwd ?? deps.cwd ?? process.cwd(), dryRun: args.dryRun }, deps);
|
|
562
|
+
log(formatReadLaneResult(laneResult));
|
|
563
|
+
return EXIT_OK;
|
|
564
|
+
}
|
|
376
565
|
const result = writeGateHook({ cwd: args.cwd ?? deps.cwd ?? process.cwd(), dryRun: args.dryRun }, deps);
|
|
377
566
|
log(formatResult(result));
|
|
378
567
|
return EXIT_OK;
|
package/tools/procedures.mjs
CHANGED
|
@@ -296,8 +296,9 @@ const costLanesAdvice = () => [
|
|
|
296
296
|
' • L2 subscription bridge (codex / agy) — reviews per the resolved recipe above, on frontier bridge models (quality-first).',
|
|
297
297
|
' • L3 frontier — judgment: plan/fold/synthesis, ADR/handover/changelog-entry wording, persuasive copy, go/no-go, real code.',
|
|
298
298
|
' • A step with no named guardrail does not move down a lane; red lines never move down (council review models · real code · memory/copy wording · the maintainer approval asks).',
|
|
299
|
-
' • Sandbox lanes (under an OS sandbox): the L0 surfaces are sandbox-safe — gates/ledger/state/fold checks, git reads, plain no-network tests; the bridge wrappers are genuinely unsandboxed (network); npm-cache-touching commands are COMMAND-SHAPE dependent — first try the sandbox-safe shape (cache under $TMPDIR, offline/notifier off). Move ONLY the failing command out of the sandbox, never its class; BATCH consecutive unsandboxed calls.',
|
|
299
|
+
' • Sandbox lanes (under an OS sandbox): the L0 surfaces are sandbox-safe — gates/ledger/state/fold checks, git reads, plain no-network tests; the bridge wrappers are genuinely unsandboxed (network); npm-cache-touching commands are COMMAND-SHAPE dependent — first try the sandbox-safe shape (cache under $TMPDIR, offline/notifier off). Move ONLY the failing command out of the sandbox, never its class; BATCH consecutive unsandboxed calls. Pre-dispatch host-diff: before the FIRST dispatch of each bridge, diff its manifest networkHosts against the live sandbox allow-list — a missing host is surfaced to the maintainer BEFORE dispatching, never fired into a known prompt. Nested-sandbox honesty: a backend CLI shipping its OWN OS sandbox cannot run nested inside a harness sandbox — route it outside (excludedCommands / a per-run consented bypass) on the OBSERVED failure, never a preemptive blanket.',
|
|
300
300
|
' • Prompt economy (autonomy-preserving dispatch): read-only fan-out (research/sweeps/extraction) runs ONLY on restricted-tool vehicles — a full-tool subagent for read-only work is a forbidden lane downgrade (invisible prompt-flood + blast radius), and a subagent is never told to shell out for facts obtainable read-only; the orchestrator\'s own shell form is ONE plain pipeline per call (a ;/&& chain or env-prefixed invocation never matches a prefix allow rule); a fan-out LAUNCHER that gates per call yields to the agent-spawn lane. Capability-gated: on a host with restricted-tool subagent vehicles use them; without restricted-tool vehicles (a host offering only full-tool agents included), read-only research stays in the orchestrator\'s own context — never a vehicle mandate a host cannot satisfy.',
|
|
301
|
+
' • Writer economy (autonomy-preserving dispatch): a stage\'s repeated WRITER commands batch into ONE invocation — combine a stage\'s writers via one batched write or one launcher per stage; never one writer call at a time (each write is its own prompt).',
|
|
301
302
|
' • The prompt-economy clause narrows TOOLS for read-only work only — judgment, code, synthesis stay at the frontier lane; a task that genuinely needs to run or write keeps a full-tool subagent. Honest limit: no deterministic gate classifies a dispatch — enforcement is the canon at the point of use + the placed vehicles + the retro loop.',
|
|
302
303
|
];
|
|
303
304
|
|
|
@@ -320,6 +321,10 @@ const contractLines = ({ cmd, contract, settings }) => {
|
|
|
320
321
|
if (contract.passthrough) {
|
|
321
322
|
lines.push(` passthrough after '--' is ${contract.passthrough.policy}: blocked always: ${contract.passthrough.blocked.join(' ')}; relaxed only under CODEX_PROBE=1: ${contract.passthrough.probeRelaxed.join(' ')}`);
|
|
322
323
|
}
|
|
324
|
+
// Contract notes — a TYPED renderer (AD-054): a manifest note MUST render here, else adding a
|
|
325
|
+
// notes[] key would silently disappear (the manifest validator tolerates unknown keys). Drift-
|
|
326
|
+
// guarded both directions against the manifest AND each wrapper's --help.
|
|
327
|
+
for (const note of contract.notes ?? []) lines.push(` note: ${note}`);
|
|
323
328
|
// Host-level settings knobs this wrapper honors (fact-only, from the bundled manifests; explicit
|
|
324
329
|
// branch — contractLines drops any contract key it does not name, so this must be enumerated here).
|
|
325
330
|
if ((settings ?? []).length) {
|