@kontourai/flow-agents 3.2.0 → 3.4.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 (281) hide show
  1. package/.github/workflows/add-to-project.yml +15 -0
  2. package/.github/workflows/ci.yml +173 -0
  3. package/CHANGELOG.md +54 -0
  4. package/CONTEXT.md +5 -1
  5. package/README.md +19 -8
  6. package/build/src/builder-flow-run-adapter.d.ts +80 -0
  7. package/build/src/builder-flow-run-adapter.js +241 -0
  8. package/build/src/builder-flow-runtime.d.ts +16 -0
  9. package/build/src/builder-flow-runtime.js +290 -0
  10. package/build/src/cli/assignment-provider.js +10 -1
  11. package/build/src/cli/builder-run.d.ts +1 -0
  12. package/build/src/cli/builder-run.js +27 -0
  13. package/build/src/cli/effective-backlog-settings.js +70 -2
  14. package/build/src/cli/init.d.ts +34 -0
  15. package/build/src/cli/init.js +341 -61
  16. package/build/src/cli/kit.js +55 -12
  17. package/build/src/cli/pull-work-provider.js +346 -5
  18. package/build/src/cli/skill-drift-check.d.ts +1 -0
  19. package/build/src/cli/skill-drift-check.js +165 -0
  20. package/build/src/cli/telemetry-doctor.d.ts +37 -0
  21. package/build/src/cli/telemetry-doctor.js +53 -6
  22. package/build/src/cli/validate-hook-influence.js +37 -7
  23. package/build/src/cli/workflow-artifact-cleanup-audit.js +418 -11
  24. package/build/src/cli/workflow-sidecar.d.ts +310 -1
  25. package/build/src/cli/workflow-sidecar.js +1914 -126
  26. package/build/src/cli.js +5 -0
  27. package/build/src/flow-kit/validate.d.ts +54 -34
  28. package/build/src/flow-kit/validate.js +237 -26
  29. package/build/src/index.d.ts +2 -0
  30. package/build/src/index.js +1 -0
  31. package/build/src/lib/console-connect-options.d.ts +97 -0
  32. package/build/src/lib/console-connect-options.js +199 -0
  33. package/build/src/lib/console-telemetry-validate.d.ts +49 -0
  34. package/build/src/lib/console-telemetry-validate.js +91 -0
  35. package/build/src/lib/flow-resolver.d.ts +54 -1
  36. package/build/src/lib/flow-resolver.js +112 -5
  37. package/build/src/lib/fs.d.ts +17 -0
  38. package/build/src/lib/fs.js +172 -0
  39. package/build/src/lib/local-artifact-root.d.ts +44 -1
  40. package/build/src/lib/local-artifact-root.js +131 -3
  41. package/build/src/runtime-adapters.d.ts +39 -3
  42. package/build/src/runtime-adapters.js +77 -31
  43. package/build/src/tools/build-universal-bundles.js +40 -2
  44. package/build/src/tools/codex-agent-routing.d.ts +2 -0
  45. package/build/src/tools/codex-agent-routing.js +49 -0
  46. package/build/src/tools/generate-context-map.js +1 -0
  47. package/build/src/tools/validate-source-tree.js +30 -3
  48. package/context/contracts/artifact-contract.md +16 -2
  49. package/context/scripts/hooks/lib/kit-catalog.js +235 -0
  50. package/context/scripts/hooks/lib/runnable-command.js +177 -0
  51. package/context/scripts/hooks/stop-goal-fit.js +278 -48
  52. package/context/scripts/hooks/workflow-steering.js +194 -22
  53. package/context/scripts/package.json +3 -0
  54. package/context/scripts/telemetry/install-console-config.sh +25 -4
  55. package/context/scripts/telemetry/lib/config.sh +102 -12
  56. package/context/scripts/telemetry/lib/pricing.sh +50 -0
  57. package/context/scripts/telemetry/lib/session.sh +3 -0
  58. package/context/scripts/telemetry/lib/transport.sh +87 -0
  59. package/context/scripts/telemetry/lib/usage.sh +205 -4
  60. package/context/scripts/telemetry/telemetry.conf +6 -0
  61. package/context/scripts/telemetry/telemetry.sh +48 -0
  62. package/context/settings/workspace-backlog-provider-settings.example.json +48 -0
  63. package/docs/agent-usage-feedback-loop.md +35 -0
  64. package/docs/architecture-engine-and-kits.md +110 -0
  65. package/docs/context-map.md +2 -0
  66. package/docs/coordination-guide.md +370 -0
  67. package/docs/decisions/agent-coordination.md +26 -9
  68. package/docs/decisions/embeddable-engine.md +152 -0
  69. package/docs/decisions/index.md +5 -3
  70. package/docs/decisions/trust-ledger-retention.md +88 -0
  71. package/docs/decisions/trust-reconcile.md +42 -9
  72. package/docs/decisions/workflow-enforcement.md +31 -9
  73. package/docs/fixture-ownership.md +6 -2
  74. package/docs/implementing-trust-reconciliation.md +129 -0
  75. package/docs/index.md +23 -9
  76. package/docs/integrations/flow-agents-console.md +275 -0
  77. package/docs/integrations/index.md +4 -0
  78. package/docs/kit-authoring-guide.md +52 -21
  79. package/docs/spec/builder-flow-runtime.md +80 -0
  80. package/docs/spec/runtime-hook-surface.md +45 -1
  81. package/docs/specs/economics-record-contract.md +270 -0
  82. package/docs/specs/harness-capability-matrix.md +74 -0
  83. package/docs/specs/learning-review-proposals-contract.md +340 -0
  84. package/docs/specs/routing-efficiency-review.md +59 -0
  85. package/docs/verifiable-trust.md +74 -25
  86. package/docs/workflow-artifact-lifecycle.md +38 -1
  87. package/docs/workflow-usage-guide.md +10 -0
  88. package/evals/acceptance/prove-capture-teeth.sh +132 -0
  89. package/evals/ci/antigaming-suite.sh +2 -0
  90. package/evals/ci/run-baseline.sh +78 -4
  91. package/evals/fixtures/economics/acceptance.json +12 -0
  92. package/evals/fixtures/economics/agents/tool-worker-1/events.jsonl +2 -0
  93. package/evals/fixtures/economics/agents/tool-worker-2/events.jsonl +2 -0
  94. package/evals/fixtures/economics/agents/tool-worker-3/events.jsonl +2 -0
  95. package/evals/fixtures/economics/agents/tool-worker-4/events.jsonl +1 -0
  96. package/evals/fixtures/economics/agents/tool-worker-5/events.jsonl +2 -0
  97. package/evals/fixtures/economics/critique.json +22 -0
  98. package/evals/fixtures/economics/expected-record.json +71 -0
  99. package/evals/fixtures/economics/session-usage-event.json +1 -0
  100. package/evals/fixtures/economics/state.json +11 -0
  101. package/evals/fixtures/economics/transcript.jsonl +3 -0
  102. package/evals/fixtures/hook-influence/cases.json +7 -7
  103. package/evals/fixtures/learning-review-proposals/balanced/economics.jsonl +6 -0
  104. package/evals/fixtures/learning-review-proposals/effect-follow-up/economics.jsonl +5 -0
  105. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-1/trust.bundle +21 -0
  106. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-2/trust.bundle +21 -0
  107. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-3/trust.bundle +21 -0
  108. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-4/trust.bundle +21 -0
  109. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-5/trust.bundle +21 -0
  110. package/evals/fixtures/learning-review-proposals/pattern-present/economics.jsonl +6 -0
  111. package/evals/fixtures/learning-review-proposals/pattern-present/expected-aggregates.json +30 -0
  112. package/evals/fixtures/learning-review-proposals/pattern-present/expected-aggregates.md +66 -0
  113. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-1/gate-review.inquiries.json +26 -0
  114. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-1/trust.bundle +21 -0
  115. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-2/gate-review.inquiries.json +26 -0
  116. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-2/trust.bundle +21 -0
  117. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-3/gate-review.inquiries.json +26 -0
  118. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-3/trust.bundle +21 -0
  119. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-4/gate-review.inquiries.json +26 -0
  120. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-4/trust.bundle +21 -0
  121. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-5/trust.bundle +21 -0
  122. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-6/trust.bundle +21 -0
  123. package/evals/fixtures/learning-review-proposals/repeat-window/economics.jsonl +6 -0
  124. package/evals/fixtures/learning-review-proposals/under-threshold/economics.jsonl +3 -0
  125. package/evals/fixtures/reconcile-preflight/disputed-critique-unsuperseded.json +48 -0
  126. package/evals/fixtures/reconcile-preflight/standalone-disputed-session-local.json +59 -0
  127. package/evals/fixtures/telemetry/usage-transcript-sample.jsonl +4 -0
  128. package/evals/fixtures/trust-reconcile-exploits/mcp-degrade.json +42 -0
  129. package/evals/integration/test_builder_entry_enforcement.sh +241 -0
  130. package/evals/integration/test_builder_step_producers.sh +18 -10
  131. package/evals/integration/test_bundle_install.sh +172 -0
  132. package/evals/integration/test_checkpoint_signing.sh +10 -2
  133. package/evals/integration/test_ci_actor_identity.sh +221 -0
  134. package/evals/integration/test_console_tenant_isolation.sh +167 -0
  135. package/evals/integration/test_critique_supersession_roundtrip.sh +4 -1
  136. package/evals/integration/test_dual_emit_flow_step.sh +10 -4
  137. package/evals/integration/test_economics_record.sh +674 -0
  138. package/evals/integration/test_effective_backlog_settings.sh +1 -1
  139. package/evals/integration/test_evidence_capture_hook.sh +17 -2
  140. package/evals/integration/test_exemption_usage_review.sh +198 -0
  141. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  142. package/evals/integration/test_flow_kit_install_git.sh +83 -0
  143. package/evals/integration/test_flowdef_session_activation.sh +0 -1
  144. package/evals/integration/test_flowdef_session_history_preservation.sh +13 -3
  145. package/evals/integration/test_gate_lockdown.sh +7 -0
  146. package/evals/integration/test_gate_review_inquiry_records.sh +9 -1
  147. package/evals/integration/test_goal_fit_hook.sh +2031 -0
  148. package/evals/integration/test_hook_category_behaviors.sh +8 -1
  149. package/evals/integration/test_hook_influence_cases.sh +25 -1
  150. package/evals/integration/test_install_merge.sh +227 -2
  151. package/evals/integration/test_kit_conformance_levels.sh +6 -6
  152. package/evals/integration/test_learning_review_proposals.sh +329 -0
  153. package/evals/integration/test_liveness_conflict_injection.sh +26 -22
  154. package/evals/integration/test_liveness_console_relay.sh +166 -0
  155. package/evals/integration/test_liveness_heartbeat.sh +17 -17
  156. package/evals/integration/test_liveness_worktree_root.sh +575 -0
  157. package/evals/integration/test_phase_map_and_gate_claim.sh +6 -1
  158. package/evals/integration/test_publish_delivery.sh +389 -2
  159. package/evals/integration/test_pull_work_board.sh +200 -0
  160. package/evals/integration/test_pull_work_provider.sh +1 -1
  161. package/evals/integration/test_reconcile_preflight.sh +304 -0
  162. package/evals/integration/test_record_check.sh +378 -0
  163. package/evals/integration/test_routing_efficiency.sh +71 -0
  164. package/evals/integration/test_runtime_adapter_activation.sh +28 -0
  165. package/evals/integration/test_session_resume_roundtrip.sh +16 -19
  166. package/evals/integration/test_skill_drift_check.sh +870 -0
  167. package/evals/integration/test_takeover_protocol.sh +340 -0
  168. package/evals/integration/test_telemetry.sh +445 -0
  169. package/evals/integration/test_telemetry_doctor.sh +66 -0
  170. package/evals/integration/test_telemetry_usage_pipeline.sh +228 -0
  171. package/evals/integration/test_trust_reconcile_negatives.sh +121 -13
  172. package/evals/integration/test_trust_reconcile_trailer_diagnostic.sh +247 -0
  173. package/evals/integration/test_usage_cost.sh +61 -0
  174. package/evals/integration/test_verify_hold.sh +910 -0
  175. package/evals/integration/test_veritas_governance_kit.sh +257 -0
  176. package/evals/integration/test_workflow_artifact_cleanup_audit.sh +575 -3
  177. package/evals/integration/test_workflow_sidecar_writer.sh +1395 -0
  178. package/evals/integration/test_workflow_steering_hook.sh +157 -16
  179. package/evals/integration/test_workspace_settings.sh +176 -0
  180. package/evals/lib/env.sh +26 -0
  181. package/evals/lib/node.sh +8 -0
  182. package/evals/run.sh +37 -0
  183. package/evals/static/test_ci_integration_coverage.sh +115 -0
  184. package/evals/static/test_declared_scope_forms_documented.sh +114 -0
  185. package/evals/static/test_universal_bundles.sh +34 -0
  186. package/evals/static/test_validate_source_kit_asset_scope.sh +259 -0
  187. package/evals/static/test_workflow_skills.sh +1 -1
  188. package/kits/builder/flows/build.flow.json +9 -18
  189. package/kits/builder/flows/publish-learn.flow.json +5 -1
  190. package/kits/builder/kit.json +120 -0
  191. package/kits/builder/skills/continue-work/SKILL.md +2 -0
  192. package/kits/builder/skills/deliver/SKILL.md +115 -0
  193. package/kits/builder/skills/evidence-gate/SKILL.md +12 -0
  194. package/kits/builder/skills/execute-plan/SKILL.md +9 -0
  195. package/kits/builder/skills/learning-review/SKILL.md +51 -0
  196. package/kits/builder/skills/plan-work/SKILL.md +17 -20
  197. package/kits/builder/skills/pull-work/SKILL.md +33 -2
  198. package/kits/builder/skills/release-readiness/SKILL.md +12 -0
  199. package/kits/knowledge/kit.json +9 -0
  200. package/kits/veritas-governance/docs/README.md +113 -7
  201. package/kits/veritas-governance/fixtures/exemption/approved.trust-bundle.json +74 -0
  202. package/kits/veritas-governance/fixtures/exemption/not-approved.trust-bundle.json +74 -0
  203. package/kits/veritas-governance/fixtures/exemption-review/mixed-fresh-stale.DECLARED.json +14 -0
  204. package/kits/veritas-governance/flows/exemption-issuance.flow.json +35 -0
  205. package/kits/veritas-governance/kit.json +19 -0
  206. package/kits/veritas-governance/skills/exemption-usage-review/SKILL.md +128 -0
  207. package/kits/veritas-governance/skills/exemption-usage-review/review-exemptions.mjs +231 -0
  208. package/package.json +2 -2
  209. package/packaging/manifest.json +29 -0
  210. package/schemas/backlog-provider-settings.schema.json +13 -0
  211. package/schemas/workflow-state.schema.json +44 -0
  212. package/scripts/README.md +4 -0
  213. package/scripts/check-content-boundary.cjs +8 -1
  214. package/scripts/ci/trust-reconcile.js +214 -253
  215. package/scripts/hooks/codex-hook-adapter.js +77 -2
  216. package/scripts/hooks/evidence-capture.js +38 -5
  217. package/scripts/hooks/lib/actor-identity.js +82 -0
  218. package/scripts/hooks/lib/codex-exit-code.js +316 -0
  219. package/scripts/hooks/lib/kit-catalog.js +235 -0
  220. package/scripts/hooks/lib/liveness-write.js +28 -1
  221. package/scripts/hooks/lib/local-artifact-paths.js +97 -1
  222. package/scripts/hooks/lib/runnable-command.js +177 -0
  223. package/scripts/hooks/lib/skill-drift.js +350 -0
  224. package/scripts/hooks/stop-goal-fit.js +278 -48
  225. package/scripts/hooks/workflow-steering.js +194 -22
  226. package/scripts/install-codex-home.sh +97 -47
  227. package/scripts/install-merge.js +72 -14
  228. package/scripts/install-owned-files.js +178 -0
  229. package/scripts/lib/reconcile-shape.js +381 -0
  230. package/scripts/liveness/relay.sh +84 -0
  231. package/scripts/telemetry/economics-record.schema.json +145 -0
  232. package/scripts/telemetry/economics-record.sh +331 -0
  233. package/scripts/telemetry/install-console-config.sh +25 -4
  234. package/scripts/telemetry/learning-review-decide.sh +124 -0
  235. package/scripts/telemetry/learning-review-proposals.schema.json +161 -0
  236. package/scripts/telemetry/learning-review-proposals.sh +484 -0
  237. package/scripts/telemetry/lib/config.sh +102 -12
  238. package/scripts/telemetry/lib/pricing.sh +14 -6
  239. package/scripts/telemetry/lib/session.sh +3 -0
  240. package/scripts/telemetry/lib/transport.sh +133 -15
  241. package/scripts/telemetry/lib/usage.sh +121 -28
  242. package/scripts/telemetry/routing-efficiency.sh +0 -0
  243. package/scripts/telemetry/telemetry.conf +6 -0
  244. package/scripts/telemetry/telemetry.sh +48 -0
  245. package/src/builder-flow-run-adapter.ts +357 -0
  246. package/src/builder-flow-runtime.ts +348 -0
  247. package/src/cli/assignment-provider.ts +12 -1
  248. package/src/cli/builder-flow-run-adapter.test.mjs +495 -0
  249. package/src/cli/builder-flow-runtime.test.mjs +213 -0
  250. package/src/cli/builder-run.ts +28 -0
  251. package/src/cli/codex-agent-routing.test.mjs +44 -0
  252. package/src/cli/codex-exit-code.test.mjs +207 -0
  253. package/src/cli/console-connect-options.test.mjs +329 -0
  254. package/src/cli/console-telemetry-validate.test.mjs +157 -0
  255. package/src/cli/effective-backlog-settings.ts +68 -2
  256. package/src/cli/flow-resolver-composition.test.mjs +72 -0
  257. package/src/cli/init.test.mjs +161 -0
  258. package/src/cli/init.ts +407 -62
  259. package/src/cli/kit-metadata-security.test.mjs +443 -0
  260. package/src/cli/kit.ts +50 -12
  261. package/src/cli/pull-work-provider.ts +377 -3
  262. package/src/cli/sidecar-pure-helpers.test.mjs +64 -0
  263. package/src/cli/skill-drift-check.ts +196 -0
  264. package/src/cli/telemetry-doctor.test.mjs +53 -0
  265. package/src/cli/telemetry-doctor.ts +50 -7
  266. package/src/cli/validate-hook-influence.ts +37 -6
  267. package/src/cli/workflow-artifact-cleanup-audit.ts +483 -10
  268. package/src/cli/workflow-sidecar.ts +1980 -119
  269. package/src/cli.ts +5 -0
  270. package/src/flow-kit/validate.ts +277 -38
  271. package/src/index.ts +19 -0
  272. package/src/lib/console-connect-options.ts +261 -0
  273. package/src/lib/console-telemetry-validate.ts +88 -0
  274. package/src/lib/flow-resolver.ts +117 -4
  275. package/src/lib/fs.ts +160 -0
  276. package/src/lib/local-artifact-root.ts +129 -3
  277. package/src/runtime-adapters.ts +113 -33
  278. package/src/tools/build-universal-bundles.ts +36 -2
  279. package/src/tools/codex-agent-routing.ts +48 -0
  280. package/src/tools/generate-context-map.ts +1 -0
  281. package/src/tools/validate-source-tree.ts +29 -3
@@ -12,7 +12,12 @@
12
12
  * Records to `.kontourai/flow-agents/<slug>/command-log.jsonl`, one JSON object per line:
13
13
  * {
14
14
  * "command": "<the command string the agent ran>",
15
- * "observedResult": "pass" | "fail", // deterministically inferred
15
+ * "observedResult": "pass" | "fail" | "ambiguous", // deterministically inferred
16
+ * // ("ambiguous": a bare, non-self-asserting
17
+ * // grep/diff exited 1 — could mean zero
18
+ * // matches/no differences (PASS for an
19
+ * // absence check) or an unintended miss
20
+ * // (FAIL for a presence check); #362)
16
21
  * "exitCode": <integer> | null, // null when only pass/fail is inferable
17
22
  * "capturedAt": "<ISO-8601 timestamp>",
18
23
  * "source": "postToolUse-capture",
@@ -38,7 +43,10 @@
38
43
  * (per docs/spec/runtime-hook-surface.md §1, postToolUse). A clean integer exit
39
44
  * code is host-dependent. We extract the real exit code where present; otherwise
40
45
  * we derive `observedResult` from `error`/stderr-style failure indication and
41
- * record `exitCode: null`. We never record the model's words about the outcome.
46
+ * record `exitCode: null`. Absent positive success evidence (a clean integer
47
+ * exit code of 0; no host currently surfaces a positive success flag), the
48
+ * result is 'ambiguous', never 'pass'. We never record the model's words about
49
+ * the outcome.
42
50
  *
43
51
  * Non-blocking — always exits 0. Idempotent/append-only. Fail-open on any error:
44
52
  * a capture failure must never block the agent or corrupt the log.
@@ -51,6 +59,7 @@ const path = require('path');
51
59
  const { flowAgentsArtifactRootsForRead } = require('./lib/local-artifact-paths');
52
60
  const { resolveActor } = require('./lib/actor-identity.js');
53
61
  const { readCurrentPointer } = require('./lib/current-pointer.js');
62
+ const { isAmbiguousAbsenceCommand } = require('./lib/runnable-command.js');
54
63
  const crypto = require('crypto');
55
64
 
56
65
  const MAX_STDIN = 1024 * 1024;
@@ -248,15 +257,32 @@ function cleanExitCode(value) {
248
257
  *
249
258
  * Priority:
250
259
  * 1. A clean integer exit code anywhere the host surfaces it → exitCode set;
251
- * observedResult = pass iff exitCode === 0.
260
+ * observedResult = pass iff exitCode === 0, EXCEPT (#362) a bare,
261
+ * non-self-asserting `grep`/`diff` invocation (per
262
+ * `isAmbiguousAbsenceCommand`, ./lib/runnable-command.js) that exits
263
+ * EXACTLY 1 → observedResult = 'ambiguous' instead of 'fail'. Exit 1 for
264
+ * such a command could mean zero matches/no differences (PASS for an
265
+ * absence check) or an unintended miss (FAIL for a presence check) —
266
+ * this is never coerced to 'pass' (that would trade a false-block for a
267
+ * false-pass) nor silently left as 'fail' (that is the exact false
268
+ * caught-completion #362 reports). Exit codes >= 2 for these two
269
+ * binaries are real tool errors and remain 'fail', unchanged.
252
270
  * 2. Else, a non-empty `error` field or stderr-style failure indication →
253
271
  * observedResult = fail, exitCode = null.
254
- * 3. Else → observedResult = pass, exitCode = null.
272
+ * 3. Else → observedResult = ambiguous, exitCode = null. Absent positive
273
+ * success evidence (a clean integer exit code of 0; no host currently
274
+ * surfaces a positive success flag), the result is 'ambiguous', never
275
+ * 'pass'.
276
+ *
277
+ * `input.command` (the raw command string, already in scope at the run()
278
+ * call site) is required to evaluate the #362 carve-out; when absent, the
279
+ * carve-out simply never fires and behavior is byte-identical to before #362.
255
280
  */
256
281
  function observeResult(input) {
257
282
  const response = input.tool_response;
258
283
  const output = input.tool_output;
259
284
  const error = input.error;
285
+ const command = typeof input.command === 'string' ? input.command : '';
260
286
 
261
287
  // Candidate locations for a host-provided exit code.
262
288
  const candidates = [];
@@ -274,6 +300,9 @@ function observeResult(input) {
274
300
  }
275
301
 
276
302
  if (exitCode !== null) {
303
+ if (exitCode === 1 && command && isAmbiguousAbsenceCommand(command)) {
304
+ return { exitCode, observedResult: 'ambiguous' };
305
+ }
277
306
  return { exitCode, observedResult: exitCode === 0 ? 'pass' : 'fail' };
278
307
  }
279
308
 
@@ -281,7 +310,7 @@ function observeResult(input) {
281
310
  if (isFailureIndicated(error, response, output)) {
282
311
  return { exitCode: null, observedResult: 'fail' };
283
312
  }
284
- return { exitCode: null, observedResult: 'pass' };
313
+ return { exitCode: null, observedResult: 'ambiguous' };
285
314
  }
286
315
 
287
316
  // True when the host surfaces a deterministic failure signal: a non-empty
@@ -326,6 +355,10 @@ function run(rawInput) {
326
355
  exit_code: input.exit_code,
327
356
  status: input.status,
328
357
  code: input.code,
358
+ // #362: thread the raw command string through so observeResult can apply the
359
+ // bare-grep/diff-exit-1 ambiguous carve-out. `command` is already in scope here
360
+ // (extracted above from input.tool_input.command).
361
+ command,
329
362
  });
330
363
 
331
364
  const record = {
@@ -229,6 +229,75 @@ function sanitizeSegment(value) {
229
229
  return cleaned || 'unknown';
230
230
  }
231
231
 
232
+ /**
233
+ * #398: detect a CI-triggered session and derive a STABLE actor identity from the CI provider's
234
+ * published identifiers. The right granularity is the JOB/RUN (a CI job ≈ one agent session), NOT
235
+ * the runner machine (hostname can be shared/reused across jobs). Detection is order-independent
236
+ * (each provider gates on its own canonical marker env var). Returns `{ runtime, session_id }` on a
237
+ * recognized provider WITH a non-blank stable id, else `null` (caller falls through to
238
+ * process-ancestry, where #293's advisory verify-hold net still protects).
239
+ *
240
+ * Deliberately conservative: a generic/unrecognized `CI=true` returns null rather than fabricating
241
+ * a "stable" id from something that might shift between subprocesses — a wrong stable classification
242
+ * would let the #293 hard gate ENFORCE against a shifting identity, which is worse than advisory.
243
+ * All segments are sanitized by serializeActor at the call site (allowed charset, length-capped),
244
+ * so a hostile env var value cannot inject.
245
+ *
246
+ * ACCEPTED GRANULARITY GAP (matrix / parallelism): the id is job/run-granular. GitLab (`CI_JOB_ID`),
247
+ * Azure (`SYSTEM_JOBID`), and Buildkite (`BUILDKITE_JOB_ID`) expose a per-job-INSTANCE id that is
248
+ * already unique across matrix/parallel legs. CircleCI parallelism is disambiguated below via
249
+ * `CIRCLE_NODE_INDEX`. But GitHub Actions and Jenkins declarative-`matrix{}` cells share
250
+ * `GITHUB_JOB` / `BUILD_TAG` across all legs of one run+attempt — matrix values live only in the
251
+ * `${{ matrix }}` / axis context, not in any env var this can read — so two concurrent legs collapse
252
+ * to the SAME CI actor. This degrades SAFELY: worst case is idempotent self-recognition (one leg
253
+ * treats another's claim as its own); it never false-blocks and never injects. The coordination-
254
+ * relevant CI jobs (trust-reconcile / publish) run as single, non-matrix jobs today. Filed as a
255
+ * fast-follow to add a GitHub-matrix disambiguator if a coordination path ever runs under a matrix.
256
+ *
257
+ * @param {NodeJS.ProcessEnv} [env]
258
+ * @returns {{runtime: string, session_id: string} | null}
259
+ */
260
+ function detectCiActor(env = process.env) {
261
+ env = env || {};
262
+ const s = (v) => String(v == null ? '' : v).trim();
263
+ const compose = (...parts) => parts.map(s).filter(Boolean).join('-');
264
+
265
+ // GitHub Actions — run id + attempt + job is unique and stable across the whole job.
266
+ if (s(env.GITHUB_ACTIONS) === 'true') {
267
+ const id = compose(env.GITHUB_RUN_ID, env.GITHUB_RUN_ATTEMPT, env.GITHUB_JOB);
268
+ return id ? { runtime: 'github-actions', session_id: id } : null;
269
+ }
270
+ // GitLab CI — CI_JOB_ID is stable per job.
271
+ if (s(env.GITLAB_CI) === 'true') {
272
+ const id = s(env.CI_JOB_ID);
273
+ return id ? { runtime: 'gitlab-ci', session_id: id } : null;
274
+ }
275
+ // CircleCI — workflow id + job name + node index (CIRCLE_NODE_INDEX disambiguates the containers
276
+ // of a `parallelism: N` job; "0" for a single-container job, so it is always present and stable).
277
+ if (s(env.CIRCLECI) === 'true') {
278
+ const id = compose(env.CIRCLE_WORKFLOW_ID, env.CIRCLE_JOB, env.CIRCLE_NODE_INDEX) || s(env.CIRCLE_BUILD_NUM);
279
+ return id ? { runtime: 'circleci', session_id: id } : null;
280
+ }
281
+ // Jenkins — BUILD_TAG is the stable per-build identifier; fall back to job + build id.
282
+ if (s(env.JENKINS_URL)) {
283
+ const id = s(env.BUILD_TAG) || compose(env.JOB_NAME, env.BUILD_ID);
284
+ return id ? { runtime: 'jenkins', session_id: id } : null;
285
+ }
286
+ // Azure Pipelines — build id + system job id (SYSTEM_JOBID is a per-job-instance GUID, unique
287
+ // across matrix/strategy legs).
288
+ if (s(env.TF_BUILD) === 'true') {
289
+ const id = compose(env.BUILD_BUILDID, env.SYSTEM_JOBID);
290
+ return id ? { runtime: 'azure-pipelines', session_id: id } : null;
291
+ }
292
+ // Buildkite — job id is a stable UUID per job.
293
+ if (s(env.BUILDKITE) === 'true') {
294
+ const id = s(env.BUILDKITE_JOB_ID);
295
+ return id ? { runtime: 'buildkite', session_id: id } : null;
296
+ }
297
+ // Generic/unrecognized CI: do NOT fabricate stability — fall through to process-ancestry.
298
+ return null;
299
+ }
300
+
232
301
  /**
233
302
  * Serialize a runtime-agnostic actor struct into a single string safe for
234
303
  * the existing `${subjectId}::${actor}` grouping key: each field is passed
@@ -334,6 +403,18 @@ function resolveActor(env = process.env) {
334
403
  return { actor, source: `runtime-session-id:${runtime}` };
335
404
  }
336
405
 
406
+ // #398: CI-runtime tier — sits ABOVE process-ancestry (stable across every subprocess in a CI
407
+ // job) and BELOW an explicit override / native runtime session id (those are more specific and
408
+ // already returned above). A CI-triggered agent session otherwise falls to process-ancestry,
409
+ // whose seed differs across subprocesses within one job (a subject claimed in `ensure-session`
410
+ // isn't recognized as self at `publish`) — the exact instability #293 had to degrade the
411
+ // verify-hold gate to advisory for. A stable CI identity lets that gate ENFORCE instead.
412
+ const ci = detectCiActor(env);
413
+ if (ci && ci.session_id) {
414
+ const actor = serializeActor({ runtime: ci.runtime, session_id: ci.session_id, host: os.hostname() });
415
+ return { actor, source: `ci-runtime:${ci.runtime}` };
416
+ }
417
+
337
418
  const seed = ancestorActorSeed();
338
419
  if (seed) {
339
420
  const actor = serializeActor({ runtime, session_id: `anc-${seed}`, host: os.hostname() });
@@ -360,6 +441,7 @@ function isUnresolvedActor(actor) {
360
441
  module.exports = {
361
442
  detectRuntime,
362
443
  runtimeSessionId,
444
+ detectCiActor,
363
445
  ancestorActorSeed,
364
446
  sanitizeSegment,
365
447
  serializeActor,
@@ -0,0 +1,316 @@
1
+ 'use strict';
2
+ /**
3
+ * codex-exit-code.js — codex-only host-banner exit-code extraction (#470).
4
+ *
5
+ * The codex PostToolUse payload carries no structured exit code (no
6
+ * `tool_response.exitCode`/`exit_code`, no `error`, no `stderr`). The only
7
+ * deterministic host signal is a fixed-format banner the codex CLI itself
8
+ * writes into the session rollout's `function_call_output`:
9
+ *
10
+ * Process exited with code N
11
+ *
12
+ * This is HOST-authored prose (observed on codex-cli 0.142.5), not model
13
+ * narration — regex-matching exactly this banner is a deterministic host
14
+ * signal, distinct from scanning stdout/narration for "error"/"fail" words
15
+ * (which docs/spec/runtime-hook-surface.md §2.5 forbids and this module does
16
+ * not do).
17
+ *
18
+ * SECURITY (iteration 2 / #470 review). The banner lives in the HOST-authored
19
+ * PREAMBLE of a `function_call_output.output` string, BEFORE the model's own
20
+ * stdout, which is appended after a literal `Output:` delimiter:
21
+ *
22
+ * Process exited with code 1
23
+ * Original token count: 25
24
+ * Output:
25
+ * <arbitrary model-produced stdout, which may itself contain the string
26
+ * "Process exited with code 0" — this is NOT a host signal and must never
27
+ * be matched>
28
+ *
29
+ * Both exports are therefore PREAMBLE-ANCHORED: extraction only ever looks
30
+ * BEFORE the first `Output:` delimiter and takes the FIRST banner match there
31
+ * (the host writes it once). The post-delimiter model stdout is never
32
+ * scanned, at any size — this is what makes the extraction fail-safe against
33
+ * a command that deliberately prints a forged banner to stdout.
34
+ *
35
+ * Both exports are pure/fail-open: malformed input, a missing/unreadable file,
36
+ * an oversized/inaccessible rollout, or an ambiguous cross-call correlation
37
+ * all degrade to `null` ("no signal"), never a throw. Callers must treat
38
+ * `null` as "extraction unavailable", not as exit code 0.
39
+ */
40
+
41
+ const fs = require('fs');
42
+ const path = require('path');
43
+ const os = require('os');
44
+
45
+ // The two forms the `Output:` delimiter can take depending on how far the
46
+ // caller has decoded the surrounding JSON:
47
+ // - a JSON.parse'd `output` string carries the delimiter as a REAL newline
48
+ // pair: "...\nOutput:\n...".
49
+ // - a raw (still JSON-escaped) slice of the rollout file — used only by the
50
+ // head-only fallback below, when a line is too large to fully parse —
51
+ // carries it literally escaped: "...\\nOutput:\\n...".
52
+ // extractExitCodeFromBanner accepts either input flavor and anchors on
53
+ // whichever form is present (real callers only ever hand it one flavor at a
54
+ // time; this is not an ambiguity, just support for both call sites).
55
+ const ANCHOR_REAL = '\nOutput:\n';
56
+ const ANCHOR_ESCAPED = '\\nOutput:\\n';
57
+
58
+ const BANNER_RE = /Process exited with code (\d+)/;
59
+
60
+ function splitPreamble(text) {
61
+ const iReal = text.indexOf(ANCHOR_REAL);
62
+ const iEsc = text.indexOf(ANCHOR_ESCAPED);
63
+ let idx = -1;
64
+ if (iReal !== -1 && iEsc !== -1) idx = Math.min(iReal, iEsc);
65
+ else if (iReal !== -1) idx = iReal;
66
+ else if (iEsc !== -1) idx = iEsc;
67
+ // No anchor found at all: fall back to treating the whole text as the
68
+ // preamble (documented residual — the observed codex format always
69
+ // includes the `Output:` delimiter; see docs/spec + plan risk log).
70
+ return idx === -1 ? text : text.slice(0, idx);
71
+ }
72
+
73
+ /**
74
+ * extractExitCodeFromBanner(text) → integer | null
75
+ *
76
+ * Pure regex extraction — no I/O, cannot throw on well-formed string input.
77
+ * PREAMBLE-ANCHORED (#470 iteration 2, CRITICAL finding #1): splits `text` on
78
+ * the FIRST `Output:` delimiter (real or JSON-escaped form) and matches the
79
+ * banner ONLY in the portion before it, returning the FIRST match. The
80
+ * model's stdout after the delimiter is never scanned, so a command that
81
+ * prints a forged `Process exited with code 0` to its own stdout cannot
82
+ * override the real host-reported code in the preamble.
83
+ */
84
+ function extractExitCodeFromBanner(text) {
85
+ if (typeof text !== 'string' || !text) return null;
86
+ const preamble = splitPreamble(text);
87
+ const match = BANNER_RE.exec(preamble);
88
+ if (!match) return null;
89
+ const n = Number(match[1]);
90
+ return Number.isInteger(n) ? n : null;
91
+ }
92
+
93
+ function normalizeCommandText(value) {
94
+ if (typeof value === 'string') return value.trim() || null;
95
+ if (Array.isArray(value)) {
96
+ const joined = value.map(v => String(v)).join(' ').trim();
97
+ return joined || null;
98
+ }
99
+ return null;
100
+ }
101
+
102
+ // Best-effort parse of a rollout `function_call`'s `arguments` field into a
103
+ // normalized command string, used only for the command cross-check
104
+ // correlation (Decision B #2). Any shape mismatch degrades to `null`
105
+ // (unresolvable pairing), never a throw.
106
+ function normalizeCallArguments(argumentsField) {
107
+ let parsed = argumentsField;
108
+ if (typeof argumentsField === 'string') {
109
+ try {
110
+ parsed = JSON.parse(argumentsField);
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+ if (!parsed || typeof parsed !== 'object') return null;
116
+ return normalizeCommandText(parsed.command);
117
+ }
118
+
119
+ const OUTPUT_FIELD_NEEDLE = '"output":"';
120
+
121
+ /**
122
+ * parseCandidateLine(line, maxLineHeadBytes) → candidate | null
123
+ *
124
+ * Interprets one JSONL rollout line as either a `function_call_output` or a
125
+ * `function_call` response_item, HEAD-ANCHORED (#470 iteration 2, MEDIUM
126
+ * finding #5): a line that fits within `maxLineHeadBytes` is fully
127
+ * `JSON.parse`d (cheap, and correct for the real-newline anchor form); a line
128
+ * that exceeds the head window is NEVER fully parsed (that would mean holding
129
+ * arbitrarily large flooded stdout in memory and risks the preamble/banner —
130
+ * which lives at the line HEAD — being reasoned about incorrectly). Instead
131
+ * only the first `maxLineHeadBytes` raw bytes are inspected: the banner and
132
+ * `Output:` delimiter live within the first few hundred bytes of the `output`
133
+ * field regardless of how much stdout follows, so the raw head slice is
134
+ * sufficient for `extractExitCodeFromBanner` (escaped-anchor form).
135
+ */
136
+ function parseCandidateLine(line, maxLineHeadBytes) {
137
+ const byteLen = Buffer.byteLength(line, 'utf8');
138
+ if (byteLen <= maxLineHeadBytes) {
139
+ let entry;
140
+ try {
141
+ entry = JSON.parse(line);
142
+ } catch {
143
+ return null; // malformed/partial line — skip, keep scanning
144
+ }
145
+ const payload = entry && typeof entry === 'object' ? entry.payload : null;
146
+ if (!payload || typeof payload !== 'object') return null;
147
+ const callId = typeof payload.call_id === 'string' ? payload.call_id : null;
148
+ if (payload.type === 'function_call_output' && typeof payload.output === 'string') {
149
+ return { type: 'function_call_output', callId, output: payload.output };
150
+ }
151
+ if (payload.type === 'function_call') {
152
+ return { type: 'function_call', callId, command: normalizeCallArguments(payload.arguments) };
153
+ }
154
+ return null;
155
+ }
156
+
157
+ // Line exceeds the head window: raw, head-only, best-effort extraction.
158
+ // `function_call` entries carry only a short command string in practice and
159
+ // are not expected to exceed the head window; only `function_call_output`
160
+ // (whose stdout can flood) is handled here.
161
+ const head = line.slice(0, maxLineHeadBytes);
162
+ if (head.indexOf('"type":"function_call_output"') === -1) return null;
163
+ const callIdMatch = /"call_id":"([^"]*)"/.exec(head);
164
+ const outputIdx = head.indexOf(OUTPUT_FIELD_NEEDLE);
165
+ if (outputIdx === -1) return null; // output field itself starts beyond the head window
166
+ const rawOutputHead = head.slice(outputIdx + OUTPUT_FIELD_NEEDLE.length);
167
+ return {
168
+ type: 'function_call_output',
169
+ callId: callIdMatch ? callIdMatch[1] : null,
170
+ output: rawOutputHead, // still JSON-escaped; extractExitCodeFromBanner handles both anchor forms
171
+ };
172
+ }
173
+
174
+ // Best-effort containment (#470 iteration 2, LOW finding #8): realpath the
175
+ // transcript path and require it to be a regular file; when the codex
176
+ // sessions root itself resolves on this host, additionally reject a realpath
177
+ // that escapes it (defends against a symlinked transcript_path pointing
178
+ // somewhere unexpected). Fail-open: any resolution error propagates to the
179
+ // caller's try/catch as "unusable" (null); a non-resolvable sessions root
180
+ // (e.g. custom CODEX_HOME, test fixtures) simply skips the containment check.
181
+ function resolveContainedRealPath(transcriptPath) {
182
+ const realPath = fs.realpathSync(transcriptPath);
183
+ const stat = fs.statSync(realPath);
184
+ if (!stat.isFile()) return null;
185
+ try {
186
+ const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
187
+ const sessionsRoot = path.join(codexHome, 'sessions');
188
+ const realRoot = fs.realpathSync(sessionsRoot);
189
+ const rel = path.relative(realRoot, realPath);
190
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null;
191
+ } catch {
192
+ // Sessions root doesn't exist/resolve on this host — best-effort only.
193
+ }
194
+ return realPath;
195
+ }
196
+
197
+ /**
198
+ * readExitCodeFromRollout(transcriptPath, options) → integer | null
199
+ *
200
+ * options:
201
+ * - maxScanBytes (default 1MB): bounded backward-scan window from EOF used
202
+ * to locate candidate `response_item` lines. Beyond this cap, an
203
+ * unresolvable line start (and thus that candidate) is dropped — never
204
+ * read further back, and never a crash.
205
+ * - maxLineHeadBytes (default 64KB): per-line head window; see
206
+ * `parseCandidateLine`. This is what makes extraction survive a flooded
207
+ * `output` field (#470 iteration 2, MEDIUM finding #5) — the preamble
208
+ * banner sits at the line HEAD, so bounding reads to the head can never
209
+ * have the banner pushed out of the window by TAIL stdout volume.
210
+ * - callId: correlate to a specific `function_call_output.call_id`.
211
+ * - command: the claimed command (e.g. `tool_input.command`), used only for
212
+ * the command cross-check correlation below.
213
+ *
214
+ * Correlation policy (Decision B, #470 iteration 2, HIGH finding #4), in
215
+ * priority order:
216
+ * 1. call_id match wins — authoritative.
217
+ * 2. Absent a call_id match: take the newest `function_call_output` in the
218
+ * scanned window, resolve its paired `function_call` (same call_id), and
219
+ * compare that call's `arguments` command (normalized) to `command`. A
220
+ * resolved MATCH uses the newest output; a resolved MISMATCH DECLINES
221
+ * (`null`) rather than knowingly attribute a different call's exit code.
222
+ * 3. If no pairing is resolvable at all (the common single-call case): fall
223
+ * back to the newest `function_call_output` banner.
224
+ *
225
+ * Any failure (missing/unreadable/non-regular file, containment violation,
226
+ * malformed JSON lines, no candidate found) yields `null` — never throws.
227
+ */
228
+ function readExitCodeFromRollout(transcriptPath, options) {
229
+ const opts = options || {};
230
+ const maxScanBytes = Number.isInteger(opts.maxScanBytes) && opts.maxScanBytes > 0
231
+ ? opts.maxScanBytes
232
+ : 1024 * 1024;
233
+ const maxLineHeadBytes = Number.isInteger(opts.maxLineHeadBytes) && opts.maxLineHeadBytes > 0
234
+ ? opts.maxLineHeadBytes
235
+ : 64 * 1024;
236
+ const callId = typeof opts.callId === 'string' && opts.callId ? opts.callId : null;
237
+ const command = normalizeCommandText(opts.command);
238
+
239
+ if (typeof transcriptPath !== 'string' || !transcriptPath) return null;
240
+
241
+ let text;
242
+ let truncated;
243
+ try {
244
+ const realPath = resolveContainedRealPath(transcriptPath);
245
+ if (realPath === null) return null; // containment violation
246
+
247
+ const stat = fs.statSync(realPath);
248
+ const fd = fs.openSync(realPath, 'r');
249
+ try {
250
+ const readLen = Math.min(maxScanBytes, stat.size);
251
+ const start = stat.size - readLen;
252
+ const buf = Buffer.alloc(readLen);
253
+ fs.readSync(fd, buf, 0, readLen, start);
254
+ text = buf.toString('utf8');
255
+ // A read starting after byte 0 means the file is larger than the scan
256
+ // window and the first captured line may be a truncated fragment of a
257
+ // longer line that started before `start` — only then is it dropped.
258
+ truncated = start > 0;
259
+ } finally {
260
+ fs.closeSync(fd);
261
+ }
262
+ } catch {
263
+ return null;
264
+ }
265
+
266
+ let lines;
267
+ try {
268
+ lines = text.split('\n');
269
+ } catch {
270
+ return null;
271
+ }
272
+ if (truncated && lines.length > 1) lines.shift();
273
+
274
+ let matchedByCallId = null;
275
+ let newestOutputEntry = null;
276
+ let pairedCommand = null;
277
+
278
+ for (let i = lines.length - 1; i >= 0; i--) {
279
+ const line = lines[i].trim();
280
+ if (!line) continue;
281
+ const candidate = parseCandidateLine(line, maxLineHeadBytes);
282
+ if (!candidate) continue; // malformed/partial/unrecognized line — skip, keep scanning
283
+
284
+ if (candidate.type === 'function_call_output') {
285
+ if (newestOutputEntry === null) newestOutputEntry = candidate; // first seen scanning backward = newest
286
+ if (callId && candidate.callId === callId) {
287
+ matchedByCallId = candidate;
288
+ break; // call_id match is authoritative — stop scanning
289
+ }
290
+ } else if (candidate.type === 'function_call') {
291
+ if (newestOutputEntry && pairedCommand === null && candidate.callId === newestOutputEntry.callId) {
292
+ pairedCommand = candidate.command;
293
+ }
294
+ }
295
+ }
296
+
297
+ let chosenOutput = null;
298
+ if (matchedByCallId) {
299
+ chosenOutput = matchedByCallId.output;
300
+ } else if (newestOutputEntry) {
301
+ if (pairedCommand !== null && command) {
302
+ if (pairedCommand === command) {
303
+ chosenOutput = newestOutputEntry.output;
304
+ } else {
305
+ return null; // positive command mismatch — decline rather than mis-attribute
306
+ }
307
+ } else {
308
+ chosenOutput = newestOutputEntry.output; // single-call fallback — no pairing resolvable
309
+ }
310
+ }
311
+
312
+ if (chosenOutput === null) return null;
313
+ return extractExitCodeFromBanner(chosenOutput);
314
+ }
315
+
316
+ module.exports = { extractExitCodeFromBanner, readExitCodeFromRollout };