@kontourai/flow-agents 4.1.0 → 4.2.1

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 (108) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/build/src/builder-flow-runtime.js +10 -5
  3. package/context/contracts/verification-contract.md +6 -0
  4. package/context/scripts/telemetry/lib/config.sh +29 -0
  5. package/dist/base/build/package.json +1 -1
  6. package/dist/base/build/src/builder-flow-runtime.js +10 -5
  7. package/dist/base/context/contracts/verification-contract.md +6 -0
  8. package/dist/base/context/scripts/telemetry/lib/config.sh +29 -0
  9. package/dist/base/evals/acceptance/prove-capture-teeth.sh +24 -0
  10. package/dist/base/evals/integration/test_effective_backlog_settings.sh +118 -15
  11. package/dist/base/evals/integration/test_liveness_console_relay.sh +91 -1
  12. package/dist/base/evals/integration/test_liveness_heartbeat.sh +66 -0
  13. package/dist/base/install.sh +1 -1
  14. package/dist/base/scripts/hooks/claude-telemetry-hook.js +6 -0
  15. package/dist/base/scripts/hooks/lib/codex-exit-code.js +75 -23
  16. package/dist/base/scripts/hooks/lib/liveness-heartbeat.js +42 -1
  17. package/dist/base/scripts/hooks/lib/liveness-write.js +109 -9
  18. package/dist/base/scripts/liveness/relay.sh +23 -11
  19. package/dist/base/scripts/telemetry/lib/config.sh +29 -0
  20. package/dist/claude-code/build/package.json +1 -1
  21. package/dist/claude-code/build/src/builder-flow-runtime.js +10 -5
  22. package/dist/claude-code/context/contracts/verification-contract.md +6 -0
  23. package/dist/claude-code/context/scripts/telemetry/lib/config.sh +29 -0
  24. package/dist/claude-code/evals/acceptance/prove-capture-teeth.sh +24 -0
  25. package/dist/claude-code/evals/integration/test_effective_backlog_settings.sh +118 -15
  26. package/dist/claude-code/evals/integration/test_liveness_console_relay.sh +91 -1
  27. package/dist/claude-code/evals/integration/test_liveness_heartbeat.sh +66 -0
  28. package/dist/claude-code/install.sh +1 -1
  29. package/dist/claude-code/scripts/hooks/claude-telemetry-hook.js +6 -0
  30. package/dist/claude-code/scripts/hooks/lib/codex-exit-code.js +75 -23
  31. package/dist/claude-code/scripts/hooks/lib/liveness-heartbeat.js +42 -1
  32. package/dist/claude-code/scripts/hooks/lib/liveness-write.js +109 -9
  33. package/dist/claude-code/scripts/liveness/relay.sh +23 -11
  34. package/dist/claude-code/scripts/telemetry/lib/config.sh +29 -0
  35. package/dist/codex/build/package.json +1 -1
  36. package/dist/codex/build/src/builder-flow-runtime.js +10 -5
  37. package/dist/codex/context/contracts/verification-contract.md +6 -0
  38. package/dist/codex/context/scripts/telemetry/lib/config.sh +29 -0
  39. package/dist/codex/evals/acceptance/prove-capture-teeth.sh +24 -0
  40. package/dist/codex/evals/integration/test_effective_backlog_settings.sh +118 -15
  41. package/dist/codex/evals/integration/test_liveness_console_relay.sh +91 -1
  42. package/dist/codex/evals/integration/test_liveness_heartbeat.sh +66 -0
  43. package/dist/codex/install.sh +1 -1
  44. package/dist/codex/scripts/hooks/claude-telemetry-hook.js +6 -0
  45. package/dist/codex/scripts/hooks/lib/codex-exit-code.js +75 -23
  46. package/dist/codex/scripts/hooks/lib/liveness-heartbeat.js +42 -1
  47. package/dist/codex/scripts/hooks/lib/liveness-write.js +109 -9
  48. package/dist/codex/scripts/liveness/relay.sh +23 -11
  49. package/dist/codex/scripts/telemetry/lib/config.sh +29 -0
  50. package/dist/kiro/build/package.json +1 -1
  51. package/dist/kiro/build/src/builder-flow-runtime.js +10 -5
  52. package/dist/kiro/context/contracts/verification-contract.md +6 -0
  53. package/dist/kiro/context/scripts/telemetry/lib/config.sh +29 -0
  54. package/dist/kiro/evals/acceptance/prove-capture-teeth.sh +24 -0
  55. package/dist/kiro/evals/integration/test_effective_backlog_settings.sh +118 -15
  56. package/dist/kiro/evals/integration/test_liveness_console_relay.sh +91 -1
  57. package/dist/kiro/evals/integration/test_liveness_heartbeat.sh +66 -0
  58. package/dist/kiro/install.sh +1 -1
  59. package/dist/kiro/scripts/hooks/claude-telemetry-hook.js +6 -0
  60. package/dist/kiro/scripts/hooks/lib/codex-exit-code.js +75 -23
  61. package/dist/kiro/scripts/hooks/lib/liveness-heartbeat.js +42 -1
  62. package/dist/kiro/scripts/hooks/lib/liveness-write.js +109 -9
  63. package/dist/kiro/scripts/liveness/relay.sh +23 -11
  64. package/dist/kiro/scripts/telemetry/lib/config.sh +29 -0
  65. package/dist/opencode/build/package.json +1 -1
  66. package/dist/opencode/build/src/builder-flow-runtime.js +10 -5
  67. package/dist/opencode/context/contracts/verification-contract.md +6 -0
  68. package/dist/opencode/context/scripts/telemetry/lib/config.sh +29 -0
  69. package/dist/opencode/evals/acceptance/prove-capture-teeth.sh +24 -0
  70. package/dist/opencode/evals/integration/test_effective_backlog_settings.sh +118 -15
  71. package/dist/opencode/evals/integration/test_liveness_console_relay.sh +91 -1
  72. package/dist/opencode/evals/integration/test_liveness_heartbeat.sh +66 -0
  73. package/dist/opencode/install.sh +1 -1
  74. package/dist/opencode/scripts/hooks/claude-telemetry-hook.js +6 -0
  75. package/dist/opencode/scripts/hooks/lib/codex-exit-code.js +75 -23
  76. package/dist/opencode/scripts/hooks/lib/liveness-heartbeat.js +42 -1
  77. package/dist/opencode/scripts/hooks/lib/liveness-write.js +109 -9
  78. package/dist/opencode/scripts/liveness/relay.sh +23 -11
  79. package/dist/opencode/scripts/telemetry/lib/config.sh +29 -0
  80. package/dist/pi/build/package.json +1 -1
  81. package/dist/pi/build/src/builder-flow-runtime.js +10 -5
  82. package/dist/pi/context/contracts/verification-contract.md +6 -0
  83. package/dist/pi/context/scripts/telemetry/lib/config.sh +29 -0
  84. package/dist/pi/evals/acceptance/prove-capture-teeth.sh +24 -0
  85. package/dist/pi/evals/integration/test_effective_backlog_settings.sh +118 -15
  86. package/dist/pi/evals/integration/test_liveness_console_relay.sh +91 -1
  87. package/dist/pi/evals/integration/test_liveness_heartbeat.sh +66 -0
  88. package/dist/pi/install.sh +1 -1
  89. package/dist/pi/scripts/hooks/claude-telemetry-hook.js +6 -0
  90. package/dist/pi/scripts/hooks/lib/codex-exit-code.js +75 -23
  91. package/dist/pi/scripts/hooks/lib/liveness-heartbeat.js +42 -1
  92. package/dist/pi/scripts/hooks/lib/liveness-write.js +109 -9
  93. package/dist/pi/scripts/liveness/relay.sh +23 -11
  94. package/dist/pi/scripts/telemetry/lib/config.sh +29 -0
  95. package/evals/acceptance/prove-capture-teeth.sh +24 -0
  96. package/evals/integration/test_effective_backlog_settings.sh +118 -15
  97. package/evals/integration/test_liveness_console_relay.sh +91 -1
  98. package/evals/integration/test_liveness_heartbeat.sh +66 -0
  99. package/package.json +2 -1
  100. package/scripts/hooks/claude-telemetry-hook.js +6 -0
  101. package/scripts/hooks/lib/codex-exit-code.js +75 -23
  102. package/scripts/hooks/lib/liveness-heartbeat.js +42 -1
  103. package/scripts/hooks/lib/liveness-write.js +109 -9
  104. package/scripts/liveness/relay.sh +23 -11
  105. package/scripts/telemetry/lib/config.sh +29 -0
  106. package/src/builder-flow-runtime.ts +10 -5
  107. package/src/cli/builder-flow-runtime.test.mjs +55 -2
  108. package/src/cli/codex-exit-code.test.mjs +217 -2
@@ -18,27 +18,127 @@
18
18
  */
19
19
 
20
20
  const fs = require('fs');
21
+ const os = require('os');
21
22
  const path = require('path');
22
23
  const { spawn } = require('child_process');
23
24
 
25
+ const TRUTHY = new Set(['1', 'true', 'yes', 'on']);
26
+ const FALSY = new Set(['0', 'false', 'no', 'off']);
27
+ // Cheap per-process memo keyed by the exact env inputs, so a throttled hot path does at most one
28
+ // conf read per (env flag, conf path) signature. Keyed inputs make unit tests that vary
29
+ // TELEMETRY_CONFIG_FILE independent.
30
+ const relayEnabledCache = new Map();
31
+
32
+ /**
33
+ * The console-conf resolution chain, mirroring scripts/telemetry/lib/config.sh's slot order:
34
+ * 1. TELEMETRY_CONFIG_FILE (env) if set + readable
35
+ * 2. per-workspace override: <workspace-root>/.kontourai/telemetry-console.conf
36
+ * 3. user-global durable override: ~/.flow-agents/telemetry-console.conf
37
+ * 4. the shipped default: scripts/telemetry/telemetry.conf (relative to this file)
38
+ * Slot 4's path is <this file>/../../telemetry/telemetry.conf; the workspace root for slot 2 is
39
+ * three levels up from scripts/hooks/lib. This resolver is only the SPAWN pre-gate — it is
40
+ * intentionally lenient (no mode-600 trust gate). relay.sh re-sources config.sh, which applies the
41
+ * trust gate and is the authoritative decision for both enablement and the POST endpoint, so a
42
+ * lenient "enabled" here at worst spawns a relay.sh that then trust-gates itself to a no-op.
43
+ *
44
+ * ACCEPTED, BY DESIGN (security review LOW, A04): because this pre-gate does NOT re-apply
45
+ * config.sh's mode-600/owner gate, it can read an explicit key from an UNtrusted default-path conf
46
+ * (a mode-644 .kontourai/telemetry-console.conf a local tool dropped) that config.sh would skip.
47
+ * This is fail-toward-no-op, never fail-toward-exfil: the only divergence is (a) we spawn a
48
+ * relay.sh that config.sh then no-ops (harmless), or (b) an untrusted `=0` here suppresses a spawn
49
+ * that a trusted conf would have enabled (a missed pulse — availability only, requiring local FS
50
+ * write access). The pre-gate deliberately does not guarantee relay AVAILABILITY — only that we
51
+ * avoid spawning when nothing plausibly enables it. It NEVER weakens the exfil defense, which lives
52
+ * entirely in relay.sh's authoritative, trust-gated re-resolution.
53
+ * @returns {string|null} first readable conf path, or null
54
+ */
55
+ function resolveConsoleConfPath(env) {
56
+ const candidates = [
57
+ env.TELEMETRY_CONFIG_FILE,
58
+ path.join(__dirname, '..', '..', '..', '.kontourai', 'telemetry-console.conf'),
59
+ path.join(os.homedir(), '.flow-agents', 'telemetry-console.conf'),
60
+ path.join(__dirname, '..', '..', 'telemetry', 'telemetry.conf'),
61
+ ];
62
+ for (const c of candidates) {
63
+ try {
64
+ if (c && fs.existsSync(c) && fs.statSync(c).isFile()) return c;
65
+ } catch {
66
+ /* keep scanning */
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Conf-driven liveness relay enablement (#567 — parity with the economics relay #469, so an
74
+ * operator enables via `console_liveness_relay=1` in the console conf, NOT an env var/.profile).
75
+ * Precedence mirrors config.sh's economics rule EXACTLY (config.sh:182-204): an explicit
76
+ * `console_liveness_relay` conf key wins over the env var; absent a key, a pre-set env var is
77
+ * honored; absent both, default ON once a console sink is configured (any console_*url key present)
78
+ * — opt-out, not silent-off. Best-effort and cheap: any FS/parse failure falls back to the env-only
79
+ * decision; never throws. This is the spawn pre-gate; relay.sh+config.sh is authoritative.
80
+ */
81
+ function resolveLivenessRelayEnabled(env) {
82
+ const envFlag = String(env.FLOW_AGENTS_CONSOLE_LIVENESS_RELAY || '').toLowerCase();
83
+ const envDecision = TRUTHY.has(envFlag) ? true : FALSY.has(envFlag) ? false : null;
84
+ const cacheKey = `${envFlag}|${env.TELEMETRY_CONFIG_FILE || ''}`;
85
+ if (relayEnabledCache.has(cacheKey)) return relayEnabledCache.get(cacheKey);
86
+ let enabled;
87
+ try {
88
+ let explicit = null;
89
+ let hasConsoleUrl = false;
90
+ const confPath = resolveConsoleConfPath(env);
91
+ if (confPath) {
92
+ for (const raw of fs.readFileSync(confPath, 'utf8').split('\n')) {
93
+ const line = raw.trim();
94
+ if (!line || line.startsWith('#')) continue;
95
+ const eq = line.indexOf('=');
96
+ if (eq < 0) continue;
97
+ const key = line.slice(0, eq).trim();
98
+ const value = line.slice(eq + 1).trim().toLowerCase();
99
+ if (key === 'console_liveness_relay') {
100
+ if (TRUTHY.has(value)) explicit = true;
101
+ else if (FALSY.has(value)) explicit = false;
102
+ } else if (key === 'console_telemetry_url' || key === 'console_url' || key === 'console_telemetry_endpoint_url') {
103
+ if (value) hasConsoleUrl = true;
104
+ }
105
+ }
106
+ }
107
+ // config.sh precedence: explicit conf key > pre-set env var > default-on when a console url resolves.
108
+ enabled = explicit !== null ? explicit : envDecision !== null ? envDecision : hasConsoleUrl;
109
+ } catch {
110
+ enabled = envDecision === true; // best-effort: honor only an explicit env-on if the conf read failed
111
+ }
112
+ relayEnabledCache.set(cacheKey, enabled);
113
+ return enabled;
114
+ }
115
+
24
116
  /**
25
- * OPTIONAL console liveness relay (#295, ADR 0021 §4/§7). Best-effort, FULLY detached mirror of a
26
- * liveness event to the hosted Console via `scripts/liveness/relay.sh`. Local-first is sacred: this
27
- * runs AFTER the durable local append and can never block, throw, or affect it — gated on
28
- * `FLOW_AGENTS_CONSOLE_LIVENESS_RELAY`, the whole thing wrapped so any failure (missing script,
29
- * spawn error) is swallowed. No flag ⇒ a single cheap env read and return (true no-op).
117
+ * OPTIONAL console liveness relay (#295, ADR 0021 §4/§7; conf-driven per #567). Best-effort, FULLY
118
+ * detached mirror of a liveness event to the hosted Console via `scripts/liveness/relay.sh`.
119
+ * Local-first is sacred: this runs AFTER the durable local append and can never block, throw, or
120
+ * affect it — gated on `resolveLivenessRelayEnabled`, the whole thing wrapped so any failure
121
+ * (missing script, spawn error) is swallowed. Not enabled ⇒ a cheap resolve and return (true no-op).
30
122
  *
31
123
  * @param {object} evt The liveness event just written locally.
32
124
  * @returns {void}
33
125
  */
34
126
  function relayLivenessEvent(evt) {
35
127
  try {
36
- const flag = String(process.env.FLOW_AGENTS_CONSOLE_LIVENESS_RELAY || '').toLowerCase();
37
- if (flag !== '1' && flag !== 'true' && flag !== 'yes' && flag !== 'on') return;
128
+ if (!resolveLivenessRelayEnabled(process.env)) return;
38
129
  // scripts/hooks/lib/ -> scripts/liveness/relay.sh (same relative layout in dist/* bundles).
39
130
  const relay = path.join(__dirname, '..', '..', 'liveness', 'relay.sh');
40
131
  if (!fs.existsSync(relay)) return;
41
- const child = spawn('bash', [relay, JSON.stringify(evt)], { detached: true, stdio: 'ignore' });
132
+ // Pass the environment through UNCHANGED do not force the enable flag. relay.sh re-sources
133
+ // config.sh, which is the authoritative, trust-gated decision for both enablement (conf key >
134
+ // env > default-on) and the POST endpoint. Our resolve above is only a lenient spawn pre-gate:
135
+ // if we enabled from an untrusted default-path conf, config.sh trust-gates relay.sh to a no-op
136
+ // rather than us bypassing it by forcing the flag on.
137
+ const child = spawn('bash', [relay, JSON.stringify(evt)], {
138
+ detached: true,
139
+ stdio: 'ignore',
140
+ env: process.env,
141
+ });
42
142
  child.on('error', () => {}); // never surface a spawn failure
43
143
  child.unref(); // fully detach — the parent never waits on the relay
44
144
  } catch {
@@ -70,4 +170,4 @@ function appendLivenessEvent(root, evt) {
70
170
  relayLivenessEvent(evt); // then optionally mirror to the Console — best-effort, detached, off by default
71
171
  }
72
172
 
73
- module.exports = { livenessStreamFile, appendLivenessEvent };
173
+ module.exports = { livenessStreamFile, appendLivenessEvent, resolveLivenessRelayEnabled };
@@ -7,26 +7,36 @@
7
7
  # never forked). The Console side ingests this record type and projects the fleet view + runs the
8
8
  # janitor (console repo #125); this script is only the flow-agents EMIT half.
9
9
  #
10
- # STRICTLY OPTIONAL and local-first (ADR 0012 §5): a no-op unless FLOW_AGENTS_CONSOLE_LIVENESS_RELAY
11
- # is enabled AND a console endpoint is configured. Best-effort throughout it must NEVER block, slow,
12
- # or fail the local liveness write that already happened before this was invoked. Every failure path
13
- # is a quiet `exit 0`.
10
+ # STRICTLY OPTIONAL and local-first (ADR 0012 §5): a no-op unless the liveness relay is enabled AND a
11
+ # console endpoint is configured. Enablement is conf-driven (#567, parity with economics #469): an
12
+ # operator sets `console_liveness_relay=1` in the console conf (or it defaults on once a console url
13
+ # resolves) — NOT an env var. config.sh (sourced below) is the authoritative, trust-gated decision
14
+ # and also supplies the endpoint/token/tenant from the conf. Best-effort throughout — it must NEVER
15
+ # block, slow, or fail the local liveness write that already happened before this was invoked. Every
16
+ # failure path is a quiet `exit 0`.
14
17
  #
15
18
  # Invoked (fully detached, best-effort) from scripts/hooks/lib/liveness-write.js after the durable
16
19
  # local append. Usage: relay.sh '<liveness-event-json>'
17
20
  set -uo pipefail
18
21
 
19
- # Opt-in gate — off by default.
20
- case "${FLOW_AGENTS_CONSOLE_LIVENESS_RELAY:-}" in
21
- 1 | true | TRUE | yes | on) ;;
22
- *) exit 0 ;;
23
- esac
24
-
25
22
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" || exit 0
26
23
 
27
24
  # transport.sh sources ${TELEMETRY_DIR}/lib/redact.sh at load — point it at the telemetry lib dir,
28
25
  # then reuse the SAME console POST core the telemetry mirror uses.
29
26
  export TELEMETRY_DIR="${TELEMETRY_DIR:-$SCRIPT_DIR/../telemetry}"
27
+
28
+ # Source config.sh FIRST (best-effort, like economics-record.sh) so the conf resolves enablement
29
+ # (FLOW_AGENTS_CONSOLE_LIVENESS_RELAY) and the endpoint/token/tenant — trust-gated for default-path
30
+ # confs. This is what makes conf-only operation work with NO env var set.
31
+ # shellcheck source=/dev/null
32
+ [[ -f "$TELEMETRY_DIR/lib/config.sh" ]] && source "$TELEMETRY_DIR/lib/config.sh" 2>/dev/null || true
33
+
34
+ # Opt-in gate — off by default, now honoring the conf-derived value config.sh just resolved.
35
+ case "${FLOW_AGENTS_CONSOLE_LIVENESS_RELAY:-}" in
36
+ 1 | true | TRUE | yes | on) ;;
37
+ *) exit 0 ;;
38
+ esac
39
+
30
40
  [[ -f "$TELEMETRY_DIR/lib/transport.sh" ]] || exit 0
31
41
  # shellcheck source=/dev/null
32
42
  source "$TELEMETRY_DIR/lib/transport.sh" 2>/dev/null || exit 0
@@ -73,7 +83,9 @@ record="$(printf '%s' "$event_json" | jq -c '{
73
83
  ttlSeconds: (.ttlSeconds // null),
74
84
  host: (.host // null),
75
85
  branch: (.branch // null),
76
- artifact_dir: (.artifact_dir // .artifactDir // null)
86
+ artifact_dir: (.artifact_dir // .artifactDir // null),
87
+ source: (.source // null),
88
+ activity: (.activity // null)
77
89
  }' 2>/dev/null)" || exit 0
78
90
  [[ -z "$record" || "$record" == "null" ]] && exit 0
79
91
 
@@ -109,6 +109,13 @@ FLOW_AGENTS_CONSOLE_ECONOMICS_ENDPOINT_URL="${FLOW_AGENTS_CONSOLE_ECONOMICS_ENDP
109
109
  # Set (non-empty) only when the config file carries an explicit console_economics_relay key —
110
110
  # distinguishes "operator said 0/1" from "key absent" for the default-on rule below.
111
111
  console_economics_relay_raw=""
112
+ # Liveness relay (#567): same conf-driven, opt-out-not-silent-off shape as economics (#469) so an
113
+ # operator enables the hosted liveness mirror via console_liveness_relay=1 in the conf, NOT an env
114
+ # var/.profile. A caller-pre-set env var is the starting point; the config key overrides it; absent
115
+ # any key the default-on rule below turns it on once a console telemetry sink resolves.
116
+ FLOW_AGENTS_CONSOLE_LIVENESS_RELAY="${FLOW_AGENTS_CONSOLE_LIVENESS_RELAY:-}"
117
+ FLOW_AGENTS_CONSOLE_LIVENESS_ENDPOINT_URL="${FLOW_AGENTS_CONSOLE_LIVENESS_ENDPOINT_URL:-}"
118
+ console_liveness_relay_raw=""
112
119
  # Pricing registry source (consumed by lib/pricing.sh). Explicit file/URL win;
113
120
  # otherwise lib/pricing.sh uses the bundled pricing.json offline.
114
121
  TELEMETRY_PRICING_FILE="${TELEMETRY_PRICING_FILE:-${FLOW_AGENTS_PRICING_FILE:-}}"
@@ -164,6 +171,17 @@ if [[ -f "$TELEMETRY_CONFIG_FILE" ]]; then
164
171
  esac
165
172
  ;;
166
173
  console_economics_endpoint_url) FLOW_AGENTS_CONSOLE_ECONOMICS_ENDPOINT_URL="$value" ;;
174
+ console_liveness_relay)
175
+ case "$(echo "$value" | tr '[:upper:]' '[:lower:]')" in
176
+ 1|true|yes|on) console_liveness_relay_raw="1" ;;
177
+ 0|false|no|off) console_liveness_relay_raw="0" ;;
178
+ *)
179
+ printf 'warning: config.sh: unrecognized console_liveness_relay value %q; treating as off\n' "$value" >&2
180
+ console_liveness_relay_raw="$value"
181
+ ;;
182
+ esac
183
+ ;;
184
+ console_liveness_endpoint_url) FLOW_AGENTS_CONSOLE_LIVENESS_ENDPOINT_URL="$value" ;;
167
185
  console_pricing_url) TELEMETRY_PRICING_URL="$value" ;;
168
186
  pricing_url) TELEMETRY_PRICING_URL="$value" ;;
169
187
  pricing_file) TELEMETRY_PRICING_FILE="$value" ;;
@@ -186,6 +204,17 @@ elif [[ -z "$FLOW_AGENTS_CONSOLE_ECONOMICS_RELAY" \
186
204
  FLOW_AGENTS_CONSOLE_ECONOMICS_RELAY=1
187
205
  fi
188
206
 
207
+ # Liveness relay default-on rule (#567), identical shape to economics above: an explicit
208
+ # console_liveness_relay config key always wins; otherwise, once a console telemetry sink resolves,
209
+ # the relay defaults ON unless a caller already pre-set FLOW_AGENTS_CONSOLE_LIVENESS_RELAY in the
210
+ # environment. relay.sh's own opt-in gate reads this exact variable.
211
+ if [[ -n "$console_liveness_relay_raw" ]]; then
212
+ FLOW_AGENTS_CONSOLE_LIVENESS_RELAY="$console_liveness_relay_raw"
213
+ elif [[ -z "$FLOW_AGENTS_CONSOLE_LIVENESS_RELAY" \
214
+ && ( -n "${CONSOLE_TELEMETRY_URL:-}" || -n "${CONSOLE_TELEMETRY_ENDPOINT_URL:-}" ) ]]; then
215
+ FLOW_AGENTS_CONSOLE_LIVENESS_RELAY=1
216
+ fi
217
+
189
218
  # Pricing URL is explicit-only (env or config). Do not derive the console
190
219
  # pricing endpoint by default; the bundled registry is the reliable offline floor.
191
220
 
@@ -710,7 +710,7 @@ async function bundleGateEvidence(
710
710
  throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", `is not declared by gate ${String((gate as AnyRecord).id ?? "<unknown>")}`);
711
711
  }
712
712
  if (String((gate as AnyRecord).id) === "verify-gate" && relevant.some((claim) => claim.claimType === "builder.verify.tests" && claim.value === "pass")) {
713
- await assertVerifiedTestsTrust(bundle.claims, projectRoot);
713
+ await assertVerifiedTestsTrust(relevant, projectRoot);
714
714
  }
715
715
  return { failed, routeReason, expectationIds, visitEnteredAt: enteredAt };
716
716
  }
@@ -739,15 +739,20 @@ function timestampAtOrAfter(value: unknown, boundary: number): boolean {
739
739
  return parsed !== null && parsed >= boundary;
740
740
  }
741
741
 
742
- async function assertVerifiedTestsTrust(claims: unknown[], projectRoot: string): Promise<void> {
743
- const testClaims = claims.filter((claim): claim is AnyRecord => isRecord(claim)
742
+ async function assertVerifiedTestsTrust(currentGateClaims: AnyRecord[], projectRoot: string): Promise<void> {
743
+ const testClaims = currentGateClaims.filter((claim): claim is AnyRecord => isRecord(claim)
744
744
  && claim.claimType === "builder.verify.tests"
745
745
  && claim.value === "pass"
746
746
  && isRecord(claim.metadata)
747
747
  && isRecord(claim.metadata.gate_claim)
748
748
  && claim.metadata.gate_claim.expectation_id === "tests-evidence");
749
749
  if (testClaims.length === 0) throw new BuilderBuildRunInputError("evidence.tests", "is missing a passing tests-evidence claim");
750
- const liveCritiques = claims.filter((claim): claim is AnyRecord => isRecord(claim)
750
+ // A route-back starts a new gate visit and therefore a new critique generation. Historical
751
+ // reviewer slices remain in the bundle and manifest for audit, but only critiques acquired
752
+ // during this visit describe the implementation snapshot currently being verified. Within a
753
+ // visit every live reviewer slice still participates, so changing reviewers cannot bury a
754
+ // disputed finding.
755
+ const liveCritiques = currentGateClaims.filter((claim): claim is AnyRecord => isRecord(claim)
751
756
  && isRecord(claim.metadata)
752
757
  && claim.metadata.origin === "critique"
753
758
  && typeof claim.metadata.superseded_by !== "string");
@@ -763,7 +768,7 @@ async function assertVerifiedTestsTrust(claims: unknown[], projectRoot: string):
763
768
  await Promise.all(artifacts.map((artifact) => assertReviewedArtifactDigest(artifact, projectRoot)));
764
769
  assertReviewedWorkspaceSnapshot(claim, artifacts, projectRoot);
765
770
  }));
766
- const criteria = claims.filter((claim): claim is AnyRecord => isRecord(claim) && isRecord(claim.metadata) && claim.metadata.origin === "acceptance");
771
+ const criteria = currentGateClaims.filter((claim): claim is AnyRecord => isRecord(claim) && isRecord(claim.metadata) && claim.metadata.origin === "acceptance");
767
772
  if (criteria.length === 0 || criteria.some((claim) => {
768
773
  const criterion = isRecord(claim.metadata.criterion) ? claim.metadata.criterion : null;
769
774
  return claim.value !== "pass" || !criterion || !Array.isArray(criterion.evidence_refs) || criterion.evidence_refs.length === 0;
@@ -1382,6 +1382,8 @@ test("failed verification projects Flow-owned route-back attempt and budget", as
1382
1382
  assert.equal(verify.run.state.current_step, "verify");
1383
1383
 
1384
1384
  const failureTimestamp = new Date().toISOString();
1385
+ const initialPrerequisites = verifiedTestsPrerequisites(session, failureTimestamp);
1386
+ initialPrerequisites[0].claim.metadata.reviewer = "reviewer-before-route-back";
1385
1387
  const routed = await writeAndSync(session, [bundleClaim({
1386
1388
  expectation: "tests-evidence",
1387
1389
  claimType: "builder.verify.tests",
@@ -1389,7 +1391,7 @@ test("failed verification projects Flow-owned route-back attempt and budget", as
1389
1391
  status: "fail",
1390
1392
  routeReason: "implementation_defect",
1391
1393
  timestamp: failureTimestamp,
1392
- }), ...verifiedTestsPrerequisites(session, failureTimestamp)]);
1394
+ }), ...initialPrerequisites]);
1393
1395
 
1394
1396
  assert.equal(routed.run.state.current_step, "execute");
1395
1397
  assert.equal(routed.projection.flow_run.route_back_attempt, 1);
@@ -1415,15 +1417,66 @@ test("failed verification projects Flow-owned route-back attempt and budget", as
1415
1417
  assert.equal(staleRetry.attached, false);
1416
1418
  assert.equal(staleRetry.run.state.current_step, "verify");
1417
1419
  assert.equal(staleRetry.run.state.transitions.filter((transition) => transition.type === "route_back").length, 1);
1420
+ fs.writeFileSync(path.join(session.projectRoot, "review-target", "delivery.md"), "corrected delivery after route-back\n");
1418
1421
  const correctedAt = new Date(Date.parse(reentered.run.state.transitions.at(-1).at) + 1).toISOString();
1422
+ const correctedPrerequisites = verifiedTestsPrerequisites(session, correctedAt)
1423
+ .map((entry, index) => withIdentitySuffix(entry, `corrected-${index}`));
1424
+ correctedPrerequisites[0].claim.metadata.reviewer = "reviewer-after-route-back";
1419
1425
  const corrected = await writeAndSync(session, [
1420
1426
  withIdentitySuffix(bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step", timestamp: correctedAt }), "corrected"),
1421
- ...verifiedTestsPrerequisites(session, correctedAt).map((entry, index) => withIdentitySuffix(entry, `corrected-${index}`)),
1427
+ ...correctedPrerequisites,
1428
+ // Compose-safe writers preserve this older reviewer's still-live PASS slice. It targets the
1429
+ // prior implementation bytes and must remain audit history without deadlocking the new gate
1430
+ // visit or requiring the new reviewer to impersonate the old one.
1431
+ initialPrerequisites[0],
1422
1432
  ]);
1423
1433
  assert.equal(corrected.run.state.current_step, "merge-ready");
1424
1434
  const verifyEvidence = corrected.run.manifest.evidence.filter((entry) => entry.gate_id === "verify-gate");
1425
1435
  assert.equal(verifyEvidence.length, 2);
1426
1436
  assert.equal(verifyEvidence[0].superseded_by, verifyEvidence[1].id);
1437
+ const retainedCritiques = readJson(path.join(session.sessionDir, "trust.bundle")).claims
1438
+ .filter((claim) => claim.metadata?.origin === "critique" && !claim.metadata.superseded_by);
1439
+ assert.deepEqual(retainedCritiques.map((claim) => claim.metadata.reviewer).sort(), [
1440
+ "reviewer-after-route-back",
1441
+ "reviewer-before-route-back",
1442
+ ]);
1443
+ });
1444
+
1445
+ test("a different passing reviewer cannot hide a disputed critique in the same gate visit", async () => {
1446
+ const session = makeSession("same-visit-reviewer-handoff");
1447
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
1448
+ await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
1449
+ await writeAndSync(session, [
1450
+ bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
1451
+ bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
1452
+ ]);
1453
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
1454
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
1455
+
1456
+ const timestamp = new Date().toISOString();
1457
+ const prerequisites = verifiedTestsPrerequisites(session, timestamp);
1458
+ prerequisites[0].claim.metadata.reviewer = "passing-reviewer";
1459
+ const disputedCritique = withIdentitySuffix(structuredClone(prerequisites[0]), "disputed-reviewer");
1460
+ disputedCritique.claim.value = "fail";
1461
+ disputedCritique.claim.status = "disputed";
1462
+ disputedCritique.claim.metadata.reviewer = "disputed-reviewer";
1463
+ disputedCritique.claim.metadata.lanes = [{ id: "code", status: "fail" }];
1464
+ disputedCritique.claim.metadata.findings = [{ id: "unresolved", status: "open", severity: "high" }];
1465
+ disputedCritique.event.status = "disputed";
1466
+
1467
+ const blocked = await writeAndSync(session, [
1468
+ bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step", timestamp }),
1469
+ ...prerequisites,
1470
+ disputedCritique,
1471
+ ]);
1472
+ assert.equal(blocked.attached, false);
1473
+ assert.equal(blocked.run.state.current_step, "verify");
1474
+ const retainedCritiques = readJson(path.join(session.sessionDir, "trust.bundle")).claims
1475
+ .filter((claim) => claim.metadata?.origin === "critique" && !claim.metadata.superseded_by);
1476
+ assert.deepEqual(retainedCritiques.map((claim) => [claim.metadata.reviewer, claim.value]).sort(), [
1477
+ ["disputed-reviewer", "fail"],
1478
+ ["passing-reviewer", "pass"],
1479
+ ]);
1427
1480
  });
1428
1481
 
1429
1482
  test("producer-superseded FAIL is audit history and live PASS drives verify", async () => {
@@ -55,6 +55,22 @@ function functionCall(callId, command) {
55
55
  };
56
56
  }
57
57
 
58
+ function execCommandCall(callId, cmd) {
59
+ return {
60
+ timestamp: "2026-07-06T00:00:00Z",
61
+ type: "response_item",
62
+ payload: { type: "function_call", call_id: callId, name: "exec_command", arguments: JSON.stringify({ cmd }) },
63
+ };
64
+ }
65
+
66
+ function functionToolCall(callId, name, args) {
67
+ return {
68
+ timestamp: "2026-07-06T00:00:00Z",
69
+ type: "response_item",
70
+ payload: { type: "function_call", call_id: callId, name, arguments: JSON.stringify(args) },
71
+ };
72
+ }
73
+
58
74
  // --- extractExitCodeFromBanner: preamble-anchored (CRITICAL finding #1) ---
59
75
 
60
76
  test("extractExitCodeFromBanner: forgery in post-Output: stdout is ignored (preamble wins)", () => {
@@ -95,7 +111,7 @@ test("readExitCodeFromRollout: malformed JSONL lines are skipped, valid entry st
95
111
 
96
112
  // --- truncation arithmetic (HEAD-anchored bounded scan, MEDIUM finding #5) ---
97
113
 
98
- test("readExitCodeFromRollout: target line near EOF still found when file exceeds the scan window", () => {
114
+ test("readExitCodeFromRollout: call-id target near EOF is found when file exceeds the scan window", () => {
99
115
  const filler = [];
100
116
  for (let i = 0; i < 60; i++) filler.push({ timestamp: "2026-07-06T00:00:00Z", type: "turn_context", payload: {} });
101
117
  const target = functionCallOutput("call_1", "Process exited with code 1\nOutput:\n...");
@@ -106,7 +122,7 @@ test("readExitCodeFromRollout: target line near EOF still found when file exceed
106
122
  // Window smaller than the whole file (forces truncation) but comfortably
107
123
  // larger than the target line itself.
108
124
  const maxScanBytes = targetLineBytes + 50;
109
- assert.equal(readExitCodeFromRollout(file, { maxScanBytes }), 1);
125
+ assert.equal(readExitCodeFromRollout(file, { callId: "call_1", maxScanBytes }), 1);
110
126
  });
111
127
 
112
128
  test("readExitCodeFromRollout: target line start beyond maxScanBytes yields null (never mis-reads a fragment)", () => {
@@ -130,6 +146,15 @@ test("readExitCodeFromRollout: call_id match wins over the newer entry", () => {
130
146
  assert.equal(readExitCodeFromRollout(file, { callId: "call_b" }), 5);
131
147
  });
132
148
 
149
+ test("readExitCodeFromRollout: an unresolved explicit call_id never downgrades to command matching", () => {
150
+ const file = writeRollout([
151
+ execCommandCall("call_other", "npm test"),
152
+ functionCallOutput("call_other", "Process exited with code 0\nOutput:\n..."),
153
+ ]);
154
+
155
+ assert.equal(readExitCodeFromRollout(file, { callId: "call_missing", command: "npm test" }), null);
156
+ });
157
+
133
158
  // --- command cross-check correlation (Decision B #2, HIGH finding #4) ---
134
159
 
135
160
  test("readExitCodeFromRollout: command cross-check mismatch declines to null", () => {
@@ -148,6 +173,196 @@ test("readExitCodeFromRollout: command cross-check match uses the correlated ent
148
173
  assert.equal(readExitCodeFromRollout(file, { command: "npm run lint" }), 1);
149
174
  });
150
175
 
176
+ test("readExitCodeFromRollout: Codex exec_command cmd argument correlates the result", () => {
177
+ const file = writeRollout([
178
+ execCommandCall("call_1", "npm run lint"),
179
+ functionCallOutput("call_1", "Process exited with code 1\nOutput:\n..."),
180
+ ]);
181
+ assert.equal(readExitCodeFromRollout(file, { command: "npm run lint" }), 1);
182
+ assert.equal(readExitCodeFromRollout(file, { command: "npm test" }), null);
183
+ });
184
+
185
+ test("readExitCodeFromRollout: parallel call cannot inherit the previous call result", () => {
186
+ const file = writeRollout([
187
+ execCommandCall("call_pass", "find seed -type f"),
188
+ execCommandCall("call_fail", "cat seed/src/window.js"),
189
+ functionCallOutput("call_pass", "Process exited with code 0\nOutput:\n..."),
190
+ ]);
191
+
192
+ assert.equal(readExitCodeFromRollout(file, { command: "find seed -type f" }), 0);
193
+ assert.equal(readExitCodeFromRollout(file, { command: "cat seed/src/window.js" }), null);
194
+ });
195
+
196
+ test("readExitCodeFromRollout: parallel outputs correlate to their exact commands", () => {
197
+ const file = writeRollout([
198
+ execCommandCall("call_pass", "find seed -type f"),
199
+ execCommandCall("call_fail", "cat seed/src/window.js"),
200
+ functionCallOutput("call_pass", "Process exited with code 0\nOutput:\n..."),
201
+ functionCallOutput("call_fail", "Process exited with code 1\nOutput:\n..."),
202
+ ]);
203
+
204
+ assert.equal(readExitCodeFromRollout(file, { command: "find seed -type f" }), 0);
205
+ assert.equal(readExitCodeFromRollout(file, { command: "cat seed/src/window.js" }), 1);
206
+ });
207
+
208
+ test("readExitCodeFromRollout: repeated parallel command text is ambiguous without call_id", () => {
209
+ const file = writeRollout([
210
+ execCommandCall("call_a", "npm test"),
211
+ execCommandCall("call_b", "npm test"),
212
+ functionCallOutput("call_a", "Process exited with code 0\nOutput:\n..."),
213
+ functionCallOutput("call_b", "Process exited with code 1\nOutput:\n..."),
214
+ ]);
215
+
216
+ assert.equal(readExitCodeFromRollout(file, { command: "npm test" }), null);
217
+ assert.equal(readExitCodeFromRollout(file, { callId: "call_a", command: "npm test" }), 0);
218
+ assert.equal(readExitCodeFromRollout(file, { callId: "call_b", command: "npm test" }), 1);
219
+ });
220
+
221
+ test("readExitCodeFromRollout: a repeated call without its output cannot reuse an older result", () => {
222
+ const file = writeRollout([
223
+ execCommandCall("call_old", "npm test"),
224
+ functionCallOutput("call_old", "Process exited with code 0\nOutput:\n..."),
225
+ execCommandCall("call_current", "npm test"),
226
+ ]);
227
+
228
+ assert.equal(readExitCodeFromRollout(file, { command: "npm test" }), null);
229
+ });
230
+
231
+ test("readExitCodeFromRollout: unresolved multi-call tail never falls back to newest output", () => {
232
+ const file = writeRollout([
233
+ {
234
+ timestamp: "2026-07-06T00:00:00Z",
235
+ type: "response_item",
236
+ payload: { type: "function_call", call_id: "call_a", name: "unknown", arguments: "not-json" },
237
+ },
238
+ functionCallOutput("call_a", "Process exited with code 0\nOutput:\n..."),
239
+ functionCallOutput("call_b", "Process exited with code 1\nOutput:\n..."),
240
+ ]);
241
+
242
+ assert.equal(readExitCodeFromRollout(file, { command: "npm test" }), null);
243
+ assert.equal(readExitCodeFromRollout(file, {}), null);
244
+ });
245
+
246
+ test("readExitCodeFromRollout: malformed function call cannot make an output look unpaired", () => {
247
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-exit-code-"));
248
+ const file = path.join(dir, "rollout.jsonl");
249
+ const output = JSON.stringify(functionCallOutput("call_old", "Process exited with code 0\nOutput:\n..."));
250
+ fs.writeFileSync(file, `${output}\n{"payload":{"type":"function_call","call_id":"call_current"\n`);
251
+
252
+ assert.equal(readExitCodeFromRollout(file, { command: "npm test" }), null);
253
+ });
254
+
255
+ test("readExitCodeFromRollout: truncated call context cannot make an output look unpaired", () => {
256
+ const output = functionCallOutput("call_old", "Process exited with code 7\nOutput:\n...");
257
+ const file = writeRollout([functionCall("call_old", "npm run lint"), output]);
258
+ const outputLineBytes = Buffer.byteLength(JSON.stringify(output), "utf8") + 1;
259
+
260
+ assert.equal(readExitCodeFromRollout(file, {
261
+ command: "npm test",
262
+ maxScanBytes: outputLineBytes + 10,
263
+ }), null);
264
+ });
265
+
266
+ test("readExitCodeFromRollout: reused call IDs are ambiguous", () => {
267
+ const file = writeRollout([
268
+ execCommandCall("same", "npm test"),
269
+ execCommandCall("same", "npm run lint"),
270
+ functionCallOutput("same", "Process exited with code 9\nOutput:\n..."),
271
+ ]);
272
+
273
+ assert.equal(readExitCodeFromRollout(file, { command: "npm test" }), null);
274
+ assert.equal(readExitCodeFromRollout(file, { command: "npm run lint" }), null);
275
+ });
276
+
277
+ test("readExitCodeFromRollout: an unresolvable current call blocks historical command reuse", () => {
278
+ const file = writeRollout([
279
+ execCommandCall("call_old", "npm test"),
280
+ functionCallOutput("call_old", "Process exited with code 0\nOutput:\n..."),
281
+ {
282
+ timestamp: "2026-07-06T00:00:01Z",
283
+ type: "response_item",
284
+ payload: { type: "function_call", call_id: "call_current", name: "exec_command", arguments: "not-json" },
285
+ },
286
+ ]);
287
+
288
+ assert.equal(readExitCodeFromRollout(file, { command: "npm test" }), null);
289
+ });
290
+
291
+ test("readExitCodeFromRollout: malformed duplicate call IDs remain ambiguous", () => {
292
+ const file = writeRollout([
293
+ execCommandCall("same", "npm test"),
294
+ {
295
+ timestamp: "2026-07-06T00:00:01Z",
296
+ type: "response_item",
297
+ payload: { type: "function_call", call_id: "same", name: "exec_command", arguments: "not-json" },
298
+ },
299
+ functionCallOutput("same", "Process exited with code 9\nOutput:\n..."),
300
+ ]);
301
+
302
+ assert.equal(readExitCodeFromRollout(file, { callId: "same", command: "npm test" }), null);
303
+ assert.equal(readExitCodeFromRollout(file, { command: "npm test" }), null);
304
+ });
305
+
306
+ test("readExitCodeFromRollout: malformed JSON blocks historical and explicit-id correlation", () => {
307
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-exit-code-"));
308
+ const file = path.join(dir, "rollout.jsonl");
309
+ const call = JSON.stringify(execCommandCall("same", "npm test"));
310
+ const output = JSON.stringify(functionCallOutput("same", "Process exited with code 0\nOutput:\n..."));
311
+ fs.writeFileSync(file, `${call}\n${output}\n{"payload":{"type":"function_call","call_id":"same"\n`);
312
+
313
+ assert.equal(readExitCodeFromRollout(file, { command: "npm test" }), null);
314
+ assert.equal(readExitCodeFromRollout(file, { callId: "same", command: "npm test" }), null);
315
+ });
316
+
317
+ test("readExitCodeFromRollout: a partial function-call token blocks historical correlation", () => {
318
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-exit-code-"));
319
+ const file = path.join(dir, "rollout.jsonl");
320
+ const call = JSON.stringify(execCommandCall("old", "npm test"));
321
+ const output = JSON.stringify(functionCallOutput("old", "Process exited with code 0\nOutput:\n..."));
322
+ fs.writeFileSync(file, `${call}\n${output}\n{"payload":{"type":"function_cal\n`);
323
+
324
+ assert.equal(readExitCodeFromRollout(file, { command: "npm test" }), null);
325
+ });
326
+
327
+ test("readExitCodeFromRollout: unrelated function tools do not poison exact command correlation", () => {
328
+ const file = writeRollout([
329
+ functionToolCall("patch_call", "apply_patch", { patch: "*** Begin Patch" }),
330
+ execCommandCall("command_call", "npm test"),
331
+ functionCallOutput("command_call", "Process exited with code 0\nOutput:\n..."),
332
+ ]);
333
+
334
+ assert.equal(readExitCodeFromRollout(file, { command: "npm test" }), 0);
335
+ });
336
+
337
+ test("readExitCodeFromRollout: nameless command-shaped calls participate in ambiguity checks", () => {
338
+ const file = writeRollout([
339
+ execCommandCall("old", "npm test"),
340
+ functionCallOutput("old", "Process exited with code 0\nOutput:\n..."),
341
+ functionToolCall("current", null, { cmd: "npm test" }),
342
+ functionCallOutput("current", "Process exited with code 1\nOutput:\n..."),
343
+ ]);
344
+
345
+ assert.equal(readExitCodeFromRollout(file, { command: "npm test" }), null);
346
+ assert.equal(readExitCodeFromRollout(file, { callId: "current", command: "npm test" }), 1);
347
+ });
348
+
349
+ test("readExitCodeFromRollout: semantically nameless malformed calls block historical command reuse", () => {
350
+ for (const name of [null, "", " "]) {
351
+ const file = writeRollout([
352
+ execCommandCall("old", "npm test"),
353
+ functionCallOutput("old", "Process exited with code 0\nOutput:\n..."),
354
+ {
355
+ timestamp: "2026-07-06T00:00:01Z",
356
+ type: "response_item",
357
+ payload: { type: "function_call", call_id: "current", name, arguments: "not-json" },
358
+ },
359
+ functionCallOutput("current", "Process exited with code 1\nOutput:\n..."),
360
+ ]);
361
+
362
+ assert.equal(readExitCodeFromRollout(file, { command: "npm test" }), null);
363
+ }
364
+ });
365
+
151
366
  test("readExitCodeFromRollout: no resolvable pairing falls back to the newest banner (single-call case)", () => {
152
367
  const file = writeRollout([
153
368
  functionCallOutput("call_1", "Process exited with code 1\nOutput:\n..."),