@sabaiway/agent-workflow-kit 1.45.1 → 1.47.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.
@@ -406,7 +406,91 @@ export const recordTriage = (params, deps = {}) => {
406
406
  return appendRecord(ledgerPath, record, deps);
407
407
  };
408
408
 
409
- // ── CLI (record / classify) ──────────────────────────────────────────────────────────────────────
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 !== 'record' && sub !== 'classify' && sub !== 'override') throw usageFail(`unknown subcommand "${sub}" (expected: record | classify | override)`);
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, ...payload })
622
+ ? recordRound({ ...payload, cwd, env })
517
623
  : sub === 'classify'
518
- ? recordTriage({ cwd, env, ...payload })
519
- : recordOverride({ cwd, env, ...payload });
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}` };
@@ -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
- // codex-exec (execution role) and agy-run (probe role) are deliberately ABSENT delegated
168
- // execution keeps its human prompt; the velocity pain this tier closes is council REVIEW runs.
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 bare-name wildcard allow rule per PLACED review
194
- * wrapper (the frozen constant is the membership source; placement is a probe, never a role
195
- * source), the SAME wrapper names for sandbox.excludedCommands (the harness runs an excluded
196
- * command OUTSIDE the sandbox, so a plain allowlisted invocation needs no sandbox-bypass
197
- * approval — the zero-prompt wiring), and the grounding pre-step rule in its rendered quoted
198
- * byte-form (derived only when ≥1 review bridge is placed; an unseedable kit path is a stated
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);