@sabaiway/agent-workflow-kit 5.0.0 → 5.2.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/SKILL.md +13 -1
  3. package/bridges/antigravity-cli-bridge/SKILL.md +14 -3
  4. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +220 -30
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +264 -8
  6. package/bridges/antigravity-cli-bridge/bin/agy.sh +12 -2
  7. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +18 -0
  8. package/bridges/antigravity-cli-bridge/capability.json +19 -13
  9. package/bridges/antigravity-cli-bridge/references/driving-agy.md +3 -2
  10. package/bridges/codex-cli-bridge/SKILL.md +8 -5
  11. package/bridges/codex-cli-bridge/bin/codex-exec.sh +3 -2
  12. package/bridges/codex-cli-bridge/bin/codex-review.sh +205 -34
  13. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +276 -5
  14. package/bridges/codex-cli-bridge/capability.json +8 -6
  15. package/bridges/codex-cli-bridge/references/driving-codex.md +2 -2
  16. package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +2 -2
  17. package/capability.json +1 -1
  18. package/package.json +1 -1
  19. package/references/modes/flow-writer.md +37 -0
  20. package/references/modes/gates.md +4 -4
  21. package/references/modes/procedures.md +4 -2
  22. package/references/modes/receipt-deadline.md +16 -0
  23. package/references/modes/review-state.md +1 -1
  24. package/references/modes/set-flow.md +22 -0
  25. package/tools/cheap-agents.mjs +8 -2
  26. package/tools/commands.mjs +24 -2
  27. package/tools/commit-guard.mjs +44 -9
  28. package/tools/core-evidence.mjs +25 -22
  29. package/tools/detect-backends.mjs +32 -11
  30. package/tools/doc-parity.mjs +29 -2
  31. package/tools/flow-check.mjs +806 -0
  32. package/tools/flow-record.mjs +795 -0
  33. package/tools/flow-store-read.mjs +114 -0
  34. package/tools/flow-store.mjs +1178 -0
  35. package/tools/flow-writer.mjs +1265 -0
  36. package/tools/fs-read-nofollow.mjs +128 -0
  37. package/tools/gates-declaration.mjs +184 -0
  38. package/tools/gates-init.mjs +59 -17
  39. package/tools/orchestration-config.mjs +105 -4
  40. package/tools/orchestration-write.mjs +3 -3
  41. package/tools/plan-files.mjs +35 -0
  42. package/tools/procedures.mjs +75 -11
  43. package/tools/receipt-deadline.mjs +242 -0
  44. package/tools/recipes.mjs +21 -0
  45. package/tools/repo-lex.mjs +22 -0
  46. package/tools/review-state.mjs +240 -80
  47. package/tools/run-gates.mjs +361 -139
  48. package/tools/set-flow.mjs +465 -0
  49. package/tools/velocity-profile.mjs +8 -2
@@ -26,17 +26,24 @@ import { detectBackends, wrapperCmdFor, wrapperContractFor } from './detect-back
26
26
  import { loadRegistry, allowedLabel } from './bridge-settings-read.mjs';
27
27
  import { ACTIVITIES, resolveActivityRecipe, planRecipe } from './recipes.mjs';
28
28
  import { resolveEngineDir, readEngineFragment, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
29
- // The plan-in-flight detector (AD-038) — imported from the READ-ONLY checker (review-state.mjs
30
- // performs no fs writes, so the "procedures never reaches a writer" import-split invariant holds;
31
- // the WRITER-capable grounding.mjs is only NAMED in rendered text, never imported).
32
- import { plansInFlight, PLANS_REL } from './review-state.mjs';
29
+ // The plan-in-flight detector (AD-038) — imported from the plan-files.mjs LEAF (read-only fs by
30
+ // construction); the WRITER-capable grounding.mjs is only NAMED in rendered text, never imported.
31
+ import { plansInFlight, PLANS_REL } from './plan-files.mjs';
33
32
  // The config schema/read core lives in orchestration-config.mjs (the single config contract). procedures
34
33
  // is READ-ONLY: it imports the reader + the SHARED slot/recipe validity, never the fs-writer
35
- // (orchestration-write.mjs) — so "the read-only advisor can never reach a writer" is STRUCTURALLY true
36
- // (an import-split test pins it). CONFIG_REL is RE-EXPORTED so existing importers (procedures.test.mjs,
37
- // historically) keep their import site working.
34
+ // (orchestration-write.mjs) DIRECTLY — the import-split test pins the direct-import rule.
35
+ // CONFIG_REL is RE-EXPORTED so existing importers (procedures.test.mjs, historically) keep their
36
+ // import site working.
38
37
  import { CONFIG_REL, fail, loadConfig, assertSlotRecipe } from './orchestration-config.mjs';
39
38
  import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, isSparseSeedConfig } from './autonomy-config.mjs';
39
+ // The flow armed-halves probe (P8): read-only store presence/adoption facts for the session-start
40
+ // surface, imported from the read module that OWNS no write API — this advisor never imports the
41
+ // mixed flow-store module (append API) DIRECTLY, like it never imports orchestration-write (the
42
+ // import-split test pins both direct rules). The TRANSITIVE claim is now structural, not
43
+ // narrated: this advisor's import closure reaches NO write-API module and the tools graph is
44
+ // acyclic — pinned by test/read-graph-purity.test.mjs (FLOW-READ-GRAPH-PURITY).
45
+ import { resolveFlowStorePath, readFlowStore } from './flow-store-read.mjs';
46
+ import { CHAIN_KIND } from './flow-record.mjs';
40
47
  export { CONFIG_REL };
41
48
 
42
49
  // ── argument + override parsing (usage errors → exit 2) ─────────────────────────────
@@ -305,6 +312,55 @@ const costLanesAdvice = () => [
305
312
  ' • 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.',
306
313
  ];
307
314
 
315
+ // ── the flow armed-halves block (P8 — design §5 read side) ─────────────────────────
316
+ // Rendered ONLY when the config carries a `flow` block (an unarmed project sees byte-identical
317
+ // output and NO store probe). Three halves: config-armed (the block's own keys), chain-armed (the
318
+ // flow store's adoption state — a light read-only probe on the checker's FIXED path, env ignored),
319
+ // bookkeeping (per declared path: tracked-at-arming vs loudly declared-excluded).
320
+
321
+ export const FLOW_ARMED_HALVES_HEADER = 'Flow (schema 1) — armed halves (config · chain · bookkeeping):';
322
+
323
+ export const defaultFlowProbe = (cwd, lstat = lstatSync) => {
324
+ const storePath = resolveFlowStorePath(cwd, {});
325
+ if (storePath == null) return { present: false, armed: false, broken: null };
326
+ try {
327
+ lstat(storePath);
328
+ } catch (err) {
329
+ if (err && err.code === 'ENOENT') return { present: false, armed: false, broken: null };
330
+ return { present: true, armed: false, broken: 'the store leaf cannot be stat-ed (fail closed)' };
331
+ }
332
+ const read = readFlowStore(storePath);
333
+ const broken = read.readError ?? (read.malformed > 0 ? `${read.malformed} malformed line(s) (${read.malformedReasons[0]})` : null);
334
+ return {
335
+ present: true,
336
+ armed: broken == null && read.records.some((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption'),
337
+ broken,
338
+ };
339
+ };
340
+
341
+ const flowHalvesAdvice = (flow, probe) => {
342
+ if (flow == null) return [];
343
+ const chainLine = probe.broken != null
344
+ ? ` chain: store BROKEN — ${probe.broken}; every composed checker fails closed on it`
345
+ : !probe.present
346
+ ? ' chain: UNARMED — no flow store file yet (plan adoption arms it: flow-writer adoption <plan-file>)'
347
+ : probe.armed
348
+ ? ' chain: ARMED — the flow store carries an adoption record'
349
+ : ' chain: UNARMED — a store file exists but no chain is adopted (semantic arms stay inert, #52)';
350
+ const bookkeeping = [
351
+ ['debtQueue', flow.debtQueue, flow.debtQueueExcluded],
352
+ ['convergenceSummary', flow.convergenceSummary, flow.convergenceSummaryExcluded],
353
+ ].map(([key, rel, excluded]) => ` bookkeeping.${key}: ${rel == null
354
+ ? '(undeclared)'
355
+ : `${rel} — ${excluded === true ? 'DECLARED-EXCLUDED (loud, #31)' : 'declared non-excluded (the tracked-file floor verifies on the set-flow arming path, #37)'}`}`);
356
+ return [
357
+ FLOW_ARMED_HALVES_HEADER,
358
+ ` config: ARMED — preset ${flow.preset ?? '(unset)'} · councilRounds ${flow.councilRounds ?? '(unset)'} · kitMinVersion ${flow.kitMinVersion ?? '(none declared)'}`,
359
+ chainLine,
360
+ ...bookkeeping,
361
+ ];
362
+ };
363
+
308
364
  // The verbatim per-backend DRIVING CONTRACT block (M-contract): the exact invocation descriptor(s),
309
365
  // the closed flag set, the grounding note, the round-2/continue delta, and the guarded passthrough
310
366
  // tiers — every descriptor printed VERBATIM from the registry mirror of the bridge manifest
@@ -341,7 +397,7 @@ const contractLines = ({ cmd, contract, settings }) => {
341
397
  return lines;
342
398
  };
343
399
 
344
- const formatHuman = ({ activity, section, slots, warnings, plans, autonomy }) => {
400
+ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flowHalves }) => {
345
401
  const lines = [
346
402
  section,
347
403
  '',
@@ -353,6 +409,7 @@ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy }) =>
353
409
  if (s.reason) lines.push(` ↳ ${s.reason}`);
354
410
  for (const c of s.contracts ?? []) lines.push(...contractLines(c));
355
411
  }
412
+ if ((flowHalves ?? []).length) lines.push('', ...flowHalves);
356
413
  const autonomyBlock = autonomyAdvice(activity, autonomy);
357
414
  if (autonomyBlock.length) lines.push('', ...autonomyBlock);
358
415
  const grounding = groundingPreStepAdvice(activity, slots, plans);
@@ -367,7 +424,7 @@ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy }) =>
367
424
  return lines.join('\n');
368
425
  };
369
426
 
370
- const buildJson = ({ activity, section, slots, configSource, warnings, plans, autonomy }) => ({
427
+ const buildJson = ({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves }) => ({
371
428
  activity,
372
429
  section,
373
430
  slots: Object.fromEntries(
@@ -382,6 +439,9 @@ const buildJson = ({ activity, section, slots, configSource, warnings, plans, au
382
439
  costLanes: costLanesAdvice(),
383
440
  // ADDITIVE (AD-044 Plan 4): the per-activity autonomy block, structured (empty when unresolvable).
384
441
  autonomy: autonomyAdvice(activity, autonomy),
442
+ // CONDITIONAL (flow P8): the armed-halves block rides ONLY a flow-carrying config — the unarmed
443
+ // JSON key set stays byte-exact (unarmed neutrality outranks the additive-key precedent).
444
+ ...(flowHalves == null ? {} : { flowHalves }),
385
445
  configSource,
386
446
  warnings,
387
447
  });
@@ -451,9 +511,13 @@ export const main = (argv, ctx = {}) => {
451
511
  return { error: (err && err.message) || String(err) };
452
512
  }
453
513
  })();
514
+ // The flow armed-halves block (P8): probed ONLY when the config carries a flow block — an
515
+ // unarmed project keeps byte-identical output (human AND JSON) and never pays the store probe.
516
+ const flowProbe = ctx.flowProbe ?? defaultFlowProbe;
517
+ const flowHalves = config?.flow == null ? null : flowHalvesAdvice(config.flow, flowProbe(cwd));
454
518
  const stdout = json
455
- ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans, autonomy }), null, 2)
456
- : formatHuman({ activity, section, slots, warnings, plans, autonomy });
519
+ ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves }), null, 2)
520
+ : formatHuman({ activity, section, slots, warnings, plans, autonomy, flowHalves });
457
521
  if (autonomy?.error) {
458
522
  return { code: 1, stdout, stderr: `procedures: malformed ${AUTONOMY_REL} — ${autonomy.error}` };
459
523
  }
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+ // receipt-deadline.mjs — the per-dispatch receipt-ARRIVAL deadline runner (flow-orchestration
3
+ // Plan 3 Phase 4, #41/#26/#50, P6/P18). It waits for ONE dispatched review to ANSWER, never for
4
+ // the review obligations to be satisfied — that is review-state --await's job. Satisfaction is
5
+ // ARRIVAL: a newline-terminated parseable receipt line from the dispatched backend starting
6
+ // at/after the watermark offset, or — PREFERRED whenever a dispatch nonce is supplied and its
7
+ // finding manifest exists — the nonce-matched manifest (the manifest is minted atomically BEFORE
8
+ // the receipt append, so its presence is the stronger dispatch-identity signal).
9
+ //
10
+ // Watermark semantics (P6/P18, split by surface): the PERSISTED dispatch-ledger watermark stays
11
+ // the plain byte-length integer; THIS RUNNER additionally binds the receipts-file PREFIX
12
+ // IN-PROCESS at start (a hash of the bytes up to the watermark offset) — a shrunken file or a
13
+ // changed prefix refuses LOUDLY for the lifetime of the run, so a truncate-and-rewrite can never
14
+ // masquerade as arrival. Honest limit: the prefix binding is a runtime guard, never a persisted
15
+ // proof. Timeout fires ONLY when no receipt landed, and its wording names the watermark.
16
+ //
17
+ // Read-only: never writes, never commits, never runs a subscription CLI. The clock is injectable
18
+ // (ctx.now / ctx.sleep / ctx.pollMs) so hermetic tests never spend wall-clock. Dependency-free,
19
+ // Node >= 22. No side effects on import (the isDirectRun idiom).
20
+
21
+ import { join, dirname } from 'node:path';
22
+ import { pathToFileURL } from 'node:url';
23
+ import { createHash } from 'node:crypto';
24
+ import { resolveReceiptsPath } from './core-evidence.mjs';
25
+ import { SAFE_NONCE_RE, findingManifestBasename, decodeFindingManifest } from './flow-record.mjs';
26
+ import { readFileBytesNoFollow } from './flow-store-read.mjs';
27
+
28
+ const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 });
29
+
30
+ export const DEFAULT_DEADLINE_TIMEOUT_S = 900;
31
+ export const DEADLINE_POLL_MS = 5000;
32
+
33
+ // The one contract sentence, doc-parity-bound into references/modes/receipt-deadline.md — the
34
+ // arrival-not-satisfaction split is the tool's identity and must not drift in the mode doc.
35
+ export const RECEIPT_DEADLINE_CONTRACT = 'satisfaction is receipt ARRIVAL past the watermark — a strictly-newer parseable receipt line from the dispatched backend (or its nonce-matched finding manifest, preferred when present) — never obligation satisfaction';
36
+
37
+ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
38
+
39
+ // Every read rides the kit's ONE race-free reader (flow-store-read's no-follow/non-block
40
+ // discipline): store identity is never resolved through a link, a FIFO can never block the
41
+ // bounded wait, and an invalid-UTF-8 store refuses (a byte-unstable store cannot carry a prefix
42
+ // binding). → { bytes } | { bytes: null } (absent) | { refuse: reason }.
43
+ const readBytesOrRefuse = (path, io, label) => {
44
+ const r = readFileBytesNoFollow(path, io);
45
+ if (r.outcome === 'ok') return { bytes: r.bytes };
46
+ if (r.outcome === 'absent') return { bytes: null };
47
+ if (r.outcome === 'foreign') return { refuse: `${label} at ${path} is a ${r.className}, not a regular file — never followed, never read (fail closed)` };
48
+ return { refuse: `${label} at ${path} is unreadable (${r.code}) — fail closed` };
49
+ };
50
+
51
+ // One poll over the bound state → { state: 'waiting' | 'satisfied' | 'refused', reason }.
52
+ // Refusals are TERMINAL for the run (P6: the prefix binding refuses for the run's lifetime).
53
+ // Order is load-bearing: STORE INTEGRITY first (the P6 guarantee is unconditional — a manifest
54
+ // landing after a truncate/rewrite must never mask it), the manifest correlation second, the
55
+ // tail line scan last. An absent store under watermark 0 is not an integrity violation (no store
56
+ // yet; the prefix below offset 0 is vacuously intact), so a present manifest still satisfies it.
57
+ export const pollArrival = ({ path, watermark, prefixHash, backend, nonce = null, manifestPath, io = {} }) => {
58
+ const store = readBytesOrRefuse(path, io, 'the receipts store');
59
+ if (store.refuse !== undefined) return { state: 'refused', reason: store.refuse };
60
+ const bytes = store.bytes;
61
+ if (bytes == null && watermark > 0) {
62
+ return { state: 'refused', reason: `the receipts file vanished below watermark offset ${watermark} (${path}) — a shrunken store refuses loudly for the lifetime of this run (P6)` };
63
+ }
64
+ if (bytes != null) {
65
+ if (bytes.byteLength < watermark) {
66
+ return { state: 'refused', reason: `the receipts file shrank below watermark offset ${watermark} (${bytes.byteLength} bytes at ${path}) — a shrunken store refuses loudly for the lifetime of this run (P6)` };
67
+ }
68
+ if (sha256(bytes.subarray(0, watermark)) !== prefixHash) {
69
+ return { state: 'refused', reason: `the receipts-file prefix below watermark offset ${watermark} was REWRITTEN (${path}) — a truncate-and-rewrite can never masquerade as arrival; this run refuses for its lifetime (P6)` };
70
+ }
71
+ }
72
+ if (manifestPath != null) {
73
+ const m = readBytesOrRefuse(manifestPath, io, 'the finding manifest');
74
+ if (m.refuse !== undefined) return { state: 'refused', reason: m.refuse };
75
+ if (m.bytes != null) {
76
+ const decoded = decodeFindingManifest(m.bytes);
77
+ if (!decoded.ok) return { state: 'refused', reason: `the finding manifest at ${manifestPath} is malformed — ${decoded.reason} — a malformed manifest never proves arrival` };
78
+ const manifest = decoded.manifest;
79
+ if (manifest.backend !== backend || (nonce != null && manifest.nonce !== nonce)) {
80
+ return { state: 'refused', reason: `the finding manifest at ${manifestPath} declares {backend "${manifest.backend}", nonce "${manifest.nonce}"}, not the awaited {backend "${backend}", nonce "${nonce}"} — a foreign manifest never proves this dispatch (fail closed)` };
81
+ }
82
+ return { state: 'satisfied', reason: `the nonce-matched finding manifest landed (${manifestPath}) — dispatch-identity correlation (preferred over the watermark scan)` };
83
+ }
84
+ }
85
+ if (bytes == null) {
86
+ return { state: 'waiting', reason: `no receipt line from backend "${backend}" has arrived past watermark offset ${watermark} yet (${path} does not exist yet)` };
87
+ }
88
+ // Only COMPLETE (newline-terminated) lines count — a partial in-flight append is not a receipt.
89
+ const tail = bytes.subarray(watermark).toString('utf8');
90
+ const lastNewline = tail.lastIndexOf('\n');
91
+ const complete = lastNewline === -1 ? [] : tail.slice(0, lastNewline).split('\n');
92
+ for (const line of complete) {
93
+ if (line.trim() === '') continue;
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(line);
97
+ } catch {
98
+ continue; // a malformed line never satisfies — and never masks a later valid one
99
+ }
100
+ if (parsed && typeof parsed === 'object' && parsed.backend === backend) {
101
+ return { state: 'satisfied', reason: `a receipt line from backend "${backend}" arrived past watermark offset ${watermark} (${path})` };
102
+ }
103
+ }
104
+ return { state: 'waiting', reason: `no receipt line from backend "${backend}" has arrived past watermark offset ${watermark} yet (${path})` };
105
+ };
106
+
107
+ // runReceiptDeadline({ backend, watermark, nonce?, timeoutS, cwd, env, … }) → { code, stdout, stderr }.
108
+ export const runReceiptDeadline = async ({
109
+ backend, watermark, nonce = null, timeoutS = DEFAULT_DEADLINE_TIMEOUT_S,
110
+ cwd = process.cwd(), env = process.env,
111
+ now = () => Date.now(), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
112
+ pollMs = DEADLINE_POLL_MS, io = {},
113
+ }) => {
114
+ const path = resolveReceiptsPath(cwd, env);
115
+ if (path == null) {
116
+ return { code: 1, stdout: '', stderr: 'receipt-deadline: not inside a git work tree (and no AW_REVIEW_RECEIPTS override) — there is no receipts file to watch' };
117
+ }
118
+ let manifestPath = null;
119
+ if (nonce != null) {
120
+ const basename = findingManifestBasename(backend, nonce);
121
+ if (basename == null) {
122
+ return { code: 2, stdout: '', stderr: 'receipt-deadline: --nonce (and the backend name) must satisfy the safe nonce grammar ([A-Za-z0-9._-]{1,64}) — an unsafe token never composes a manifest name' };
123
+ }
124
+ manifestPath = join(dirname(path), basename);
125
+ }
126
+ // Bind the prefix IN-PROCESS at start (P6/P18): the bytes up to the watermark offset are hashed
127
+ // once; every poll re-verifies them. A file already shorter than the watermark refuses at start.
128
+ const start0 = readBytesOrRefuse(path, io, 'the receipts store');
129
+ if (start0.refuse !== undefined) return { code: 1, stdout: '', stderr: `receipt-deadline: REFUSED — ${start0.refuse}` };
130
+ const startBytes = start0.bytes;
131
+ const startLength = startBytes == null ? 0 : startBytes.byteLength;
132
+ if (startLength < watermark) {
133
+ return { code: 1, stdout: '', stderr: `receipt-deadline: the receipts file is ${startLength} bytes, below watermark offset ${watermark} (${path}) — the watermark was minted on a longer file, so the store shrank; refusing loudly (P6)` };
134
+ }
135
+ // The watermark must sit on a JSONL line boundary: with an UNTERMINATED pre-dispatch tail an
136
+ // appended receipt physically CONTINUES that malformed line, yet the isolated tail slice would
137
+ // parse — so the boundary binds at start beside the prefix (the hash then freezes it).
138
+ if (watermark > 0 && startBytes[watermark - 1] !== 0x0a) {
139
+ return { code: 1, stdout: '', stderr: `receipt-deadline: REFUSED — watermark offset ${watermark} does not sit on a line boundary (the byte before it is not a newline): the pre-dispatch store tail is unterminated, and an appended receipt would physically continue that malformed line; re-mint the watermark on a newline-terminated store (${path})` };
140
+ }
141
+ const prefixHash = sha256(startBytes == null ? Buffer.alloc(0) : startBytes.subarray(0, watermark));
142
+ const timeoutMs = timeoutS * 1000;
143
+ const start = now();
144
+ let lastReason = `no receipt line from backend "${backend}" has arrived past watermark offset ${watermark} yet (${path})`;
145
+ for (;;) {
146
+ // Deadline BEFORE readiness (the --await discipline): a receipt landing at/after the deadline
147
+ // never flips the run to ARRIVED, and each sleep is bounded to the remaining time.
148
+ const elapsed = now() - start;
149
+ if (elapsed >= timeoutMs) {
150
+ return { code: 1, stdout: '', stderr: `receipt-deadline: TIMEOUT after ${timeoutS}s — ${lastReason}; no receipt landed past watermark offset ${watermark}` };
151
+ }
152
+ const poll = pollArrival({ path, watermark, prefixHash, backend, nonce, manifestPath, io });
153
+ if (poll.state === 'satisfied') return { code: 0, stdout: `receipt-deadline: ARRIVED — ${poll.reason}`, stderr: '' };
154
+ if (poll.state === 'refused') return { code: 1, stdout: '', stderr: `receipt-deadline: REFUSED — ${poll.reason}` };
155
+ lastReason = poll.reason;
156
+ await sleep(Math.min(pollMs, timeoutMs - elapsed));
157
+ }
158
+ };
159
+
160
+ const HELP = `receipt-deadline — the per-dispatch receipt-ARRIVAL deadline runner (flow-orchestration).
161
+
162
+ Usage:
163
+ node receipt-deadline.mjs --backend <name> --watermark <bytes> [--nonce <nonce>] [--timeout <s>]
164
+ (every flag also accepts the inline --flag=<value> form — the lane a leading-dash value rides)
165
+
166
+ Waits for ONE dispatched review to ANSWER: ${RECEIPT_DEADLINE_CONTRACT}.
167
+ The watermark is the receipts-file byte length minted BEFORE the dispatch (the round dispatch
168
+ ledger's receiptWatermark); the runner binds the file prefix below that offset IN-PROCESS at
169
+ start — a shrunken file or a rewritten prefix refuses loudly for the lifetime of the run (a
170
+ runtime guard, never a persisted proof) — and a positive watermark must sit on a LINE BOUNDARY
171
+ (an unterminated pre-dispatch tail refuses loudly at start: an appended receipt would
172
+ physically continue that malformed line). With --nonce, the {backend, nonce}-named finding
173
+ manifest beside the receipts file is the PREFERRED arrival signal (it is minted atomically
174
+ before the receipt append). Timeout (default ${DEFAULT_DEADLINE_TIMEOUT_S}s) fires only when no
175
+ receipt landed, and names the watermark.
176
+
177
+ Read-only: never writes, never commits, never runs a subscription CLI.
178
+ Exit codes: 0 arrived; 1 timeout or a loud refusal (shrunken/rewritten store, malformed manifest); 2 usage.`;
179
+
180
+ export const main = async (argv, ctx = {}) => {
181
+ try {
182
+ if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
183
+ // ONE single-pass parse: every known flag takes exactly one value and appears at most ONCE
184
+ // (a silently-ignored duplicate would let `--backend a --backend b` wait on the wrong
185
+ // backend); the inline `--flag=<value>` form is the lane a grammar-legal leading-dash value
186
+ // rides, and duplicate detection is CANONICAL on the flag name across both forms.
187
+ const known = new Set(['--backend', '--watermark', '--nonce', '--timeout']);
188
+ const values = {};
189
+ for (let i = 0; i < argv.length; i += 1) {
190
+ const token = argv[i];
191
+ const eq = token.indexOf('=');
192
+ const flag = eq === -1 ? token : token.slice(0, eq);
193
+ if (!known.has(flag)) throw usageFail(`unknown argument: ${flag}`);
194
+ if (Object.hasOwn(values, flag)) throw usageFail(`duplicate flag: ${flag} — every flag is given at most once, whichever form it rides`);
195
+ if (eq !== -1) {
196
+ values[flag] = token.slice(eq + 1);
197
+ continue;
198
+ }
199
+ const value = argv[i + 1];
200
+ if (value === undefined || value.startsWith('--')) throw usageFail(`${flag} requires a value (or use ${flag}=<value> for a leading-dash value)`);
201
+ values[flag] = value;
202
+ i += 1;
203
+ }
204
+ const backend = values['--backend'] ?? null;
205
+ if (backend == null) throw usageFail('--backend <name> is required (the dispatched backend whose receipt is awaited)');
206
+ // The backend is a FILTER TOKEN over receipt lines and (with --nonce) a manifest-name half —
207
+ // an empty/control/non-ASCII value can match no honest backend, so waiting on it never helps.
208
+ if (!SAFE_NONCE_RE.test(backend)) throw usageFail('--backend must satisfy the safe ASCII token grammar ([A-Za-z0-9._-]{1,64})');
209
+ const watermarkRaw = values['--watermark'] ?? null;
210
+ // Both numeric flags are SAFE-INTEGER-bounded: an all-digits overflow value would coerce to a
211
+ // huge float/Infinity and silently unbound the run (the timeout bound divides by 1000 because
212
+ // timeoutS * 1000 must itself stay a safe integer).
213
+ if (watermarkRaw == null || !/^\d+$/.test(watermarkRaw) || !Number.isSafeInteger(Number(watermarkRaw))) {
214
+ throw usageFail('--watermark requires the non-negative safe integer byte offset minted before dispatch');
215
+ }
216
+ const timeoutRaw = values['--timeout'] ?? null;
217
+ const timeoutMax = Math.floor(Number.MAX_SAFE_INTEGER / 1000);
218
+ if (timeoutRaw != null && (!/^\d+$/.test(timeoutRaw) || Number(timeoutRaw) < 1 || Number(timeoutRaw) > timeoutMax)) {
219
+ throw usageFail(`--timeout requires a positive safe integer number of seconds (at most ${timeoutMax})`);
220
+ }
221
+ return await runReceiptDeadline({
222
+ backend,
223
+ watermark: Number(watermarkRaw),
224
+ nonce: values['--nonce'] ?? null,
225
+ timeoutS: timeoutRaw == null ? DEFAULT_DEADLINE_TIMEOUT_S : Number(timeoutRaw),
226
+ cwd: ctx.cwd ?? process.cwd(),
227
+ env: ctx.env ?? process.env,
228
+ now: ctx.now, sleep: ctx.sleep, pollMs: ctx.pollMs, io: ctx.io,
229
+ });
230
+ } catch (err) {
231
+ return { code: err.exitCode ?? 1, stdout: '', stderr: `receipt-deadline: ${err.message}` };
232
+ }
233
+ };
234
+
235
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
236
+ if (isDirectRun) {
237
+ main(process.argv.slice(2)).then((r) => {
238
+ if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
239
+ if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
240
+ process.exitCode = r.code;
241
+ });
242
+ }
package/tools/recipes.mjs CHANGED
@@ -43,6 +43,27 @@ export const BACKEND_ROLES = {
43
43
  [AGY]: ['review', 'probe'],
44
44
  };
45
45
 
46
+ // Review obligations from the CONFIGURED plan-execution.review recipe — the RAW config value,
47
+ // never the readiness-degraded effective recipe (a computed readiness-degrade NEVER silently
48
+ // becomes solo). Homed HERE (cycle-free) so review-state AND flow-check consume the SAME consumed
49
+ // backend set (#42 exact coverage must never fall open on the computed-default class).
50
+ // solo → no obligation; reviewed → ONE ship-class from ANY backend (minShip 1);
51
+ // council → EVERY backend ship-class or degrade-recorded, and never all (minShip 1).
52
+ export const requiredBackendsForConfiguredRecipe = ({ config, readiness = [], detectionFailed = false } = {}) => {
53
+ const configured = config?.['plan-execution']?.review;
54
+ const providers = Object.values(DISPLAY_ALIASES); // every review-capable backend, codex first
55
+ if (configured == null && detectionFailed) {
56
+ // No config + no readiness signal: the computed default is UNKNOWABLE — fail closed upstream.
57
+ return { recipe: null, source: 'default', backends: [], minShip: 0, perBackend: false, unknowable: true };
58
+ }
59
+ const anyReady = readiness.some((b) => b.readiness === READY);
60
+ const recipe = configured ?? (anyReady ? 'reviewed' : 'solo');
61
+ const source = configured != null ? 'config' : 'default';
62
+ if (recipe === 'solo') return { recipe, source, backends: [], minShip: 0, perBackend: false, unknowable: false };
63
+ if (recipe === 'council') return { recipe, source, backends: providers, minShip: 1, perBackend: true, unknowable: false };
64
+ return { recipe, source, backends: providers, minShip: 1, perBackend: false, unknowable: false };
65
+ };
66
+
46
67
  // Advisory metadata the DETECTION object does not carry (it has no cost/quota — those live only in
47
68
  // capability.json). cost/quota are drift-guarded against the manifests; the agy `health` advisory is
48
69
  // static project knowledge (Issue-001: the Antigravity service can stall on substantive prompts —
@@ -0,0 +1,22 @@
1
+ // repo-lex.mjs — pure lexical helpers shared by the read and write surfaces (FLOW-READ-GRAPH-PURITY,
2
+ // flow Plan 4 Phase 2). A LEAF: Node built-ins only, no fs, no CLI, no side effects on import —
3
+ // extracted so read-surface modules (flow-record, cheap-agents) reach these rules without pulling
4
+ // in the mixed modules that host the write APIs (core-evidence and review-state re-export them, so
5
+ // every existing consumer keeps its import site). Dependency-free, Node >= 22.
6
+
7
+ import { isAbsolute, normalize, sep } from 'node:path';
8
+
9
+ // The LEXICAL half of the repo-relative rule — ONE home shared by the record validator (which has
10
+ // no fs to resolve against) and the fs resolver in core-evidence, so the two can never drift: a
11
+ // forged record carrying an equal-but-absolute (or escaping) testId/file pair is refused at
12
+ // validation, not just at observation time.
13
+ export const lexicalRepoRelative = (rel) => {
14
+ if (typeof rel !== 'string' || rel.length === 0) return { ok: false, reason: 'empty file path' };
15
+ if (isAbsolute(rel)) return { ok: false, reason: `absolute path "${rel}" — the testId file half must be repo-relative` };
16
+ const norm = normalize(rel);
17
+ if (norm === '..' || norm.startsWith(`..${sep}`)) return { ok: false, reason: `path "${rel}" escapes the repo root` };
18
+ return { ok: true };
19
+ };
20
+
21
+ // POSIX single-quote for pasteable command rendering (display only — never an execution boundary).
22
+ export const shellQuoteArg = (s) => (/^[A-Za-z0-9_/.\-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);