@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
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
// below) and VERDICT-FIRST (D1): every non-optimal state opens with ONE composed verdict line.
|
|
20
20
|
// Registry strings are frozen tool DATA, fact-true, one line under the shape cap (D2); posture/
|
|
21
21
|
// risk prose lives in the mode doc at the consent moment (D3). A probe failure is a stated
|
|
22
|
-
// skipped-item line — never a crash, never a fabricated item. The
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// fingerprint-bound acknowledgement
|
|
22
|
+
// skipped-item line — never a crash, never a fabricated item. The kit never seeds
|
|
23
|
+
// sandbox.network.allowedDomains / filesystem.allowWrite (HAND-APPLY territory; bridge council
|
|
24
|
+
// 2026-07-11, both backends concur); the sandbox-lane item's convergence is a NEUTRAL
|
|
25
|
+
// fingerprint-bound acknowledgement recorded by the consent-gated ack writer into the family-owned
|
|
26
|
+
// docs/ai/acks.json (AD-055 relocated it off the host settings schema), never a security key (D4).
|
|
26
27
|
//
|
|
27
28
|
// Read-only: never writes, never commits, never runs a subscription CLI. The reused probes are all
|
|
28
29
|
// exported read-only surfaces of their owning tools (velocity/autonomy/doctor/backends/recipes/
|
|
@@ -55,6 +56,7 @@ import { probeSandboxMasks, needsMasksApply } from './sandbox-masks.mjs';
|
|
|
55
56
|
import { shellQuoteArg } from './review-state.mjs';
|
|
56
57
|
import { loadConfig } from './orchestration-config.mjs';
|
|
57
58
|
import { DEFAULT_BUNDLE_ROOT, SETTINGS_FILENAME, settingsPath, parseSettings, duplicateKeys } from './bridge-settings-read.mjs';
|
|
59
|
+
import { assertContainedRealPath } from './fs-safe.mjs';
|
|
58
60
|
|
|
59
61
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
60
62
|
const toolPath = (rel) => join(HERE, rel);
|
|
@@ -95,6 +97,9 @@ export const SEVERITIES = Object.freeze({
|
|
|
95
97
|
'review-recipe': SEVERITY_ATTENTION,
|
|
96
98
|
'gates-declaration': SEVERITY_OPTIONAL,
|
|
97
99
|
'gate-hook': SEVERITY_OPTIONAL,
|
|
100
|
+
'read-lane': SEVERITY_OPTIONAL,
|
|
101
|
+
'read-lane.stale': SEVERITY_ATTENTION,
|
|
102
|
+
'read-lane.missing': SEVERITY_ATTENTION,
|
|
98
103
|
'family-freshness': SEVERITY_ATTENTION,
|
|
99
104
|
'sandbox-masks': SEVERITY_OPTIONAL,
|
|
100
105
|
'agy-adddir': SEVERITY_OPTIONAL,
|
|
@@ -147,6 +152,9 @@ export const WHATS = Object.freeze({
|
|
|
147
152
|
'review-recipe': '{degraded}',
|
|
148
153
|
'gates-declaration': 'no declared gate matrix (docs/ai/gates.json absent or empty) — gates prompt one by one; the apply PREVIEWS its --apply line, writes nothing',
|
|
149
154
|
'gate-hook': '{n} declared gate(s) prompt per run — the gate-approval hook is not wired',
|
|
155
|
+
'read-lane': 'the gate hook is wired but the read-only compound lane is off — pipes/chains of seeded reads still prompt one by one',
|
|
156
|
+
'read-lane.stale': 'the read-lane is ON but the placed gate hook is stale — an old hook never reads lanes.json, so the lane is silently dark; reseed it',
|
|
157
|
+
'read-lane.missing': 'the gate hook is wired but its placed file is missing — every Bash call errors and the read-lane is dark; re-place it',
|
|
150
158
|
'family-freshness': '{parts}',
|
|
151
159
|
'sandbox-masks': '{n} sandbox device mask(s) clutter git status — the managed exclude block is absent or stale',
|
|
152
160
|
'sandbox-masks.stale-real': '{n} sandbox device mask(s) clutter git status — the exclude block is stale; {m} fenced entr(ies) are REAL paths (a fresh apply drops them)',
|
|
@@ -197,6 +205,7 @@ export const BENEFITS = Object.freeze({
|
|
|
197
205
|
'review-recipe': 'review coverage — the review recipe you configured actually runs instead of silently degrading',
|
|
198
206
|
'gates-declaration': 'velocity — your project’s gates run as ONE declared batch with a PASS/FAIL table',
|
|
199
207
|
'gate-hook': 'velocity — your own declared gate commands auto-approve byte-exactly (opt-in PreToolUse hook)',
|
|
208
|
+
'read-lane': 'velocity — pipes/chains of your seeded read-only commands auto-approve instead of prompting (opt-in, conservatively classified)',
|
|
200
209
|
'family-freshness': 'currency — placed family members carry the latest shipped fixes and features',
|
|
201
210
|
'sandbox-masks': 'zero clutter — git status shows only your changes (the review domain already ignores the masks by construction)',
|
|
202
211
|
'agy-adddir': 'large reviews — an oversized agy code review offloads to a staging dir instead of refusing',
|
|
@@ -334,6 +343,43 @@ const probeGates = ({ root, deps, add, skip }) => {
|
|
|
334
343
|
}
|
|
335
344
|
};
|
|
336
345
|
|
|
346
|
+
// The read-lane offer (AD-055 Part II, Help-through-Recommendations): once the gate hook is PLACED
|
|
347
|
+
// and WIRED, offer to enable the opt-in read-only compound lane. Mutually exclusive with the
|
|
348
|
+
// gate-hook item BY CONSTRUCTION — that item keys on `!wired`, this one on `wired` — so an unplaced
|
|
349
|
+
// or un-wired hook is covered there, never double-offered here. The apply is the gate-hook
|
|
350
|
+
// --read-lane PREVIEW one-liner (its own currency check + posture note fire at the writer; it may
|
|
351
|
+
// prompt once — it IS a consent flow). Converges once lanes.json enables the lane.
|
|
352
|
+
const probeReadLane = ({ root, deps, add, skip }) => {
|
|
353
|
+
try {
|
|
354
|
+
const sg = surveyGateHook(root, deps);
|
|
355
|
+
if (sg.error) throw new Error(sg.error);
|
|
356
|
+
if (!sg.wired) return; // not wired → the gate-hook item covers (no double-fire)
|
|
357
|
+
if (!sg.filePlaced) {
|
|
358
|
+
// Wired but the placed hook FILE is missing — the hook errors on every Bash call and the lane
|
|
359
|
+
// is silently dark; surface it as attention with a place-first recovery (council R2-M2).
|
|
360
|
+
add('read-lane', fillTemplate(WHATS['read-lane.missing'], {}), `node ${q(toolPath('gate-hook.mjs'))} --apply --cwd ${q(root)}`, 'read-lane.missing');
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (readReadLaneToggle(root, deps)) {
|
|
364
|
+
// The lane is ON: converged IFF the placed hook is byte-current — a stale (pre-1.48) hook never
|
|
365
|
+
// reads lanes.json, so the enabled lane is a silent no-op the user must reseed (council B7). The
|
|
366
|
+
// rm target is ABSOLUTE (council R2-M3) so running the recovery from any cwd can only delete this
|
|
367
|
+
// repo's hook.
|
|
368
|
+
if (isPlacedHookCurrent(root, deps)) return; // converged
|
|
369
|
+
add(
|
|
370
|
+
'read-lane',
|
|
371
|
+
fillTemplate(WHATS['read-lane.stale'], {}),
|
|
372
|
+
`HAND-APPLY: rm ${q(join(root, GATE_HOOK_REL))}, then node ${q(toolPath('gate-hook.mjs'))} --apply --cwd ${q(root)}`,
|
|
373
|
+
'read-lane.stale',
|
|
374
|
+
);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
add('read-lane', fillTemplate(WHATS['read-lane'], {}), `node ${q(toolPath('gate-hook.mjs'))} --read-lane --cwd ${q(root)}`);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
skip('read-lane', err);
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
337
383
|
const probeFamilyFreshness = ({ deps, add, skip }) => {
|
|
338
384
|
try {
|
|
339
385
|
const survey = deps.surveyFamily ?? surveyFamily;
|
|
@@ -485,15 +531,102 @@ export const recipeFingerprint = ({ hosts, dirs, home }) => {
|
|
|
485
531
|
return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
|
|
486
532
|
};
|
|
487
533
|
|
|
488
|
-
// The kit-owned neutral ack
|
|
489
|
-
//
|
|
534
|
+
// The kit-owned neutral ack store (D4; AD-055 Part I): a FAMILY-OWNED strict-JSON file no host
|
|
535
|
+
// validator guards — top-level key `sandboxLaneAck` (+ optional `_README`), unknown keys tolerated
|
|
536
|
+
// on read (future acks are siblings). This is the PRIMARY ack channel; the legacy settings-scope
|
|
537
|
+
// keys below are read for one deprecation window. The sandbox/permissions security keys are NEVER
|
|
538
|
+
// consulted as an ack.
|
|
539
|
+
export const ACKS_FILE = 'docs/ai/acks.json';
|
|
540
|
+
export const ACKS_LANE_KEY = 'sandboxLaneAck';
|
|
541
|
+
|
|
542
|
+
// The opt-in read-lane toggle file (AD-055 Part II) — the SAME kit-owned docs/ai/lanes.json the
|
|
543
|
+
// placed hook reads live. The read-lane item offers to enable it once the hook is placed+wired.
|
|
544
|
+
export const LANES_FILE = 'docs/ai/lanes.json';
|
|
545
|
+
export const READ_LANE_KEY = 'readLane';
|
|
546
|
+
|
|
547
|
+
// The placed gate hook + the bundled runtime the read-lane item byte-compares for currency (AD-055
|
|
548
|
+
// Part II, council B7): an enabled lane over a STALE hook is a silent no-op — the pre-1.48 hook never
|
|
549
|
+
// reads lanes.json — so it must surface as an ATTENTION reseed, not a silent convergence.
|
|
550
|
+
const GATE_HOOK_REL = '.claude/hooks/agent-workflow-gates.mjs';
|
|
551
|
+
const BUNDLED_HOOK_ABS = join(HERE, '..', 'references', 'hooks', 'gate-approve.mjs');
|
|
552
|
+
|
|
553
|
+
// Byte-compare the placed gate hook against the bundled runtime. A read error (an unreadable placed
|
|
554
|
+
// hook, a broken kit bundle) propagates → the probe states a skip, never a wrong currency verdict.
|
|
555
|
+
const isPlacedHookCurrent = (root, deps) => {
|
|
556
|
+
const readFile = deps.readFile ?? readFileSync;
|
|
557
|
+
const placed = readFile(join(root, GATE_HOOK_REL), 'utf8');
|
|
558
|
+
const bundle = readFile(deps.bundledHookPath ?? BUNDLED_HOOK_ABS, 'utf8');
|
|
559
|
+
return placed === bundle;
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
// The LEGACY neutral ack namespace (pre-AD-055): read from BOTH settings scopes until the next kit
|
|
563
|
+
// MAJOR (2.0.0) so a never-migrated host stays converged across the deprecation window (Decisions 3).
|
|
490
564
|
export const SANDBOX_LANE_ACK_PARENT = 'agentWorkflow';
|
|
491
565
|
export const SANDBOX_LANE_ACK_KEY = 'sandboxLaneAck';
|
|
492
566
|
|
|
567
|
+
// Read the family-owned ack store. An ABSENT file (or absent docs/ai) is the NORMAL not-yet-acked
|
|
568
|
+
// state → null (plain fall-through, never a skip). A parse/IO error on an EXISTING file THROWS — the
|
|
569
|
+
// probe's catch turns it into a stated skip line (Decisions 2). A non-object root is a malformed
|
|
570
|
+
// store (fail-closed skip); a non-string value at the key is tolerated → null (the item re-fires).
|
|
571
|
+
// The WHOLE path chain (root / docs / ai / acks.json) is guarded WITHOUT following symlinks
|
|
572
|
+
// BEFORE any read: a symlinked ANCESTOR could otherwise read an ack from OUTSIDE the project (the
|
|
573
|
+
// writer refuses such a deployment — the reader must too), a symlinked/dangling LEAF must not read as
|
|
574
|
+
// not-yet-acked, and a non-regular target (FIFO/dir/device) is a fail-closed SKIP — never read it (a
|
|
575
|
+
// FIFO would BLOCK the advisor). ENOENT-safe: an absent file/dir is the NORMAL not-yet-acked null.
|
|
576
|
+
const readAcksLane = (root, deps) => {
|
|
577
|
+
const readFile = deps.readFile ?? readFileSync;
|
|
578
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
579
|
+
const absPath = join(root, ACKS_FILE);
|
|
580
|
+
let st;
|
|
581
|
+
try {
|
|
582
|
+
assertContainedRealPath(root, absPath, { lstat }); // symlinked root/ancestor/leaf or escape → throws
|
|
583
|
+
st = lstat(absPath);
|
|
584
|
+
} catch (err) {
|
|
585
|
+
if (err?.code === 'ENOENT') return null; // genuinely absent (file or docs/ai) — normal not-yet-acked
|
|
586
|
+
throw err; // a symlinked ancestor/leaf, an escape, or a real IO error — stated skip
|
|
587
|
+
}
|
|
588
|
+
if (!st.isFile()) {
|
|
589
|
+
throw new Error(`${ACKS_FILE} is not a regular file — refusing to read it`);
|
|
590
|
+
}
|
|
591
|
+
const parsed = JSON.parse(readFile(absPath, 'utf8'));
|
|
592
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
593
|
+
throw new Error(`${ACKS_FILE}: expected a JSON object`);
|
|
594
|
+
}
|
|
595
|
+
const value = parsed[ACKS_LANE_KEY];
|
|
596
|
+
return typeof value === 'string' ? value : null;
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
// Read the opt-in read-lane toggle for the read-lane item. An ABSENT file (or absent docs/ai) →
|
|
600
|
+
// false (the lane is off — offer it). `readLane === true` → enabled (converged). A parse/IO error on
|
|
601
|
+
// an EXISTING file, a symlinked ancestor/leaf, an escape, or a non-object root THROWS — the probe
|
|
602
|
+
// turns it into a stated skip (a BROKEN toggle the writer would refuse to overwrite is not "off").
|
|
603
|
+
// A present-but-non-boolean `readLane` is a valid store the writer merges → false (offer), never a skip.
|
|
604
|
+
const readReadLaneToggle = (root, deps) => {
|
|
605
|
+
const readFile = deps.readFile ?? readFileSync;
|
|
606
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
607
|
+
const absPath = join(root, LANES_FILE);
|
|
608
|
+
let st;
|
|
609
|
+
try {
|
|
610
|
+
assertContainedRealPath(root, absPath, { lstat }); // symlinked root/ancestor/leaf or escape → throws
|
|
611
|
+
st = lstat(absPath);
|
|
612
|
+
} catch (err) {
|
|
613
|
+
if (err?.code === 'ENOENT') return false; // genuinely absent — the lane is off, offer it
|
|
614
|
+
throw err; // a symlinked ancestor/leaf, an escape, or a real IO error — stated skip
|
|
615
|
+
}
|
|
616
|
+
if (!st.isFile()) {
|
|
617
|
+
throw new Error(`${LANES_FILE} is not a regular file — refusing to read it`);
|
|
618
|
+
}
|
|
619
|
+
const parsed = JSON.parse(readFile(absPath, 'utf8'));
|
|
620
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
621
|
+
throw new Error(`${LANES_FILE}: expected a JSON object`);
|
|
622
|
+
}
|
|
623
|
+
return parsed[READ_LANE_KEY] === true;
|
|
624
|
+
};
|
|
625
|
+
|
|
493
626
|
// D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
|
|
494
627
|
// at the consent moment; the static contract test asserts EXACT bidirectional coverage
|
|
495
628
|
// (risk-marked keys == mode-doc note keys — a dropped note goes red, not silent).
|
|
496
|
-
export const RISK_NOTED_KEYS = Object.freeze(['agy-adddir', 'sandbox-lane']);
|
|
629
|
+
export const RISK_NOTED_KEYS = Object.freeze(['agy-adddir', 'sandbox-lane', 'read-lane']);
|
|
497
630
|
|
|
498
631
|
const probeSandboxLane = ({ root, deps, add, skip }) => {
|
|
499
632
|
try {
|
|
@@ -522,14 +655,28 @@ const probeSandboxLane = ({ root, deps, add, skip }) => {
|
|
|
522
655
|
if (!dirs.includes(resolved)) dirs.push(resolved);
|
|
523
656
|
}
|
|
524
657
|
const fingerprint = recipeFingerprint({ hosts, dirs, home });
|
|
525
|
-
// Convergence is the NEUTRAL fingerprint-bound acknowledgement
|
|
526
|
-
//
|
|
527
|
-
|
|
658
|
+
// Convergence is the NEUTRAL fingerprint-bound acknowledgement: the item converges iff the
|
|
659
|
+
// CURRENT fingerprint equals the ack in ANY consulted store — the family-owned acks.json FIRST,
|
|
660
|
+
// then the legacy settings scopes; a stale value in one store is ignored when another matches
|
|
661
|
+
// (Decisions 2). A changed recipe (hosts, dirs, or an env override) re-fires the item (D4).
|
|
662
|
+
const acks = [
|
|
663
|
+
readAcksLane(root, deps),
|
|
664
|
+
settings.data?.[SANDBOX_LANE_ACK_PARENT]?.[SANDBOX_LANE_ACK_KEY],
|
|
665
|
+
localSettings.data?.[SANDBOX_LANE_ACK_PARENT]?.[SANDBOX_LANE_ACK_KEY],
|
|
666
|
+
];
|
|
528
667
|
if (acks.includes(fingerprint)) return; // the acknowledged recipe — the item converged
|
|
668
|
+
// The item joins the CONSENT-GATED WRITER class (Decisions 4): the apply is the ack writer's
|
|
669
|
+
// PREVIEW one-liner (pure executable, cwd-independent), carrying the neutral fingerprint — never
|
|
670
|
+
// a security key. The LIVE recipe (egress hosts + resolved writable dirs) rides a separate
|
|
671
|
+
// rendered `recipe:` line (the fill source for the mode doc's lane-(2) hand-apply block); the
|
|
672
|
+
// fingerprint encodes it, so a changed recipe re-fires with a fresh command.
|
|
673
|
+
const recipe = `egress hosts [${hosts.join(', ')}]; writable state dirs [${dirs.join(', ')}] (observed-minimal; a blocked host names itself at run time)`;
|
|
529
674
|
add(
|
|
530
675
|
'sandbox-lane',
|
|
531
676
|
fillTemplate(WHATS['sandbox-lane'], {}),
|
|
532
|
-
`
|
|
677
|
+
`node ${q(toolPath('ack-write.mjs'))} --fingerprint ${fingerprint} --cwd ${q(root)}`,
|
|
678
|
+
'sandbox-lane',
|
|
679
|
+
recipe,
|
|
533
680
|
);
|
|
534
681
|
} catch (err) {
|
|
535
682
|
skip('sandbox-lane', err);
|
|
@@ -543,6 +690,7 @@ const PROBES = Object.freeze([
|
|
|
543
690
|
probeSandboxProvision,
|
|
544
691
|
probeReviewRecipe,
|
|
545
692
|
probeGates,
|
|
693
|
+
probeReadLane,
|
|
546
694
|
probeFamilyFreshness,
|
|
547
695
|
probeMasksItem,
|
|
548
696
|
probeAgyAdddir,
|
|
@@ -560,18 +708,22 @@ export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
|
|
|
560
708
|
// violation surfaces through the stated-skip lane, never a crash, never a rendered violation.
|
|
561
709
|
// severityKey defaults to the item key; a per-site arm passes its `<key>.<variant>` entry when
|
|
562
710
|
// its class differs from the base (the invalid-env attention arm).
|
|
563
|
-
|
|
711
|
+
// `detail` (optional) is an extra rendered `recipe:` line — factual context that is TOO LONG for
|
|
712
|
+
// the capped WHAT and does NOT belong in the pure-command apply (the sandbox-lane live recipe:
|
|
713
|
+
// egress hosts + resolved writable dirs). Single-line like apply; absent for every other item.
|
|
714
|
+
const add = (key, what, apply, severityKey = key, detail = null) => {
|
|
564
715
|
const problems = [];
|
|
565
716
|
if (!(key in BENEFITS)) problems.push(`unregistered item key ${JSON.stringify(key)}`);
|
|
566
717
|
if (!(severityKey in SEVERITIES)) problems.push(`unregistered severity key ${JSON.stringify(severityKey)}`);
|
|
567
718
|
if (/[\r\n]/.test(what)) problems.push('WHAT is not a single line');
|
|
568
719
|
else if (what.length > ITEM_LINE_CAP) problems.push(`WHAT exceeds the ${ITEM_LINE_CAP}-char cap (${what.length})`);
|
|
569
720
|
if (/[\r\n]/.test(apply)) problems.push('apply is not a single line');
|
|
721
|
+
if (detail != null && /[\r\n]/.test(detail)) problems.push('recipe detail is not a single line');
|
|
570
722
|
if (problems.length > 0) {
|
|
571
723
|
skip(key, new Error(`item shape violation — ${problems.join('; ')}`));
|
|
572
724
|
return;
|
|
573
725
|
}
|
|
574
|
-
items.push({ key, severity: SEVERITIES[severityKey], what, benefit: BENEFITS[key], apply });
|
|
726
|
+
items.push({ key, severity: SEVERITIES[severityKey], what, benefit: BENEFITS[key], apply, detail });
|
|
575
727
|
};
|
|
576
728
|
for (const probe of deps.probes ?? PROBES) probe({ root, deps, add, skip });
|
|
577
729
|
return { root, items, skips };
|
|
@@ -599,6 +751,7 @@ export const formatRecommendations = ({ items, skips }) => {
|
|
|
599
751
|
ordered.forEach((item, i) => {
|
|
600
752
|
lines.push(`${i + 1}. ${SEVERITY_LABELS[item.severity] ?? SEVERITY_LABELS[SEVERITY_OPTIONAL]}: ${item.what}`);
|
|
601
753
|
lines.push(` benefit: ${item.benefit}`);
|
|
754
|
+
if (item.detail) lines.push(` recipe: ${item.detail}`);
|
|
602
755
|
lines.push(` apply: ${item.apply}`);
|
|
603
756
|
});
|
|
604
757
|
}
|
|
@@ -615,15 +768,16 @@ Usage:
|
|
|
615
768
|
|
|
616
769
|
Computes the deterministic Recommendations section every kit upgrade ends with — VERDICT-FIRST:
|
|
617
770
|
one composed verdict line opens every non-optimal render, then per item {severity · what is
|
|
618
|
-
sub-optimal · the benefit in one plain line ·
|
|
771
|
+
sub-optimal · the benefit in one plain line · an optional \`recipe:\` line (the sandbox-lane live
|
|
772
|
+
recipe only) · the exact consent-gated apply one-liner}. --cwd is
|
|
619
773
|
REQUIRED (the target project is explicit, never inferred from the shell's current directory). The
|
|
620
774
|
section renders present-even-when-empty ("${RECOMMENDATIONS_EMPTY_LINE}"); a probe failure is a
|
|
621
775
|
stated skipped-item line. Apply lines are cwd-independent (absolute tool paths, a pinned --cwd;
|
|
622
776
|
the doctor item pins via a cd prefix; the ONE exception is the set-autonomy item — a
|
|
623
777
|
conversational skill invocation labeled "run IN the target project") and preserve each writer's
|
|
624
|
-
own consent semantics; the sandbox
|
|
625
|
-
|
|
626
|
-
|
|
778
|
+
own consent semantics; the kit never seeds sandbox network/filesystem allowances (HAND-APPLY
|
|
779
|
+
territory), and the sandbox-lane convergence is a neutral fingerprint acknowledgement recorded by
|
|
780
|
+
the consent-gated ack writer into docs/ai/acks.json (never a security key).
|
|
627
781
|
|
|
628
782
|
Read-only: never writes, never commits, never runs a subscription CLI. Exit codes: 0 report
|
|
629
783
|
rendered (items or empty); 1 error; 2 usage.`;
|
|
@@ -406,7 +406,91 @@ export const recordTriage = (params, deps = {}) => {
|
|
|
406
406
|
return appendRecord(ledgerPath, record, deps);
|
|
407
407
|
};
|
|
408
408
|
|
|
409
|
-
// ──
|
|
409
|
+
// ── batch (D4/D5 — one invocation applies an ordered list of record/classify/override ops) ────────
|
|
410
|
+
// The prompt-economy lane (WRITER-BURST-BATCH): the ledger triad a records stage would otherwise
|
|
411
|
+
// fire one writer call at a time — records, classifications, overrides — rides ONE `batch`
|
|
412
|
+
// invocation. Every op runs the SAME single-verb code path (no forked validator, D4), so a batch of
|
|
413
|
+
// N ops is record-equivalent to the same N single-verb invocations. TWO passes (D5): pass 1
|
|
414
|
+
// validates the WHOLE envelope structurally with ZERO writes (a bad envelope stops before any op
|
|
415
|
+
// runs); pass 2 applies the ops sequentially — a DOMAIN failure (a tooth, a missing receipt, a
|
|
416
|
+
// malformed record) stops the batch with an honest report, and the ops already applied stay recorded
|
|
417
|
+
// (the ledger is append-only — no rollback pretense).
|
|
418
|
+
|
|
419
|
+
// The verbs a batch operation may carry — the SAME functions the single verbs dispatch.
|
|
420
|
+
const BATCH_VERBS = new Set(['record', 'classify', 'override']);
|
|
421
|
+
|
|
422
|
+
// Pass 1 — structural envelope validation, ZERO writes (a usage failure, exit 2). Deep per-op payload
|
|
423
|
+
// validity is NOT checked here (that is pass 2's single-verb code path — checking it here would fork
|
|
424
|
+
// the validator); pass 1 proves only the envelope shape + a known verb per op, so no raw TypeError
|
|
425
|
+
// and no silent success reaches pass 2.
|
|
426
|
+
export const validateBatchEnvelope = (payload) => {
|
|
427
|
+
if (payload == null || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
428
|
+
throw usageFail('batch payload must be an object of the form { "operations": [ … ] }');
|
|
429
|
+
}
|
|
430
|
+
const operations = payload.operations;
|
|
431
|
+
if (!Array.isArray(operations)) throw usageFail('batch payload.operations must be an array of operations');
|
|
432
|
+
if (operations.length === 0) throw usageFail('batch payload.operations is empty — nothing to record');
|
|
433
|
+
operations.forEach((op, i) => {
|
|
434
|
+
if (op == null || typeof op !== 'object' || Array.isArray(op)) {
|
|
435
|
+
throw usageFail(`batch operation [${i}] must be an object of the form { "verb": …, … }`);
|
|
436
|
+
}
|
|
437
|
+
if (!BATCH_VERBS.has(op.verb)) {
|
|
438
|
+
throw usageFail(`batch operation [${i}] names an unknown verb "${op.verb}" (expected: record | classify | override)`);
|
|
439
|
+
}
|
|
440
|
+
// The batch resolves cwd/env from the INVOCATION, never per operation — a per-op override could
|
|
441
|
+
// read one project's state and write another's ledger. Reject it loudly.
|
|
442
|
+
for (const forbidden of ['cwd', 'env']) {
|
|
443
|
+
if (forbidden in op) {
|
|
444
|
+
throw usageFail(`batch operation [${i}] carries a "${forbidden}" field — the batch resolves cwd/env from the invocation, never per operation`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// fromReceipts must be a real boolean: a string "false" is truthy and would silently enable the
|
|
448
|
+
// draft. Reject any non-boolean when the field is present.
|
|
449
|
+
if ('fromReceipts' in op && typeof op.fromReceipts !== 'boolean') {
|
|
450
|
+
throw usageFail(`batch operation [${i}] has a non-boolean fromReceipts (${JSON.stringify(op.fromReceipts)}) — it must be the literal true or false`);
|
|
451
|
+
}
|
|
452
|
+
if ('fromReceipts' in op && op.verb !== 'record') {
|
|
453
|
+
throw usageFail(`batch operation [${i}] sets fromReceipts on "${op.verb}" — it applies only to record (even the literal false does not belong here)`);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
return operations;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
// Apply ONE operation through the SAME single-verb code path main() dispatches (including the
|
|
460
|
+
// --from-receipts backends[] draft for a record op). deps is threaded so a batch is as hermetic as
|
|
461
|
+
// the single verbs.
|
|
462
|
+
const applyOperation = (op, { cwd, env }, deps = {}) => {
|
|
463
|
+
const { verb, fromReceipts, ...payload } = op;
|
|
464
|
+
// The invocation's cwd/env WIN (spread last) — validateBatchEnvelope already forbids them in the
|
|
465
|
+
// payload, so this is defense-in-depth against a direct runBatch call bypassing the preflight.
|
|
466
|
+
if (verb === 'classify') return recordTriage({ ...payload, cwd, env }, deps);
|
|
467
|
+
if (verb === 'override') return recordOverride({ ...payload, cwd, env }, deps);
|
|
468
|
+
const params = { ...payload };
|
|
469
|
+
if (fromReceipts) {
|
|
470
|
+
const state = deps.buildState ? deps.buildState({ cwd, env }) : buildState({ cwd, env });
|
|
471
|
+
params.backends = draftBackendsFromReceipts({ state, findings: params.findings, explicitBackends: params.backends ?? [] }, deps);
|
|
472
|
+
}
|
|
473
|
+
return recordRound({ ...params, cwd, env }, deps);
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
// runBatch({ cwd, env, operations }, deps) → { applied: [ per-op results ], count }. Pass 2: apply
|
|
477
|
+
// sequentially, fail-fast on the first typed STOP; the ops already appended stay recorded.
|
|
478
|
+
export const runBatch = ({ cwd = process.cwd(), env = process.env, operations }, deps = {}) => {
|
|
479
|
+
const applied = [];
|
|
480
|
+
for (const [i, operation] of operations.entries()) {
|
|
481
|
+
try {
|
|
482
|
+
applied.push(applyOperation(operation, { cwd, env }, deps));
|
|
483
|
+
} catch (err) {
|
|
484
|
+
// Honest partial-success (D5): the STOP itself names the failing op index + the applied count.
|
|
485
|
+
// The ledger is append-only, so those ops are durable — resume by re-running the REMAINING ops.
|
|
486
|
+
err.message = `${err.message} [batch stopped at operation [${i}]: ${applied.length} of ${operations.length} operation(s) recorded before the stop and durable (append-only ledger); re-run the remaining operations]`;
|
|
487
|
+
throw err;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return { applied, count: applied.length };
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
// ── CLI (record / classify / override / batch) ─────────────────────────────────────────────────────
|
|
410
494
|
|
|
411
495
|
const HELP = `review-ledger-write — the review-round ledger WRITER (agent-workflow family, AD-045 + AD-047 + AD-048).
|
|
412
496
|
|
|
@@ -414,6 +498,7 @@ Usage:
|
|
|
414
498
|
node review-ledger-write.mjs record --json '<round-payload>' [--from-receipts] [--cwd <dir>]
|
|
415
499
|
node review-ledger-write.mjs classify --json '<triage-payload>' [--cwd <dir>]
|
|
416
500
|
node review-ledger-write.mjs override --json '<override-payload>' [--cwd <dir>]
|
|
501
|
+
node review-ledger-write.mjs batch --json '<{ "operations": [ … ] }>' [--cwd <dir>]
|
|
417
502
|
(every verb also accepts --json @<file> — the payload read from a file, keeping the command
|
|
418
503
|
line PLAIN: an inline JSON argv falls outside plain-invocation allow heuristics and prompts)
|
|
419
504
|
|
|
@@ -448,10 +533,24 @@ override appends one override record — the LOUD, durable waiver the gates cons
|
|
|
448
533
|
sanctions, SEGMENT-scoped — it dies at the next commit, D4).
|
|
449
534
|
REFUSES for a loop that is not the in-flight plan. QUARANTINE (a flaky/timed-out probe)
|
|
450
535
|
has NO override lane.
|
|
536
|
+
batch applies an ordered list of record / classify / override operations in ONE invocation — the
|
|
537
|
+
prompt-economy lane for a records stage (one writer call, not one per op). The payload is
|
|
538
|
+
{ "operations": [ { "verb": "record"|"classify"|"override", …that verb's payload } ] }; a
|
|
539
|
+
record op may carry "fromReceipts": true (the SAME draft as --from-receipts). Each op runs
|
|
540
|
+
the SAME per-verb code path (no forked validator), so a batch of N ops is record-equivalent
|
|
541
|
+
to the same N single invocations. TWO passes: the WHOLE envelope is validated structurally
|
|
542
|
+
first with ZERO writes (a bad envelope stops before any op runs); then ops apply sequentially
|
|
543
|
+
and fail-fast on the first typed STOP — the ops already applied stay recorded (the ledger is
|
|
544
|
+
append-only, no rollback), and the STOP names the failing op index + the applied count.
|
|
545
|
+
Resume by re-running the REMAINING operations.
|
|
451
546
|
|
|
452
547
|
The read-only checker is a SEPARATE tool: node review-ledger.mjs --check / --status / --json / --telemetry.
|
|
453
548
|
Exit codes: 0 written; 1 a typed STOP (teeth / malformed / missing receipt / fs error); 2 usage.`;
|
|
454
549
|
|
|
550
|
+
// The CLI subcommand set — the SINGLE source the dispatch and the doc-contract pin both read, so a
|
|
551
|
+
// documented verb list can never lag the dispatch (Phase 2.3 — the contract-test class).
|
|
552
|
+
export const SUBCOMMANDS = ['record', 'classify', 'override', 'batch'];
|
|
553
|
+
|
|
455
554
|
const parseArgs = (argv) => {
|
|
456
555
|
const opts = { cwd: undefined, json: undefined, fromReceipts: false };
|
|
457
556
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -500,11 +599,18 @@ export const main = (argv, ctx = {}) => {
|
|
|
500
599
|
try {
|
|
501
600
|
if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) return { code: argv.length === 0 ? 2 : 0, stdout: HELP, stderr: '' };
|
|
502
601
|
const sub = argv[0];
|
|
503
|
-
if (sub
|
|
602
|
+
if (!SUBCOMMANDS.includes(sub)) throw usageFail(`unknown subcommand "${sub}" (expected: ${SUBCOMMANDS.join(' | ')})`);
|
|
504
603
|
const opts = parseArgs(argv.slice(1));
|
|
505
|
-
if (opts.fromReceipts && sub !== 'record') throw usageFail('--from-receipts applies only to `record`');
|
|
604
|
+
if (opts.fromReceipts && sub !== 'record') throw usageFail('--from-receipts applies only to `record` (a batch carries fromReceipts per operation)');
|
|
506
605
|
const payload = parsePayload(opts.json);
|
|
507
606
|
const cwd = opts.cwd ?? cwd0;
|
|
607
|
+
if (sub === 'batch') {
|
|
608
|
+
// Pass 1 (structural, ZERO writes) → pass 2 (sequential apply, fail-fast — prior ops durable).
|
|
609
|
+
const operations = validateBatchEnvelope(payload);
|
|
610
|
+
const { applied, count } = runBatch({ cwd, env, operations });
|
|
611
|
+
const writtenPath = applied.length ? applied[applied.length - 1].writtenPath : '(none)';
|
|
612
|
+
return { code: 0, stdout: `review-ledger-write: recorded ${count} operation(s) in one batch → ${writtenPath}`, stderr: '' };
|
|
613
|
+
}
|
|
508
614
|
if (opts.fromReceipts) {
|
|
509
615
|
// Draft backends[] from the current-fingerprint receipts + the supplied findings; origins /
|
|
510
616
|
// findings stay explicit. The drafted array then rides the normal recordRound teeth.
|
|
@@ -513,10 +619,10 @@ export const main = (argv, ctx = {}) => {
|
|
|
513
619
|
}
|
|
514
620
|
const result =
|
|
515
621
|
sub === 'record'
|
|
516
|
-
? recordRound({ cwd, env
|
|
622
|
+
? recordRound({ ...payload, cwd, env })
|
|
517
623
|
: sub === 'classify'
|
|
518
|
-
? recordTriage({ cwd, env
|
|
519
|
-
: recordOverride({ cwd, env
|
|
624
|
+
? recordTriage({ ...payload, cwd, env })
|
|
625
|
+
: recordOverride({ ...payload, cwd, env });
|
|
520
626
|
return { code: 0, stdout: `review-ledger-write: recorded a ${result.record.kind} for loop "${result.record.loop}" round ${result.record.round} → ${result.writtenPath}`, stderr: '' };
|
|
521
627
|
} catch (err) {
|
|
522
628
|
return { code: err.exitCode ?? 1, stdout: '', stderr: `review-ledger-write: ${err.message}` };
|
package/tools/review-ledger.mjs
CHANGED
|
@@ -568,7 +568,7 @@ gated commit, so the round-counter reset is earned, never declared.
|
|
|
568
568
|
rounds, override usage by scope, gate-run counts (quality-green / red-by-gate), fold runs,
|
|
569
569
|
observed-red receipts, quarantined probes. Counts only — interpretation stays with you.
|
|
570
570
|
|
|
571
|
-
The writer is a SEPARATE tool (review-ledger-write.mjs record/classify/override) — this read-only
|
|
571
|
+
The writer is a SEPARATE tool (review-ledger-write.mjs record/classify/override/batch) — this read-only
|
|
572
572
|
checker never imports it. Sandbox-safe: runs fully inside an OS sandbox (fs + git reads, no
|
|
573
573
|
network) — the D4 sandbox lane. Human residual: git commit --no-verify, ledger-file editing, and
|
|
574
574
|
forged counts remain possible — a self-discipline mechanism, not a security boundary.
|
|
@@ -163,9 +163,12 @@ const RUN_GATES_CWD_FLAG = '--cwd';
|
|
|
163
163
|
// ── the opt-in --bridge-tier (AD-044 Plan 4, Decision 2) ────────────────────────────────
|
|
164
164
|
// The FROZEN membership source: review-role wrapper NAMES only, spelled bare — that is how the
|
|
165
165
|
// wrappers are invoked (setup-backends places them as symlinks in ~/.local/bin). Roles come from
|
|
166
|
-
// THIS constant, never from the detector's wrapperCmds (which carries no role labels):
|
|
167
|
-
//
|
|
168
|
-
//
|
|
166
|
+
// THIS constant, never from the detector's wrapperCmds (which carries no role labels): codex-exec
|
|
167
|
+
// (execution role) and agy-run (probe role) are deliberately ABSENT. The velocity pain this tier
|
|
168
|
+
// closes is council REVIEW runs; codex-exec's nested-sandbox recovery is handled the canon way —
|
|
169
|
+
// route it outside ON the OBSERVED failure (orchestration.md §5), NOT a preemptive tier seed (AD-054
|
|
170
|
+
// final council: a blanket exclusion at opt-in contradicts «never a preemptive blanket»), guided by
|
|
171
|
+
// codex-exec.sh's own nested-sandbox detection hint.
|
|
169
172
|
export const BRIDGE_REVIEW_WRAPPERS = Object.freeze(['codex-review', 'agy-review']);
|
|
170
173
|
// Only the `code` review mode is auto-allowed (codex R2, Segment A): a bare `Bash(<wrapper>:*)`
|
|
171
174
|
// prefix would also cover the plan/diff file-argument modes, whose targets can point OUTSIDE the
|
|
@@ -190,13 +193,12 @@ const isQuotedGroundingToken = (token) => {
|
|
|
190
193
|
};
|
|
191
194
|
|
|
192
195
|
/**
|
|
193
|
-
* Derive the opt-in bridge-wrappers tier: one
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* skip). An absent bridge is a stated skip — its entry never derives.
|
|
196
|
+
* Derive the opt-in bridge-wrappers tier: one code-mode allow rule per PLACED review wrapper (the
|
|
197
|
+
* frozen constant is the membership source; placement is a probe, never a role source), the SAME
|
|
198
|
+
* wrapper names for sandbox.excludedCommands (the harness runs an excluded command OUTSIDE the
|
|
199
|
+
* sandbox, so a plain allowlisted invocation needs no sandbox-bypass approval — the zero-prompt
|
|
200
|
+
* wiring), and the grounding pre-step rule in its rendered quoted byte-form (derived only when
|
|
201
|
+
* agy-review is placed; an unseedable kit path is a stated skip). An absent bridge is a stated skip.
|
|
200
202
|
*/
|
|
201
203
|
export const deriveBridgeTierAllowlist = ({ findWrapper, groundingAbsPath } = {}) => {
|
|
202
204
|
const probe = findWrapper ?? ((cmd) => findOnPath(cmd).state === WRAPPER_PLACED);
|
|
@@ -253,8 +255,19 @@ export const RUNTIME_RESIDUAL_FORMS = Object.freeze({
|
|
|
253
255
|
writeRedirections: Object.freeze(['>', '>>', '1>', '2>', '&>', '>|']),
|
|
254
256
|
// `$(…)` + backtick + process substitution `<(…)` all RUN a nested command (`>(…)` is caught by
|
|
255
257
|
// the `>` redirection scan). Bare `<` is input redirection (reads a file — read-only commands may
|
|
256
|
-
// already do that), so it is deliberately NOT here.
|
|
257
|
-
|
|
258
|
+
// already do that), so it is deliberately NOT here. The bash-5.3 function substitutions `${ cmd; }`
|
|
259
|
+
// (a blank — space/tab/newline/CR — right after `${`) and `${| cmd; }` (runs a command, assigns
|
|
260
|
+
// REPLY) also execute a nested command — matched as the literal openers `${ ` / `${\t` / `${\n` /
|
|
261
|
+
// `${\r` / `${|` (AD-055 Part II; the `\r` opener guards CRLF payloads — council agy nit). An
|
|
262
|
+
// ordinary `${VAR}` parameter expansion has NO blank after `${`, so it trips none of these — kept
|
|
263
|
+
// rung-(b)-silent on a settings-allowed single (rung (c) excludes all `$`).
|
|
264
|
+
commandSubstitutions: Object.freeze(['$(', '`', '<(', '${ ', '${\t', '${\n', '${\r', '${|']),
|
|
265
|
+
// A backslash immediately before a newline/CR is a bash LINE CONTINUATION: bash removes it and
|
|
266
|
+
// splices the two lines into ONE word, which can reconstruct a residual token (`--output`, `$(`,
|
|
267
|
+
// `${ …; }`) that a raw substring scan on the pre-splice string misses (`--outp\<newline>ut=f` →
|
|
268
|
+
// `--output=f`). Guards a settings-allowed SINGLE (rung c already forbids backslash in every
|
|
269
|
+
// segment). AD-055 Part II council fold (codex B3).
|
|
270
|
+
lineContinuations: Object.freeze(['\\\n', '\\\r']),
|
|
258
271
|
// The `--output` write-flag family, matched as a raw SUBSTRING of the whole command (never a
|
|
259
272
|
// whitespace-token check): the hook sees the pre-shell command string, so `--output=f`,
|
|
260
273
|
// `"--output=f"`, `'--output' f`, and `\--output` must all trip it — over-asking on a benign
|