@sabaiway/agent-workflow-kit 5.4.0 → 5.6.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 +130 -0
  2. package/README.md +1 -0
  3. package/SKILL.md +5 -1
  4. package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +1 -1
  6. package/bridges/antigravity-cli-bridge/capability.json +1 -1
  7. package/bridges/codex-cli-bridge/SKILL.md +51 -4
  8. package/bridges/codex-cli-bridge/bin/codex-exec.sh +616 -24
  9. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +700 -1
  10. package/bridges/codex-cli-bridge/bin/codex-review.sh +1 -1
  11. package/bridges/codex-cli-bridge/capability.json +15 -10
  12. package/capability.json +1 -1
  13. package/package.json +1 -1
  14. package/references/modes/dispatch.md +29 -0
  15. package/references/modes/gates.md +6 -3
  16. package/references/modes/procedures.md +2 -0
  17. package/references/modes/receipt-deadline.md +3 -3
  18. package/references/modes/recommendations.md +1 -1
  19. package/references/modes/velocity.md +1 -0
  20. package/tools/commands.mjs +7 -0
  21. package/tools/core-evidence.mjs +37 -3
  22. package/tools/detect-backends.mjs +5 -4
  23. package/tools/dispatch-record.mjs +10 -3
  24. package/tools/dispatch-store.mjs +392 -0
  25. package/tools/dispatch.mjs +1779 -0
  26. package/tools/doc-parity.mjs +10 -2
  27. package/tools/exec-producer.mjs +483 -0
  28. package/tools/exec-receipt.mjs +263 -0
  29. package/tools/flow-check-cores.mjs +253 -0
  30. package/tools/flow-check-git-lane.mjs +56 -0
  31. package/tools/flow-check-rungs.mjs +330 -0
  32. package/tools/flow-check.mjs +23 -611
  33. package/tools/flow-store.mjs +111 -462
  34. package/tools/gates-declaration.mjs +13 -1
  35. package/tools/gates-init.mjs +134 -22
  36. package/tools/procedures.mjs +64 -5
  37. package/tools/receipt-deadline.mjs +25 -3
  38. package/tools/recommendations.mjs +108 -7
  39. package/tools/release-scan.mjs +33 -0
  40. package/tools/source-size-check.mjs +320 -0
  41. package/tools/source-size-config.mjs +244 -0
  42. package/tools/source-size-core.mjs +53 -0
  43. package/tools/source-size-gate-cmd.mjs +55 -0
  44. package/tools/source-size-judge.mjs +114 -0
  45. package/tools/source-size-refusal.mjs +70 -0
  46. package/tools/source-size-report.mjs +254 -0
  47. package/tools/source-size-scope.mjs +145 -0
  48. package/tools/store-append.mjs +444 -0
  49. package/tools/velocity-profile.mjs +24 -3
@@ -50,8 +50,8 @@ case "${1:-}" in
50
50
  codex-exec — delegate plan/instruction EXECUTION to the OpenAI Codex CLI (subscription-only; workspace-write sandbox, network OFF, git writes blocked — the orchestrator commits).
51
51
 
52
52
  Usage:
53
- codex-exec <plan-file|->
54
- codex-exec <plan-file|-> -- <extra codex flags...>
53
+ codex-exec [--nonce <n>] <plan-file|->
54
+ codex-exec [--nonce <n>] <plan-file|-> -- <extra codex flags...>
55
55
 
56
56
  Grounding:
57
57
  automatic — the root AGENTS.md (Hard Constraints) is auto-merged into codex's
@@ -59,14 +59,62 @@ Grounding:
59
59
  grounding flags
60
60
 
61
61
  Round-2 / resume:
62
- codex-exec --resume-last <plan-file|->
63
- codex-exec --resume <session-id> <plan-file|->
62
+ codex-exec --resume-last [--nonce <n>] <plan-file|->
63
+ codex-exec --resume <session-id> [--nonce <n>] <plan-file|->
64
64
  (resume continues the recorded session without re-sending context; takes no '--' passthrough)
65
65
 
66
66
  Guarded passthrough after '--':
67
67
  blocked always: -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*
68
68
  relaxed only under CODEX_PROBE=1: --add-dir* -C* --cd* --skip-git-repo-check --ignore-rules --enable* --disable*
69
69
 
70
+ Receipt:
71
+ side effect — a NONCED run mints ONE exec receipt beside the delegation store: the dispatch nonce
72
+ seam is the AW_DISPATCH_NONCE environment value or its plain-argument equivalent --nonce <n>,
73
+ recognised ONLY before the prompt operand (after the operand or a literal '--' it is passthrough
74
+ payload, never a flag), under the safe grammar [A-Za-z0-9._-]{1,64} — anything else, a duplicate,
75
+ or a flag disagreeing with a non-empty env value refuses PRE-SPEND. The store directory resolves
76
+ exactly as the kit's delegation store does: the dirname of an ABSOLUTE AW_DELEGATION_STORE
77
+ (a relative one, or one ending in a path separator, refuses), else the git common dir. The
78
+ artifact is agent-workflow-exec-receipt-<backendLength>-<backend>-<nonce>.json in two states:
79
+ 'reserved' is written atomically and NO-CLOBBER immediately before the CLI runs — that write IS
80
+ the nonce reservation, so a second dispatch on the same nonce, or an already-taken report name,
81
+ refuses BEFORE any spend — and 'terminal' replaces it in place at exit. A nonced run also refuses
82
+ pre-spend when no timeout/gtimeout binary can cap it (an accounted dispatch that cannot be capped
83
+ can never honour the terminal-exit rule; a nonce-LESS run still warns and runs uncapped), when
84
+ node is missing (the mint core), and when the prompt rides on stdin instead of a contract FILE —
85
+ contractDigest is computed BY THIS WRAPPER from the dispatch file it was actually handed, so the
86
+ kit can refuse a run that executed a different contract than the one it opened. That digest is
87
+ taken from the SAME bytes already read as the prompt, never a second open of the path: two reads
88
+ leave a window in which the file can be swapped, and the run would then execute one contract while
89
+ its receipt claimed the digest of another. The header's own nonce must EQUAL the dispatch nonce —
90
+ 'dispatch open' copies the nonce FROM the header, so a disagreeing --nonce could only reserve an
91
+ identity no return would ever absorb, and it refuses pre-spend. A contract file edited BETWEEN
92
+ 'dispatch open' and the run is caught at ABSORB by the contractDigest comparison, not pre-spend:
93
+ the wrapper never reads the ledger, and that boundary is what the whole lane rests on. At exit the
94
+ wrapper FIRST re-reads its reservation and verifies its own opaque owner token — a foreign owner
95
+ refuses having published NOTHING, neither report nor receipt — THEN writes the delegate's final
96
+ message atomically to agent-workflow-exec-report-<backendLength>-<backend>-<nonce>.txt, THEN
97
+ re-verifies the owner and REPLACES the reservation with the terminal receipt {schema, kind, state,
98
+ backend, nonce, owner, contractDigest, wrapperVersion, posture {model, effort, tier}, capS,
99
+ killGraceS, sessionId, exitStatus, outcome, reportDigest, reportLength, timestamp}: the report is
100
+ complete on disk before any artifact says the run arrived. capS and killGraceS are the cap the run
101
+ ACTUALLY applied. outcome is the wrapper's own SUBSET of the ledger's vocabulary — exit 0 with a
102
+ session id -> success, exit 0 without one -> missing-identity, ANY nonzero exit including the
103
+ timeout codes 124 and 137 -> transport-failure; every orchestrator judgment is recorded at absorb
104
+ time, never claimed here. The session id is captured BEFORE outcome branching, so a FAILED run
105
+ records one too; in resume mode it is the validated resume id. FAIL-CLOSED, deliberately NOT the
106
+ review lane's warn-only receipt: a publication that cannot complete exits nonzero with a DISTINCT
107
+ status, and the message states only what the run can still prove. 70: the reservation could not be
108
+ verified BEFORE any publication — NOTHING was published, not the report and not the receipt, and
109
+ because the artifact found there belongs to another run it is never a '--no-receipt' source. 71: a
110
+ publication stopped after that point — either the report write failed (nothing beyond the
111
+ reservation was published; the '--no-receipt' absorb then records reportLength 0, ineligible by the
112
+ name empty-report) or the report IS on disk and the terminal receipt was not completed (the absorb
113
+ reads it, report-if-present). The post-report lane never claims the reservation still stands,
114
+ because after that point its fate is no longer something this run observed. Every lane names the
115
+ tree as partial/dirtied rather than untouched. A nonce-LESS invocation is byte-unchanged: no
116
+ reservation, no receipt, no artifact, no node.
117
+
70
118
  Notes:
71
119
  nested-sandbox limit: codex-exec ships its OWN OS sandbox (bwrap workspace-write) and cannot run
72
120
  nested inside a harness sandbox (the FS turns read-only) — route it OUTSIDE the harness sandbox
@@ -104,7 +152,7 @@ Settings file (KEY=VALUE, parsed never sourced; env wins over file, file wins ov
104
152
  CODEX_SERVICE_TIER — service tier: 'priority' (Fast — ~1.5x speed at a 2.5x credit rate on gpt-5.6-sol); a consented SPEND knob, default off (standard tier)
105
153
  CODEX_HARD_TIMEOUT — hard wall-clock cap, integer seconds 1..86400 (built-in default 3600)
106
154
 
107
- Environment: CODEX_HARD_TIMEOUT (seconds, default 3600), CODEX_PROBE=1 (throwaway probe only).
155
+ Environment: CODEX_HARD_TIMEOUT (seconds, default 3600), CODEX_PROBE=1 (throwaway probe only), AW_DISPATCH_NONCE (delegation dispatch nonce — mints the exec receipt; the --nonce <n> flag is its plain-argument equivalent), AW_DELEGATION_STORE (absolute delegation-store path; its dirname is where the receipt lands).
108
156
  Requires at run time: the codex CLI on PATH, a ChatGPT-subscription login, a git work tree with a root AGENTS.md (--help needs none of these).
109
157
  HELP
110
158
  exit 0
@@ -295,6 +343,17 @@ for _posture_pair in "CODEX_MODEL=$CODEX_MODEL" "CODEX_EFFORT=$CODEX_EFFORT" "CO
295
343
  exit 2
296
344
  fi
297
345
  done
346
+ # The delegation dispatch nonce seam (delegation Plan 2 / D11): ONE seam, the AW_DISPATCH_NONCE
347
+ # environment value and its plain-argument twin --nonce <n> (parsed below, after the mode selector).
348
+ # Validated pre-spend under the SAFE grammar — a nonce that would escape the derived artifact name
349
+ # refuses before any CLI run. The bracket expression ENUMERATES the ASCII set (no ranges): a range
350
+ # like A-Z is locale-collation-dependent and could admit a non-ASCII nonce the kit's JS reader then
351
+ # refuses. Byte-identical rule to codex-review.sh's AW_REVIEW_NONCE screen — one grammar, two lanes.
352
+ AW_NONCE_RE='^[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-]{1,64}$'
353
+ if [[ -n "${AW_DISPATCH_NONCE:-}" && ! "${AW_DISPATCH_NONCE}" =~ $AW_NONCE_RE ]]; then
354
+ echo "error: AW_DISPATCH_NONCE fails the safe nonce grammar ([A-Za-z0-9._-]{1,64}) — the derived receipt name would be unsafe; fix the nonce and re-run." >&2
355
+ exit 2
356
+ fi
298
357
  CODEX_HARD_TIMEOUT="$(aw_effective_timeout CODEX_HARD_TIMEOUT 3600)"
299
358
  if [[ -n "$CODEX_SERVICE_TIER" ]] && ! aw_settings_valid CODEX_SERVICE_TIER "$CODEX_SERVICE_TIER"; then
300
359
  echo "warning: CODEX_SERVICE_TIER='$CODEX_SERVICE_TIER' is not a supported service tier ('priority') — running on the standard tier." >&2
@@ -306,6 +365,19 @@ if [[ -n "$CODEX_SERVICE_TIER" ]]; then
306
365
  fi
307
366
  CHATGPT_LOGIN_GUARD="Logged in using ChatGPT"
308
367
 
368
+ # --- Exec-receipt identity (delegation Plan 2) --------------------------------
369
+ # AW_RECEIPT_BACKEND is the ledger's backend id (the value `dispatch open --backend` records), not
370
+ # the wrapper name — the artifact name is a function of {backend, nonce} alone on both sides.
371
+ # AW_BRIDGE_VERSION mirrors this bridge's SKILL.md/capability.json version and is stamped into every
372
+ # receipt this wrapper mints; scripts/release/version-sync.mjs bumps it under the one-anchor-per-file
373
+ # rule, so a release can never leave it behind (the AD-053 drift class).
374
+ AW_RECEIPT_BACKEND="codex"
375
+ AW_BRIDGE_VERSION="3.5.0" # aw-version-anchor
376
+ # The kill grace handed to timeout(1) as --kill-after, and recorded in the receipt as killGraceS:
377
+ # ONE constant, so the number the ledger checks against the dispatch deadline is the number the run
378
+ # actually applied.
379
+ CODEX_KILL_GRACE_S=15
380
+
309
381
  # --- Quality-first guard: refuse a silent model/effort downgrade ---------------
310
382
  # Real delegated runs must use the frontier model at max effort. A throwaway probe
311
383
  # (effort-independent result) may opt out with CODEX_PROBE=1, announced loudly.
@@ -443,6 +515,45 @@ case "${1:-}" in
443
515
  ;;
444
516
  esac
445
517
 
518
+ # --- --nonce <n> — the plain-argument lane onto the AW_DISPATCH_NONCE seam (D11) ---------------
519
+ # Recognised at exactly ONE position: AFTER the mode selector and BEFORE the prompt operand, so the
520
+ # three accepted forms are `codex-exec [--nonce <n>] <plan-file|->`,
521
+ # `codex-exec --resume-last [--nonce <n>] <plan-file|->` and
522
+ # `codex-exec --resume <session-id> [--nonce <n>] <plan-file|->`. A GLOBAL strip (the codex-review.sh
523
+ # shape) is wrong here: this wrapper's remaining args are the prompt operand and the `--` passthrough
524
+ # payload, and consuming a `--nonce` out of them would move that boundary — after the operand or a
525
+ # literal `--`, `--nonce` is payload, never a flag.
526
+ # Semantics are codex-review.sh's, exactly: the flag and a non-empty env value must AGREE (two
527
+ # disagreeing sources would mint an ambiguous dispatch identity — fail closed), a duplicate refuses
528
+ # (one dispatch carries one nonce), and the NEXT argument is taken unconditionally (only end-of-args
529
+ # refuses here) — the closed grammar is the single validity door, so a grammar-valid leading-dash
530
+ # value is legal, and an EMPTY value still hits the grammar screen.
531
+ nonce_flag=""
532
+ nonce_flag_set=0
533
+ while [[ "${1:-}" == "--nonce" ]]; do
534
+ if [[ "$nonce_flag_set" == "1" ]]; then
535
+ echo "error: duplicate --nonce — one dispatch carries one nonce." >&2
536
+ exit 2
537
+ fi
538
+ if [[ $# -lt 2 ]]; then
539
+ echo "error: --nonce needs a value; got '<end of args>'." >&2
540
+ exit 2
541
+ fi
542
+ nonce_flag="$2"; nonce_flag_set=1; shift 2
543
+ done
544
+ if [[ "$nonce_flag_set" == "1" ]]; then
545
+ if [[ ! "$nonce_flag" =~ $AW_NONCE_RE ]]; then
546
+ echo "error: --nonce fails the safe nonce grammar ([A-Za-z0-9._-]{1,64}) — the derived receipt name would be unsafe; fix the nonce and re-run." >&2
547
+ exit 2
548
+ fi
549
+ if [[ -n "${AW_DISPATCH_NONCE:-}" && "${AW_DISPATCH_NONCE}" != "$nonce_flag" ]]; then
550
+ echo "error: --nonce disagrees with the AW_DISPATCH_NONCE environment value — one dispatch carries one nonce; drop one source and re-run." >&2
551
+ exit 2
552
+ fi
553
+ AW_DISPATCH_NONCE="$nonce_flag"
554
+ fi
555
+ aw_nonce="${AW_DISPATCH_NONCE:-}"
556
+
446
557
  if [[ $# -lt 1 ]]; then
447
558
  echo "error: missing <plan-file|-> (the instruction to send)." >&2
448
559
  exit 2
@@ -553,6 +664,176 @@ if [[ -z "${task//[[:space:]]/}" ]]; then
553
664
  exit 2
554
665
  fi
555
666
 
667
+ # --- The ACCOUNTED lane: everything below fires only for a NONCED run ---------
668
+ # A nonce-LESS invocation is byte-unchanged — no reservation, no receipt, no artifact, no node —
669
+ # which is what keeps every existing caller working. The whole mint core rides ONE node script per
670
+ # step (the family floor, codex-review.sh's finding-manifest precedent): a bash re-implementation of
671
+ # atomic no-clobber publication, JSON composition and canonical digesting would be a second, drifting
672
+ # implementation of contracts the kit owns.
673
+ aw_owner=""
674
+ aw_contract_digest=""
675
+ aw_artifact_dir=""
676
+
677
+ # Each mint core rides a BUILTIN heredoc read — `IFS= read -r -d '' … <<'AW_JS'`, the idiom this
678
+ # wrapper already uses for its two prompt directives — rather than a `$(cat <<'AW_JS' …)` command
679
+ # substitution. That is not style: a failed `cat` (gone from PATH, shadowed, or replaced) makes the
680
+ # substitution EMPTY, `node -e ""` exits 0, and the wrapper would report a successful accounted run
681
+ # having published no report and no terminal receipt at all — silent, on the success path. The
682
+ # builtin cannot fail that way because there is no external binary left to fail. The assertion below
683
+ # is the belt: an empty core never runs, it refuses by name.
684
+ aw_require_core() {
685
+ [[ -n "$1" ]] && return 0
686
+ echo "error: $2 is EMPTY — refusing to run an empty program rather than reporting a run that published nothing." >&2
687
+ return 1
688
+ }
689
+
690
+ # The delegation store's directory, resolved EXACTLY as the kit resolves it
691
+ # (agent-workflow-kit/tools/dispatch-store.mjs): an ABSOLUTE AW_DELEGATION_STORE wins (its dirname),
692
+ # a relative one or one ending in a path separator refuses, otherwise the git common dir. Mirrored
693
+ # through node's own path primitives rather than re-derived in bash, so the two answers cannot drift
694
+ # on `..` segments, duplicate separators or a trailing dot — the cross-package parity test pins it.
695
+ aw_resolve_artifact_dir() {
696
+ local common=""
697
+ if [[ -z "${AW_DELEGATION_STORE:-}" ]]; then
698
+ if [[ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" != "true" ]]; then
699
+ echo "error: a nonced dispatch resolves its receipt beside the delegation store, and there is no store outside a git work tree (set AW_DELEGATION_STORE to an absolute path, or run from the repository)." >&2
700
+ return 1
701
+ fi
702
+ common="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)"
703
+ if [[ -z "$common" ]]; then
704
+ echo "error: could not resolve the git common dir — the receipt has nowhere to land; refusing before any spend." >&2
705
+ return 1
706
+ fi
707
+ fi
708
+ local aw_js=""
709
+ IFS= read -r -d '' aw_js <<'AW_JS' || true
710
+ const { isAbsolute, normalize, sep, join, dirname } = require("node:path");
711
+ const common = process.argv[1] || "";
712
+ const override = process.env.AW_DELEGATION_STORE || "";
713
+ let storePath;
714
+ if (override) {
715
+ if (!isAbsolute(override)) {
716
+ process.stderr.write("error: AW_DELEGATION_STORE must be an ABSOLUTE path (got \"" + override + "\") — a relative override resolves a different ledger from each worktree/cwd; refusing before any spend.\n");
717
+ process.exit(2);
718
+ }
719
+ const normalized = normalize(override);
720
+ if (normalized.endsWith(sep) || normalized.endsWith("/")) {
721
+ process.stderr.write("error: AW_DELEGATION_STORE must not end with a path separator (got \"" + override + "\") — a store is a file, not a directory; refusing before any spend.\n");
722
+ process.exit(2);
723
+ }
724
+ storePath = normalized;
725
+ } else {
726
+ storePath = join(common, "agent-workflow-delegation.jsonl");
727
+ }
728
+ process.stdout.write(dirname(storePath));
729
+ AW_JS
730
+ aw_require_core "$aw_js" "the store-directory resolver" || return 1
731
+ NODE_OPTIONS= node -e "$aw_js" "$common"
732
+ }
733
+
734
+ # The contract header this run is accountable for: its `nonce` and its `contractDigest`, both read
735
+ # from the bytes ALREADY loaded as the prompt (fed on stdin) rather than from a second open of the
736
+ # path. That single read is load-bearing: two reads leave a window in which the file can be swapped,
737
+ # and the run would then execute contract A while its receipt claimed the digest of B — a return the
738
+ # ledger would accept. Dropping the trailing newlines (`$( )` does) cannot move the digest: the digest
739
+ # is taken over the PARSED object and the fence walk is line-based, so anything after the closing
740
+ # fence is outside the block either way.
741
+ #
742
+ # contractDigest is sha256 over the CANONICAL serialization (recursively key-sorted JSON, no trailing
743
+ # newline) of the ONE top-level ```aw-dispatch-contract block. Independently produced HERE: without
744
+ # it the ledger would compare a dispatch record against values derived from itself, and a run that
745
+ # executed a DIFFERENT contract would correlate cleanly. The fence walk mirrors the kit's
746
+ # extractContractBlock — nesting-aware, so a contract marker inside another fenced block is example
747
+ # text; both line endings accepted. FORM validation is deliberately NOT duplicated here: the kit's
748
+ # `open` is the single form door, and a second one would drift. The ONE field validated is `nonce`,
749
+ # because this wrapper COMPARES it — a value outside the safe grammar could not be compared or named.
750
+ aw_compute_contract_header() {
751
+ local aw_js=""
752
+ IFS= read -r -d '' aw_js <<'AW_JS' || true
753
+ const fs = require("node:fs");
754
+ const { createHash } = require("node:crypto");
755
+ const INFO = "aw-dispatch-contract";
756
+ const FENCE = /^(`{3,})(.*)$/;
757
+ const SAFE = /^[A-Za-z0-9._-]{1,64}$/;
758
+ const die = (m) => { process.stderr.write("error: dispatch contract — " + m + ".\n"); process.exit(2); };
759
+ let text;
760
+ try { text = fs.readFileSync(0, "utf8"); } catch (err) { die("cannot read the dispatch contract bytes (" + (err && err.code) + ")"); }
761
+ const lines = text.split(/\r?\n/);
762
+ const blocks = [];
763
+ let openTicks = 0, openInfo = "", openAt = -1;
764
+ for (let i = 0; i < lines.length; i += 1) {
765
+ const fence = FENCE.exec(lines[i]);
766
+ if (fence === null) continue;
767
+ const ticks = fence[1].length;
768
+ const info = fence[2].trim();
769
+ if (openTicks === 0) { openTicks = ticks; openInfo = info; openAt = i; continue; }
770
+ if (info === "" && ticks >= openTicks) {
771
+ if (openInfo === INFO) blocks.push(lines.slice(openAt + 1, i).join("\n"));
772
+ openTicks = 0; openInfo = ""; openAt = -1;
773
+ }
774
+ }
775
+ if (openTicks !== 0 && openInfo === INFO) die("the ```" + INFO + " block is never closed");
776
+ if (blocks.length === 0) die("no top-level ```" + INFO + " block found — an accounted dispatch runs a contract");
777
+ if (blocks.length > 1) die(blocks.length + " ```" + INFO + " blocks found — a dispatch file carries exactly one");
778
+ let contract;
779
+ try { contract = JSON.parse(blocks[0]); } catch { die("the ```" + INFO + " block body is not valid JSON"); }
780
+ if (contract === null || typeof contract !== "object" || Array.isArray(contract)) die("the ```" + INFO + " block body must be ONE JSON object");
781
+ if (typeof contract.nonce !== "string" || !SAFE.test(contract.nonce)) die("the header carries no nonce in the safe grammar ([A-Za-z0-9._-]{1,64}) — an accounted dispatch is identified by the nonce the ledger copied from this header");
782
+ const ser = (v) => (Array.isArray(v)
783
+ ? "[" + v.map(ser).join(",") + "]"
784
+ : (v !== null && typeof v === "object")
785
+ ? "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + ser(v[k])).join(",") + "}"
786
+ : JSON.stringify(v));
787
+ process.stdout.write(contract.nonce + "\n" + createHash("sha256").update(ser(contract), "utf8").digest("hex"));
788
+ AW_JS
789
+ aw_require_core "$aw_js" "the contract-header reader" || return 1
790
+ printf '%s' "$1" | NODE_OPTIONS= node -e "$aw_js"
791
+ }
792
+
793
+ if [[ -n "$aw_nonce" ]]; then
794
+ if ! command -v node >/dev/null 2>&1; then
795
+ echo "error: a nonced dispatch mints an exec receipt, and its mint core needs 'node' on PATH — refusing before any spend." >&2
796
+ echo " (a nonce-less run needs no node at all)" >&2
797
+ exit 2
798
+ fi
799
+ # The digest binds the FILE the kit's `dispatch open --contract` read. A prompt arriving on stdin
800
+ # is not that file — there would be nothing to bind — so the accounted lane refuses it by name
801
+ # rather than digesting a stream nobody can re-read.
802
+ if [[ "$prompt_src" == "-" ]]; then
803
+ echo "error: a nonced dispatch runs a contract FILE, not stdin — contractDigest binds the dispatch file this run was handed, and '-' leaves nothing for the ledger to bind; pass the same file 'dispatch open --contract' read." >&2
804
+ exit 2
805
+ fi
806
+ # The resolved directory crosses back through a SENTINEL, not a bare command substitution: `$( )`
807
+ # strips every trailing newline, while the kit's own reader strips exactly ONE (git's terminator,
808
+ # flow-store-read.mjs:32-35). For a directory whose name ends in a newline the two sides would then
809
+ # resolve different paths, and the wrapper would write beside a ledger the kit never reads. Appending
810
+ # `x` inside the substitution makes the last byte a non-newline; only that final sentinel is
811
+ # stripped, so a directory whose own name ends in `x` still round-trips. A refusing resolver prints
812
+ # nothing on stdout, so the missing sentinel IS the failure signal.
813
+ aw_dir_raw=""
814
+ aw_dir_raw="$(aw_resolve_artifact_dir && printf x)" || true
815
+ if [[ "$aw_dir_raw" != *x ]]; then
816
+ echo " (the receipt has nowhere to land — refusing before any spend)" >&2
817
+ exit 2
818
+ fi
819
+ aw_artifact_dir="${aw_dir_raw%x}"
820
+ aw_header=""
821
+ if ! aw_header="$(aw_compute_contract_header "$task")"; then
822
+ echo "error: the prompt '$prompt_src' carries no readable dispatch contract (see above) — an accounted dispatch is refused before any spend." >&2
823
+ exit 2
824
+ fi
825
+ aw_contract_nonce="${aw_header%%$'\n'*}"
826
+ aw_contract_digest="${aw_header#*$'\n'}"
827
+ # The ledger's nonce IS the header's — `dispatch open` COPIES it — so a --nonce disagreeing with the
828
+ # header can only mint an artifact no return will ever absorb, after paying for the run. Refuse it
829
+ # here, where it is still free. Only the accounted lane compares: a nonce-LESS run of an ordinary
830
+ # plan file that happens to carry a contract block is none of this seam's business.
831
+ if [[ "$aw_contract_nonce" != "$aw_nonce" ]]; then
832
+ echo "error: the dispatch nonce '$aw_nonce' does not match the contract header's nonce '$aw_contract_nonce' — 'dispatch open' copies the nonce FROM the header, so this run would reserve an identity no return could absorb; nothing was reserved and nothing was spent." >&2
833
+ exit 2
834
+ fi
835
+ fi
836
+
556
837
  # --- Enforced git-write boundary (physical shim file) ------------------------
557
838
  # codex spawns `git` via execve, which BYPASSES exported shell functions — so the
558
839
  # boundary MUST be a physical executable on PATH. We write a `git` shim into a temp
@@ -563,11 +844,20 @@ fi
563
844
  # (codex could still call git by an absolute path — the contract + review are the
564
845
  # real guard); it removes the trivial PATH-level write vector.
565
846
  shim_dir=""
847
+ out_dir=""
566
848
  out=""
567
849
  trace=""
568
- trap 'rm -rf "$shim_dir" 2>/dev/null; rm -f "$out" "$trace" 2>/dev/null; true' EXIT
850
+ trap 'rm -rf "$shim_dir" "$out_dir" 2>/dev/null; rm -f "$trace" 2>/dev/null; true' EXIT
569
851
  shim_dir="$(mktemp -d)"
570
- out="$(mktemp)"
852
+ # `-o` gets a path that does NOT exist yet, inside a private directory. A pre-created file (the old
853
+ # `mktemp`) made "the delegate produced no final message" indistinguishable from "the delegate
854
+ # produced an EMPTY one" — both read back as zero bytes — so an accounted run could publish a
855
+ # `success` receipt describing a report the run never wrote, while its own stderr said there was no
856
+ # final message. The real CLI CREATES this path when it has something to write (probed against
857
+ # codex-cli directly: exit 0, the file appeared with the answer in it) — the fake accepts any argv, so
858
+ # only the real one could answer that.
859
+ out_dir="$(mktemp -d)"
860
+ out="$out_dir/final-message.txt"
571
861
  trace="$(mktemp)"
572
862
  {
573
863
  printf '#!/usr/bin/env bash\n'
@@ -676,6 +966,16 @@ run_env=(env "PATH=$shim_dir:$PATH")
676
966
  # absolute path the banner rendered from (banner and run never make independent conclusions).
677
967
  timeout_bin="$(aw_resolve_timeout_bin)"
678
968
  if [[ -z "$timeout_bin" ]]; then
969
+ # D8 — an ACCOUNTED dispatch that cannot be capped can never honour the terminal-exit rule: the
970
+ # ledger records capS + killGraceS and refuses a dispatch whose deadline is below their sum, so a
971
+ # run with no cap at all would put an unbounded run under a bounded deadline, and the waiter would
972
+ # report an expiry while the run was still legitimately alive. A nonce-LESS run keeps the existing
973
+ # behaviour exactly — warn loudly and run uncapped.
974
+ if [[ -n "$aw_nonce" ]]; then
975
+ echo "error: no 'timeout'/'gtimeout' on PATH — a nonced dispatch refuses to run uncapped (an accounted run that cannot be capped can never honour the terminal-exit rule)." >&2
976
+ echo " Install coreutils, or dispatch without a nonce; nothing was reserved and nothing was spent." >&2
977
+ exit 2
978
+ fi
679
979
  echo "warning: no 'timeout'/'gtimeout' on PATH — running codex WITHOUT a hard wall-clock cap" >&2
680
980
  echo " (install coreutils to enable CODEX_HARD_TIMEOUT=$CODEX_HARD_TIMEOUT)." >&2
681
981
  fi
@@ -689,6 +989,99 @@ aw_session_label="fresh"
689
989
  [[ -n "$resume_mode" ]] && aw_session_label="resume:$resume_id"
690
990
  echo "exec posture: model=$CODEX_MODEL effort=$CODEX_EFFORT tier=${CODEX_SERVICE_TIER:-standard} sandbox=workspace-write session=$aw_session_label timeout=$aw_timeout_banner" >&2
691
991
 
992
+ # --- The PRE-SPEND reservation (delegation Plan 2 / D1) ------------------------
993
+ # Written immediately before the CLI runs — after EVERY preflight and after the posture banner, so a
994
+ # refused run leaves no reservation behind — and it is the reservation itself, not a separate lock,
995
+ # that makes one nonce mean one dispatch: the publish is atomic and NO-CLOBBER, so a second dispatch
996
+ # on the same nonce refuses BEFORE any spend. Both derived names are checked, because the kit's
997
+ # `dispatch open` refuses on either too: a leftover report would otherwise be absorbed later as this
998
+ # run's own evidence.
999
+ #
1000
+ # The mint core is codex-review.sh's finding-manifest shape: an UNPREDICTABLE sibling temp opened
1001
+ # "wx" (O_CREAT|O_EXCL — a planted node at the name refuses, and ONLY a temp we provably created is
1002
+ # ever unlinked), then a hard-link publish (atomic, no-clobber). The owner token is minted here and
1003
+ # returned on stdout: it is what the terminal publication verifies it still holds.
1004
+ aw_write_reservation() {
1005
+ local aw_js=""
1006
+ IFS= read -r -d '' aw_js <<'AW_JS' || true
1007
+ const fs = require("node:fs");
1008
+ const { join, dirname, basename } = require("node:path");
1009
+ const { randomBytes } = require("node:crypto");
1010
+ const [dir, backend, nonce, contractDigest, wrapperVersion, model, effort, tier, capS, killGraceS] = process.argv.slice(1);
1011
+ const name = (prefix, suffix) => join(dir, prefix + backend.length + "-" + backend + "-" + nonce + suffix);
1012
+ const receipt = name("agent-workflow-exec-receipt-", ".json");
1013
+ const report = name("agent-workflow-exec-report-", ".txt");
1014
+ for (const path of [receipt, report]) {
1015
+ try {
1016
+ fs.lstatSync(path);
1017
+ process.stderr.write("error: an exec artifact for this {backend, nonce} already exists at " + path + " — one nonce, one dispatch: refusing PRE-SPEND (remove it, or dispatch under a fresh nonce).\n");
1018
+ process.exit(3);
1019
+ } catch (err) {
1020
+ if (!err || err.code !== "ENOENT") {
1021
+ process.stderr.write("error: could not probe " + path + " (" + (err && err.code) + ") — refusing PRE-SPEND.\n");
1022
+ process.exit(3);
1023
+ }
1024
+ }
1025
+ }
1026
+ const owner = randomBytes(16).toString("hex");
1027
+ const bytes = Buffer.from(JSON.stringify({
1028
+ schema: 1,
1029
+ kind: "exec-receipt",
1030
+ state: "reserved",
1031
+ backend,
1032
+ nonce,
1033
+ owner,
1034
+ contractDigest,
1035
+ wrapperVersion,
1036
+ posture: { model, effort, tier: tier || null },
1037
+ capS: Number(capS),
1038
+ killGraceS: Number(killGraceS),
1039
+ sessionId: null,
1040
+ exitStatus: null,
1041
+ outcome: null,
1042
+ reportDigest: null,
1043
+ reportLength: null,
1044
+ timestamp: new Date().toISOString(),
1045
+ }) + "\n");
1046
+ let tmp = null;
1047
+ let code = 1;
1048
+ try {
1049
+ const candidate = join(dirname(receipt), "." + basename(receipt) + "." + process.pid + "." + randomBytes(8).toString("hex") + ".tmp");
1050
+ const fd = fs.openSync(candidate, "wx", 0o600);
1051
+ tmp = candidate;
1052
+ try { fs.writeFileSync(fd, bytes); } finally { fs.closeSync(fd); }
1053
+ fs.linkSync(tmp, receipt);
1054
+ code = 0;
1055
+ } catch (err) {
1056
+ process.stderr.write("error: could not publish the nonce reservation at " + receipt + " (" + (err && err.code) + ") — refusing PRE-SPEND.\n");
1057
+ code = err && err.code === "EEXIST" ? 3 : 1;
1058
+ }
1059
+ // An orphan temp never blocks the run — but it is never silent either: it sits in the store
1060
+ // directory looking like family state, and only the run that made it knows it is debris.
1061
+ if (tmp !== null) {
1062
+ try { fs.unlinkSync(tmp); } catch {
1063
+ process.stderr.write("warning: the reservation was published, but its temporary sibling could not be removed — orphan left at: " + tmp + " — remove it by hand.\n");
1064
+ }
1065
+ }
1066
+ if (code === 0) process.stdout.write(owner);
1067
+ process.exit(code);
1068
+ AW_JS
1069
+ aw_require_core "$aw_js" "the reservation mint core" || return 1
1070
+ NODE_OPTIONS= node -e "$aw_js" "$aw_artifact_dir" "$AW_RECEIPT_BACKEND" "$aw_nonce" "$aw_contract_digest" "$AW_BRIDGE_VERSION" \
1071
+ "$CODEX_MODEL" "$CODEX_EFFORT" "${CODEX_SERVICE_TIER:-}" "$CODEX_HARD_TIMEOUT" "$CODEX_KILL_GRACE_S"
1072
+ }
1073
+
1074
+ if [[ -n "$aw_nonce" ]]; then
1075
+ if ! aw_owner="$(aw_write_reservation)"; then
1076
+ echo " (the reservation is the pre-spend half of the dispatch identity — nothing was spent)" >&2
1077
+ exit 2
1078
+ fi
1079
+ if [[ -z "$aw_owner" ]]; then
1080
+ echo "error: the nonce reservation published no owner token — refusing PRE-SPEND rather than running a dispatch nothing can claim." >&2
1081
+ exit 2
1082
+ fi
1083
+ fi
1084
+
692
1085
  # --- Nested-sandbox evidence scan: ONE entry point, TWO policies ---------------
693
1086
  # The class: codex ships its OWN OS sandbox (bwrap); run nested inside a harness sandbox the FS is
694
1087
  # read-only and codex's sandbox setup fails. The scan runs on EVERY completed run, not only a failed
@@ -814,38 +1207,237 @@ aw_scan_nested_sandbox() { # $1 = rc, $2 = trace path
814
1207
  # including the evidence scan — is genuinely shared instead of mode-dependent.
815
1208
  set +e
816
1209
  if [[ -n "$timeout_bin" ]]; then
817
- printf '%s' "$full_prompt" | "${run_env[@]}" "$timeout_bin" --kill-after=15s "$CODEX_HARD_TIMEOUT" "${codex_cmd[@]}" >"$trace" 2>&1
1210
+ printf '%s' "$full_prompt" | "${run_env[@]}" "$timeout_bin" --kill-after="${CODEX_KILL_GRACE_S}s" "$CODEX_HARD_TIMEOUT" "${codex_cmd[@]}" >"$trace" 2>&1
818
1211
  else
819
1212
  printf '%s' "$full_prompt" | "${run_env[@]}" "${codex_cmd[@]}" >"$trace" 2>&1
820
1213
  fi
821
1214
  rc=$?
822
1215
  set -e
823
1216
 
1217
+ # --- The session id, captured BEFORE any outcome branching (D3) ----------------
1218
+ # It used to be extracted only on the SUCCESS path, after the timeout and failure exits, so a run
1219
+ # that failed or timed out recorded no identity at all — and the session id is exactly the handle an
1220
+ # operator needs to find such a run in the backend's own history. In resume mode it is the resume id,
1221
+ # validated pre-spend. A fresh run that emitted no thread.started leaves it empty, which is the
1222
+ # receipt's `missing-identity` outcome, never a guess.
1223
+ session_id=""
1224
+ if [[ -n "$resume_mode" ]]; then
1225
+ session_id="$resume_id"
1226
+ else
1227
+ session_id="$(grep -m1 '"type":"thread.started"' "$trace" 2>/dev/null \
1228
+ | grep -o '"thread_id":"[^"]*"' | cut -d'"' -f4 || true)"
1229
+ fi
1230
+
1231
+ # --- The TERMINAL exec receipt, fail-closed (D1 / 3.1.d) -----------------------
1232
+ # Publication ORDER, and it is the whole point: verify the reservation is still OURS (so a tampered
1233
+ # reservation costs no overwritten artifact — nothing at all is published), write the REPORT
1234
+ # atomically, re-verify the owner, then REPLACE the reservation with the terminal receipt. An
1235
+ # artifact that has arrived therefore always has a complete report behind it.
1236
+ #
1237
+ # FAIL-CLOSED, deliberately unlike codex-review.sh's warn-only receipt (a missing review receipt only
1238
+ # fails a checker; a missing exec receipt leaves an EDITED tree with no accounting). Two statuses:
1239
+ # 70 the reservation could not be verified as this run's — NOTHING was published; 71 a publication
1240
+ # could not complete, and the message names WHICH artifact stopped. In both the RESERVATION survives,
1241
+ # so the recovery is the same door — `dispatch return --no-receipt` — while what the kit then reads
1242
+ # differs and stays two separate lanes: a failed REPORT write leaves the reservation alone (the
1243
+ # absorb records reportLength 0 and the metric is ineligible by the name `empty-report`), a failed
1244
+ # TERMINAL write leaves the published report beside it (the absorb reads it, report-if-present).
1245
+ aw_publish_terminal_receipt() {
1246
+ local aw_js=""
1247
+ IFS= read -r -d '' aw_js <<'AW_JS' || true
1248
+ const fs = require("node:fs");
1249
+ const { join, dirname, basename } = require("node:path");
1250
+ const { createHash, randomBytes } = require("node:crypto");
1251
+ const [dir, backend, nonce, owner, exitStatusRaw, sessionIdRaw, reportSrc] = process.argv.slice(1);
1252
+ const name = (prefix, suffix) => join(dir, prefix + backend.length + "-" + backend + "-" + nonce + suffix);
1253
+ const receipt = name("agent-workflow-exec-receipt-", ".json");
1254
+ const report = name("agent-workflow-exec-report-", ".txt");
1255
+ const exitStatus = Number(exitStatusRaw);
1256
+ const sessionId = sessionIdRaw === "" ? null : sessionIdRaw;
1257
+ // D3, the wrapper OUTCOME SUBSET — the only three a run can prove about itself.
1258
+ const outcome = exitStatus !== 0 ? "transport-failure" : (sessionId === null ? "missing-identity" : "success");
1259
+ const die = (code, message) => { process.stderr.write("error: " + message + "\n"); process.exit(code); };
1260
+ // The reservation is re-read and re-verified BEFORE anything at all is published: a foreign owner,
1261
+ // a missing artifact or a terminal one means this run does not hold the nonce, and the honest answer
1262
+ // is to publish NOTHING — neither the report nor the receipt.
1263
+ // `code` differs by WHEN the check runs: before any publication a refusal is total (4), after the
1264
+ // report is on disk it is not (6) — and the message must not claim otherwise.
1265
+ // The publication consequence is identical for every cause, but the DIAGNOSIS is not: "another run
1266
+ // owns this" is a specific claim, and asserting it over a malformed file, a vanished one or an I/O
1267
+ // error sends the operator hunting for a second dispatch that never existed. Each cause is named.
1268
+ const claim = (when, code) => {
1269
+ let raw;
1270
+ try {
1271
+ raw = fs.readFileSync(receipt, "utf8");
1272
+ } catch (err) {
1273
+ const why = err && err.code === "ENOENT"
1274
+ ? "it is GONE — something removed it after this run published it"
1275
+ : "it could not be read (" + (err && err.code ? err.code : "unknown error") + ")";
1276
+ die(code, "the nonce reservation at " + receipt + " cannot be verified " + when + ": " + why);
1277
+ }
1278
+ let held;
1279
+ try {
1280
+ held = JSON.parse(raw);
1281
+ } catch {
1282
+ die(code, "the nonce reservation at " + receipt + " cannot be verified " + when + ": it is MALFORMED — the bytes there are not valid JSON, so nothing about its ownership is readable");
1283
+ }
1284
+ if (held === null || typeof held !== "object" || Array.isArray(held)) {
1285
+ die(code, "the nonce reservation at " + receipt + " cannot be verified " + when + ": its body is not a JSON object");
1286
+ }
1287
+ if (held.state !== "reserved") {
1288
+ die(code, "the artifact at " + receipt + " is not a RESERVATION " + when + ": its state is " + JSON.stringify(held.state) + " — this run holds no claim on a receipt something else has already finished");
1289
+ }
1290
+ if (held.owner !== owner) {
1291
+ die(code, "the artifact at " + receipt + " is not this run’s reservation " + when + ": its owner token belongs to ANOTHER run");
1292
+ }
1293
+ return held;
1294
+ };
1295
+ const held = claim("before any publication", 4);
1296
+ // THREE cases, decided by lstat BEFORE any read — because `readFileSync` FOLLOWS a symlink, so a
1297
+ // DANGLING one at the capture path reports ENOENT and would be recorded as a clean empty report on a
1298
+ // `success` receipt. ABSENT (no entry at all) is the legitimate "the delegate wrote no final message"
1299
+ // case: the sha256 of no bytes, exactly what the receipt contract expresses. A REGULAR file is read.
1300
+ // Anything else PRESENT — a symlink, a directory, a device — is a FAILED probe, never an absence.
1301
+ // Type only: the capture lives in a directory this run created with `mktemp -d`, so a mode or
1302
+ // ownership check would be theatre against a threat model this wrapper already declares out of scope,
1303
+ // while the type check catches the real accident. Same discipline as the kit's readRegularFileNoFollow.
1304
+ let bytes;
1305
+ if (reportSrc === "") {
1306
+ bytes = Buffer.alloc(0);
1307
+ } else {
1308
+ let stat = null;
1309
+ try {
1310
+ stat = fs.lstatSync(reportSrc);
1311
+ } catch (err) {
1312
+ if (!err || err.code !== "ENOENT") {
1313
+ die(5, "the delegate’s final message at " + reportSrc + " could not be probed (" + (err && err.code) + ") — a failed probe is not an absence, and recording it as an empty report would put bytes in the receipt that the run never produced");
1314
+ }
1315
+ }
1316
+ if (stat === null) {
1317
+ bytes = Buffer.alloc(0);
1318
+ } else if (!stat.isFile()) {
1319
+ const kind = stat.isSymbolicLink() ? "symlink" : stat.isDirectory() ? "directory" : "non-regular entry";
1320
+ die(5, "the delegate’s final message at " + reportSrc + " is a " + kind + ", not a regular file — a CORRUPT capture is a FAILED probe, not an absent one (a dangling symlink reads as ENOENT and would otherwise be recorded as a clean empty report)");
1321
+ } else {
1322
+ try {
1323
+ bytes = fs.readFileSync(reportSrc);
1324
+ } catch (err) {
1325
+ die(5, "the delegate’s final message at " + reportSrc + " could not be read (" + (err && err.code) + ") — an unreadable capture is a FAILED probe, not an absent one");
1326
+ }
1327
+ }
1328
+ }
1329
+ // Both publications are atomic: an unpredictable sibling temp, then a rename that either replaces
1330
+ // the target whole or leaves it untouched. A reader never sees half a report or half a receipt.
1331
+ const publishAtomically = (path, payload, code, what) => {
1332
+ let tmp = null;
1333
+ try {
1334
+ const candidate = join(dirname(path), "." + basename(path) + "." + process.pid + "." + randomBytes(8).toString("hex") + ".tmp");
1335
+ const fd = fs.openSync(candidate, "wx", 0o600);
1336
+ tmp = candidate;
1337
+ try { fs.writeFileSync(fd, payload); } finally { fs.closeSync(fd); }
1338
+ fs.renameSync(tmp, path);
1339
+ tmp = null;
1340
+ } catch (err) {
1341
+ if (tmp !== null) { try { fs.unlinkSync(tmp); } catch { /* an orphan temp never masks the real failure */ } }
1342
+ die(code, what + " could not be published at " + path + " (" + (err && err.code) + ")");
1343
+ }
1344
+ };
1345
+ publishAtomically(report, bytes, 5, "the delegate’s report");
1346
+ claim("immediately before the terminal replace", 6);
1347
+ publishAtomically(receipt, Buffer.from(JSON.stringify({
1348
+ schema: held.schema,
1349
+ kind: held.kind,
1350
+ state: "terminal",
1351
+ backend: held.backend,
1352
+ nonce: held.nonce,
1353
+ owner: held.owner,
1354
+ contractDigest: held.contractDigest,
1355
+ wrapperVersion: held.wrapperVersion,
1356
+ posture: held.posture,
1357
+ capS: held.capS,
1358
+ killGraceS: held.killGraceS,
1359
+ sessionId,
1360
+ exitStatus,
1361
+ outcome,
1362
+ reportDigest: createHash("sha256").update(bytes).digest("hex"),
1363
+ reportLength: bytes.length,
1364
+ timestamp: new Date().toISOString(),
1365
+ }) + "\n"), 6, "the terminal exec receipt");
1366
+
1367
+ process.stdout.write(outcome);
1368
+ AW_JS
1369
+ aw_require_core "$aw_js" "the terminal publication core" || return 7
1370
+ NODE_OPTIONS= node -e "$aw_js" "$aw_artifact_dir" "$AW_RECEIPT_BACKEND" "$aw_nonce" "$aw_owner" "$1" "$session_id" "$out"
1371
+ }
1372
+
1373
+ # The failure diagnostics print BEFORE the receipt is published, and the exits happen AFTER: a
1374
+ # publication that fails must not swallow the trace tail that explains WHY the run failed. Both are
1375
+ # explicitly NON-FATAL: under `set -e` a diagnostic that cannot run (no `tail` on PATH, an unreadable
1376
+ # trace) would kill the wrapper BEFORE the terminal publication, stranding the reservation with no
1377
+ # recovery status at all — the diagnostics are the least important thing here and must never be the
1378
+ # thing that decides whether the run gets accounted.
824
1379
  if [[ $rc -eq 124 || $rc -eq 137 ]]; then
825
1380
  echo "error: codex exec exceeded the hard cap CODEX_HARD_TIMEOUT=${CODEX_HARD_TIMEOUT}s and was terminated." >&2
826
1381
  echo " Raise CODEX_HARD_TIMEOUT for a known-healthy slow run, or narrow the task, then re-dispatch." >&2
827
- exit $rc
1382
+ elif [[ $rc -ne 0 ]]; then
1383
+ echo "error: codex exec failed (exit $rc). Last lines of the run trace:" >&2
1384
+ tail -n 40 "$trace" >&2 || true
1385
+ aw_scan_nested_sandbox "$rc" "$trace" || true
828
1386
  fi
1387
+
1388
+ if [[ -n "$aw_nonce" ]]; then
1389
+ aw_outcome=""
1390
+ aw_publish_rc=0
1391
+ aw_outcome="$(aw_publish_terminal_receipt "$rc")" || aw_publish_rc=$?
1392
+ if [[ $aw_publish_rc -ne 0 ]]; then
1393
+ echo " The delegate ran and the working tree may be PARTIALLY EDITED — treat it as dirtied, not as" >&2
1394
+ echo " untouched." >&2
1395
+ # What SURVIVES differs per lane, and the message says only what this run can still prove. After
1396
+ # the report is on disk the reservation's fate is no longer known to us (it was already not ours,
1397
+ # or the replace failed for a reason that may also have removed it) — so that lane never claims
1398
+ # "the reservation stands".
1399
+ case "$aw_publish_rc" in
1400
+ 7)
1401
+ echo " The publisher never RAN — nothing was published. The reservation is untouched; absorb with:" >&2
1402
+ echo " dispatch return --nonce $aw_nonce --no-receipt --exit-status $rc --outcome <o>" >&2
1403
+ exit 71
1404
+ ;;
1405
+ 4)
1406
+ echo " NOTHING was published — not the report, not the receipt. The artifact at the receipt path" >&2
1407
+ echo " is not this run's reservation, so establish what replaced it BEFORE absorbing anything:" >&2
1408
+ echo " a --no-receipt absorb would source its posture from another run's artifact." >&2
1409
+ exit 70
1410
+ ;;
1411
+ 5)
1412
+ echo " Nothing beyond the reservation was published. Absorb the thread with:" >&2
1413
+ echo " dispatch return --nonce $aw_nonce --no-receipt --exit-status $rc --outcome <o>" >&2
1414
+ echo " (with no report on disk it records reportLength 0 — ineligible by the name empty-report)" >&2
1415
+ exit 71
1416
+ ;;
1417
+ *)
1418
+ echo " The REPORT is published; the terminal receipt was NOT completed. Absorb the thread with:" >&2
1419
+ echo " dispatch return --nonce $aw_nonce --no-receipt --exit-status $rc --outcome <o>" >&2
1420
+ echo " (it reads the published report, report-if-present), and check the receipt path by hand." >&2
1421
+ exit 71
1422
+ ;;
1423
+ esac
1424
+ fi
1425
+ echo "exec receipt: nonce=$aw_nonce outcome=$aw_outcome exit=$rc session=${session_id:-none} → $aw_artifact_dir" >&2
1426
+ fi
1427
+
829
1428
  if [[ $rc -ne 0 ]]; then
830
- echo "error: codex exec failed (exit $rc). Last lines of the run trace:" >&2
831
- tail -n 40 "$trace" >&2
832
- aw_scan_nested_sandbox "$rc" "$trace"
833
1429
  exit $rc
834
1430
  fi
835
1431
 
836
- # Success: capture the session id (NORMAL mode only — it carries thread.started in
837
- # the JSON trace; a resume continues the same session) BEFORE the trap removes the
838
- # trace, so an iterative resume (codex-exec --resume-last) can find it.
839
- if [[ -z "$resume_mode" ]]; then
840
- session_id="$(grep -m1 '"type":"thread.started"' "$trace" 2>/dev/null \
841
- | grep -o '"thread_id":"[^"]*"' | cut -d'"' -f4 || true)"
842
- if [[ -n "$session_id" ]]; then
843
- sidecar="${CODEX_SESSION_FILE:-$PWD/.codex-last-session}"
844
- if ! printf '%s\n' "$session_id" >"$sidecar" 2>/dev/null; then
845
- echo "warning: could not write the session sidecar '$sidecar' — 'codex-exec --resume-last' won't find this id." >&2
846
- fi
847
- echo "session: $session_id" >&2
1432
+ # Success: record the session id in the sidecar (NORMAL mode only — a resume continues the same
1433
+ # session) BEFORE the trap removes the trace, so an iterative resume (codex-exec --resume-last) can
1434
+ # find it. The id itself was captured above, for every outcome; only the sidecar is success-scoped.
1435
+ if [[ -z "$resume_mode" && -n "$session_id" ]]; then
1436
+ sidecar="${CODEX_SESSION_FILE:-$PWD/.codex-last-session}"
1437
+ if ! printf '%s\n' "$session_id" >"$sidecar" 2>/dev/null; then
1438
+ echo "warning: could not write the session sidecar '$sidecar' — 'codex-exec --resume-last' won't find this id." >&2
848
1439
  fi
1440
+ echo "session: $session_id" >&2
849
1441
  fi
850
1442
 
851
1443
  if [[ -f "$out" && -s "$out" ]]; then