@kontourai/flow-agents 3.3.0 → 3.4.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 (256) hide show
  1. package/.github/workflows/add-to-project.yml +15 -0
  2. package/.github/workflows/ci.yml +161 -0
  3. package/CHANGELOG.md +48 -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/builder-run.d.ts +1 -0
  11. package/build/src/cli/builder-run.js +27 -0
  12. package/build/src/cli/effective-backlog-settings.js +70 -2
  13. package/build/src/cli/init.d.ts +34 -0
  14. package/build/src/cli/init.js +341 -61
  15. package/build/src/cli/kit.js +55 -12
  16. package/build/src/cli/pull-work-provider.js +346 -5
  17. package/build/src/cli/skill-drift-check.d.ts +1 -0
  18. package/build/src/cli/skill-drift-check.js +165 -0
  19. package/build/src/cli/telemetry-doctor.d.ts +37 -0
  20. package/build/src/cli/telemetry-doctor.js +53 -6
  21. package/build/src/cli/validate-hook-influence.js +37 -7
  22. package/build/src/cli/workflow-sidecar.d.ts +93 -8
  23. package/build/src/cli/workflow-sidecar.js +1175 -158
  24. package/build/src/cli.js +5 -0
  25. package/build/src/flow-kit/validate.d.ts +54 -34
  26. package/build/src/flow-kit/validate.js +237 -26
  27. package/build/src/index.d.ts +2 -0
  28. package/build/src/index.js +1 -0
  29. package/build/src/lib/console-connect-options.d.ts +97 -0
  30. package/build/src/lib/console-connect-options.js +199 -0
  31. package/build/src/lib/console-telemetry-validate.d.ts +49 -0
  32. package/build/src/lib/console-telemetry-validate.js +91 -0
  33. package/build/src/lib/flow-resolver.d.ts +56 -3
  34. package/build/src/lib/flow-resolver.js +151 -11
  35. package/build/src/lib/fs.d.ts +17 -0
  36. package/build/src/lib/fs.js +172 -0
  37. package/build/src/lib/local-artifact-root.d.ts +44 -1
  38. package/build/src/lib/local-artifact-root.js +131 -3
  39. package/build/src/runtime-adapters.d.ts +39 -3
  40. package/build/src/runtime-adapters.js +77 -31
  41. package/build/src/tools/build-universal-bundles.js +40 -2
  42. package/build/src/tools/codex-agent-routing.d.ts +2 -0
  43. package/build/src/tools/codex-agent-routing.js +49 -0
  44. package/build/src/tools/generate-context-map.js +1 -0
  45. package/build/src/tools/validate-source-tree.js +27 -1
  46. package/context/scripts/hooks/lib/kit-catalog.js +235 -0
  47. package/context/scripts/hooks/lib/runnable-command.js +177 -0
  48. package/context/scripts/hooks/stop-goal-fit.js +278 -48
  49. package/context/scripts/hooks/workflow-steering.js +121 -21
  50. package/context/scripts/package.json +3 -0
  51. package/context/scripts/telemetry/install-console-config.sh +25 -4
  52. package/context/scripts/telemetry/lib/config.sh +102 -12
  53. package/context/scripts/telemetry/lib/pricing.sh +50 -0
  54. package/context/scripts/telemetry/lib/session.sh +3 -0
  55. package/context/scripts/telemetry/lib/transport.sh +87 -0
  56. package/context/scripts/telemetry/lib/usage.sh +205 -4
  57. package/context/scripts/telemetry/telemetry.conf +6 -0
  58. package/context/scripts/telemetry/telemetry.sh +48 -0
  59. package/context/settings/workspace-backlog-provider-settings.example.json +48 -0
  60. package/docs/agent-usage-feedback-loop.md +35 -0
  61. package/docs/architecture-engine-and-kits.md +110 -0
  62. package/docs/context-map.md +2 -0
  63. package/docs/decisions/embeddable-engine.md +152 -0
  64. package/docs/decisions/index.md +3 -1
  65. package/docs/decisions/trust-ledger-retention.md +88 -0
  66. package/docs/decisions/workflow-enforcement.md +31 -9
  67. package/docs/fixture-ownership.md +3 -0
  68. package/docs/implementing-trust-reconciliation.md +129 -0
  69. package/docs/index.md +19 -9
  70. package/docs/integrations/flow-agents-console.md +167 -0
  71. package/docs/kit-authoring-guide.md +52 -21
  72. package/docs/spec/builder-flow-runtime.md +80 -0
  73. package/docs/spec/runtime-hook-surface.md +45 -1
  74. package/docs/specs/economics-record-contract.md +270 -0
  75. package/docs/specs/harness-capability-matrix.md +74 -0
  76. package/docs/specs/learning-review-proposals-contract.md +340 -0
  77. package/docs/specs/routing-efficiency-review.md +59 -0
  78. package/docs/verifiable-trust.md +74 -25
  79. package/docs/workflow-usage-guide.md +10 -0
  80. package/evals/acceptance/prove-capture-teeth.sh +132 -0
  81. package/evals/ci/antigaming-suite.sh +1 -0
  82. package/evals/ci/run-baseline.sh +72 -4
  83. package/evals/fixtures/economics/acceptance.json +12 -0
  84. package/evals/fixtures/economics/agents/tool-worker-1/events.jsonl +2 -0
  85. package/evals/fixtures/economics/agents/tool-worker-2/events.jsonl +2 -0
  86. package/evals/fixtures/economics/agents/tool-worker-3/events.jsonl +2 -0
  87. package/evals/fixtures/economics/agents/tool-worker-4/events.jsonl +1 -0
  88. package/evals/fixtures/economics/agents/tool-worker-5/events.jsonl +2 -0
  89. package/evals/fixtures/economics/critique.json +22 -0
  90. package/evals/fixtures/economics/expected-record.json +71 -0
  91. package/evals/fixtures/economics/session-usage-event.json +1 -0
  92. package/evals/fixtures/economics/state.json +11 -0
  93. package/evals/fixtures/economics/transcript.jsonl +3 -0
  94. package/evals/fixtures/hook-influence/cases.json +7 -7
  95. package/evals/fixtures/learning-review-proposals/balanced/economics.jsonl +6 -0
  96. package/evals/fixtures/learning-review-proposals/effect-follow-up/economics.jsonl +5 -0
  97. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-1/trust.bundle +21 -0
  98. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-2/trust.bundle +21 -0
  99. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-3/trust.bundle +21 -0
  100. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-4/trust.bundle +21 -0
  101. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-5/trust.bundle +21 -0
  102. package/evals/fixtures/learning-review-proposals/pattern-present/economics.jsonl +6 -0
  103. package/evals/fixtures/learning-review-proposals/pattern-present/expected-aggregates.json +30 -0
  104. package/evals/fixtures/learning-review-proposals/pattern-present/expected-aggregates.md +66 -0
  105. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-1/gate-review.inquiries.json +26 -0
  106. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-1/trust.bundle +21 -0
  107. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-2/gate-review.inquiries.json +26 -0
  108. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-2/trust.bundle +21 -0
  109. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-3/gate-review.inquiries.json +26 -0
  110. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-3/trust.bundle +21 -0
  111. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-4/gate-review.inquiries.json +26 -0
  112. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-4/trust.bundle +21 -0
  113. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-5/trust.bundle +21 -0
  114. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-6/trust.bundle +21 -0
  115. package/evals/fixtures/learning-review-proposals/repeat-window/economics.jsonl +6 -0
  116. package/evals/fixtures/learning-review-proposals/under-threshold/economics.jsonl +3 -0
  117. package/evals/fixtures/telemetry/usage-transcript-sample.jsonl +4 -0
  118. package/evals/fixtures/trust-reconcile-exploits/mcp-degrade.json +42 -0
  119. package/evals/integration/test_builder_entry_enforcement.sh +241 -0
  120. package/evals/integration/test_builder_step_producers.sh +18 -10
  121. package/evals/integration/test_bundle_install.sh +172 -0
  122. package/evals/integration/test_console_tenant_isolation.sh +167 -0
  123. package/evals/integration/test_critique_supersession_roundtrip.sh +4 -1
  124. package/evals/integration/test_dual_emit_flow_step.sh +10 -4
  125. package/evals/integration/test_economics_record.sh +674 -0
  126. package/evals/integration/test_effective_backlog_settings.sh +1 -1
  127. package/evals/integration/test_evidence_capture_hook.sh +17 -2
  128. package/evals/integration/test_exemption_usage_review.sh +198 -0
  129. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  130. package/evals/integration/test_flow_kit_install_git.sh +83 -0
  131. package/evals/integration/test_flowdef_session_activation.sh +0 -1
  132. package/evals/integration/test_flowdef_session_history_preservation.sh +13 -3
  133. package/evals/integration/test_gate_lockdown.sh +7 -0
  134. package/evals/integration/test_gate_review_inquiry_records.sh +9 -1
  135. package/evals/integration/test_goal_fit_hook.sh +2031 -0
  136. package/evals/integration/test_hook_category_behaviors.sh +8 -1
  137. package/evals/integration/test_hook_influence_cases.sh +25 -1
  138. package/evals/integration/test_install_merge.sh +227 -2
  139. package/evals/integration/test_kit_conformance_levels.sh +6 -6
  140. package/evals/integration/test_learning_review_proposals.sh +329 -0
  141. package/evals/integration/test_liveness_conflict_injection.sh +26 -22
  142. package/evals/integration/test_liveness_console_relay.sh +166 -0
  143. package/evals/integration/test_liveness_heartbeat.sh +17 -17
  144. package/evals/integration/test_liveness_worktree_root.sh +575 -0
  145. package/evals/integration/test_phase_map_and_gate_claim.sh +6 -1
  146. package/evals/integration/test_publish_delivery.sh +331 -1
  147. package/evals/integration/test_pull_work_board.sh +200 -0
  148. package/evals/integration/test_pull_work_provider.sh +1 -1
  149. package/evals/integration/test_record_check.sh +378 -0
  150. package/evals/integration/test_routing_efficiency.sh +71 -0
  151. package/evals/integration/test_runtime_adapter_activation.sh +28 -0
  152. package/evals/integration/test_session_resume_roundtrip.sh +16 -19
  153. package/evals/integration/test_skill_drift_check.sh +870 -0
  154. package/evals/integration/test_telemetry.sh +445 -0
  155. package/evals/integration/test_telemetry_doctor.sh +66 -0
  156. package/evals/integration/test_telemetry_usage_pipeline.sh +228 -0
  157. package/evals/integration/test_trust_reconcile_negatives.sh +30 -13
  158. package/evals/integration/test_trust_reconcile_trailer_diagnostic.sh +247 -0
  159. package/evals/integration/test_usage_cost.sh +61 -0
  160. package/evals/integration/test_workflow_sidecar_writer.sh +1395 -0
  161. package/evals/integration/test_workflow_steering_hook.sh +157 -16
  162. package/evals/integration/test_workspace_settings.sh +176 -0
  163. package/evals/lib/env.sh +26 -0
  164. package/evals/lib/node.sh +8 -0
  165. package/evals/run.sh +29 -0
  166. package/evals/static/test_ci_integration_coverage.sh +115 -0
  167. package/evals/static/test_declared_scope_forms_documented.sh +114 -0
  168. package/evals/static/test_universal_bundles.sh +34 -0
  169. package/evals/static/test_validate_source_kit_asset_scope.sh +259 -0
  170. package/evals/static/test_workflow_skills.sh +1 -1
  171. package/kits/builder/flows/build.flow.json +9 -18
  172. package/kits/builder/flows/publish-learn.flow.json +5 -1
  173. package/kits/builder/kit.json +120 -0
  174. package/kits/builder/skills/deliver/SKILL.md +42 -0
  175. package/kits/builder/skills/evidence-gate/SKILL.md +12 -0
  176. package/kits/builder/skills/execute-plan/SKILL.md +9 -0
  177. package/kits/builder/skills/learning-review/SKILL.md +51 -0
  178. package/kits/builder/skills/plan-work/SKILL.md +17 -20
  179. package/kits/builder/skills/pull-work/SKILL.md +21 -0
  180. package/kits/builder/skills/release-readiness/SKILL.md +12 -0
  181. package/kits/knowledge/kit.json +9 -0
  182. package/kits/veritas-governance/docs/README.md +35 -7
  183. package/kits/veritas-governance/fixtures/exemption-review/mixed-fresh-stale.DECLARED.json +14 -0
  184. package/kits/veritas-governance/kit.json +14 -0
  185. package/kits/veritas-governance/skills/exemption-usage-review/SKILL.md +128 -0
  186. package/kits/veritas-governance/skills/exemption-usage-review/review-exemptions.mjs +231 -0
  187. package/package.json +2 -2
  188. package/packaging/manifest.json +29 -0
  189. package/schemas/backlog-provider-settings.schema.json +13 -0
  190. package/schemas/workflow-state.schema.json +44 -0
  191. package/scripts/README.md +4 -0
  192. package/scripts/check-content-boundary.cjs +8 -1
  193. package/scripts/ci/trust-reconcile.js +136 -0
  194. package/scripts/hooks/codex-hook-adapter.js +77 -2
  195. package/scripts/hooks/evidence-capture.js +38 -5
  196. package/scripts/hooks/lib/codex-exit-code.js +316 -0
  197. package/scripts/hooks/lib/kit-catalog.js +235 -0
  198. package/scripts/hooks/lib/liveness-write.js +28 -1
  199. package/scripts/hooks/lib/local-artifact-paths.js +97 -1
  200. package/scripts/hooks/lib/runnable-command.js +177 -0
  201. package/scripts/hooks/lib/skill-drift.js +350 -0
  202. package/scripts/hooks/stop-goal-fit.js +278 -48
  203. package/scripts/hooks/workflow-steering.js +121 -21
  204. package/scripts/install-codex-home.sh +97 -47
  205. package/scripts/install-merge.js +72 -14
  206. package/scripts/install-owned-files.js +178 -0
  207. package/scripts/liveness/relay.sh +84 -0
  208. package/scripts/telemetry/economics-record.schema.json +145 -0
  209. package/scripts/telemetry/economics-record.sh +331 -0
  210. package/scripts/telemetry/install-console-config.sh +25 -4
  211. package/scripts/telemetry/learning-review-decide.sh +124 -0
  212. package/scripts/telemetry/learning-review-proposals.schema.json +161 -0
  213. package/scripts/telemetry/learning-review-proposals.sh +484 -0
  214. package/scripts/telemetry/lib/config.sh +102 -12
  215. package/scripts/telemetry/lib/pricing.sh +14 -6
  216. package/scripts/telemetry/lib/session.sh +3 -0
  217. package/scripts/telemetry/lib/transport.sh +133 -15
  218. package/scripts/telemetry/lib/usage.sh +121 -28
  219. package/scripts/telemetry/routing-efficiency.sh +0 -0
  220. package/scripts/telemetry/telemetry.conf +6 -0
  221. package/scripts/telemetry/telemetry.sh +48 -0
  222. package/src/builder-flow-run-adapter.ts +357 -0
  223. package/src/builder-flow-runtime.ts +348 -0
  224. package/src/cli/builder-flow-run-adapter.test.mjs +495 -0
  225. package/src/cli/builder-flow-runtime.test.mjs +213 -0
  226. package/src/cli/builder-run.ts +28 -0
  227. package/src/cli/codex-agent-routing.test.mjs +44 -0
  228. package/src/cli/codex-exit-code.test.mjs +207 -0
  229. package/src/cli/console-connect-options.test.mjs +329 -0
  230. package/src/cli/console-telemetry-validate.test.mjs +157 -0
  231. package/src/cli/effective-backlog-settings.ts +68 -2
  232. package/src/cli/flow-resolver-composition.test.mjs +101 -0
  233. package/src/cli/init.test.mjs +161 -0
  234. package/src/cli/init.ts +407 -62
  235. package/src/cli/kit-metadata-security.test.mjs +443 -0
  236. package/src/cli/kit.ts +50 -12
  237. package/src/cli/pull-work-provider.ts +377 -3
  238. package/src/cli/sidecar-pure-helpers.test.mjs +64 -0
  239. package/src/cli/skill-drift-check.ts +196 -0
  240. package/src/cli/telemetry-doctor.test.mjs +53 -0
  241. package/src/cli/telemetry-doctor.ts +50 -7
  242. package/src/cli/validate-hook-influence.ts +37 -6
  243. package/src/cli/workflow-sidecar.ts +1150 -151
  244. package/src/cli.ts +5 -0
  245. package/src/flow-kit/validate.ts +277 -38
  246. package/src/index.ts +19 -0
  247. package/src/lib/console-connect-options.ts +261 -0
  248. package/src/lib/console-telemetry-validate.ts +88 -0
  249. package/src/lib/flow-resolver.ts +153 -10
  250. package/src/lib/fs.ts +160 -0
  251. package/src/lib/local-artifact-root.ts +129 -3
  252. package/src/runtime-adapters.ts +113 -33
  253. package/src/tools/build-universal-bundles.ts +36 -2
  254. package/src/tools/codex-agent-routing.ts +48 -0
  255. package/src/tools/generate-context-map.ts +1 -0
  256. package/src/tools/validate-source-tree.ts +26 -1
@@ -17,9 +17,8 @@
17
17
  *
18
18
  * Scope: the gate evaluates the session's current task (.kontourai/flow-agents/current.json)
19
19
  * when set, so an unrelated active workflow elsewhere in the repo does not gate
20
- * this stop. It also never hard-blocks a pre-execution (not-yet-started) task on
21
- * mere incompleteness only genuine false-completion signals (a claimed pass the
22
- * capture log or evidence.json contradicts) block before execution begins.
20
+ * this stop. A pre-execution sidecar remains warning-only unless it has an active
21
+ * canonical Flow run; once Flow is active, its unfinished gate remains blocking.
23
22
  */
24
23
 
25
24
  'use strict';
@@ -42,9 +41,12 @@ const {
42
41
  const {
43
42
  flowAgentsArtifactRoot,
44
43
  flowAgentsArtifactRootsForRead,
44
+ resolveSharedRepoRoot,
45
+ warnIfFailingOpenInsideGitTree,
45
46
  } = require('./lib/local-artifact-paths');
46
47
  const { resolveActor, isUnresolvedActor, detectRuntime } = require('./lib/actor-identity.js');
47
48
  const { readCurrentPointer } = require('./lib/current-pointer.js');
49
+ const { isRunnableCommandText, isAmbiguousAbsenceCommand } = require('./lib/runnable-command.js');
48
50
 
49
51
  const MAX_STDIN = 1024 * 1024;
50
52
  const ACTIVE_STATUSES = new Set([
@@ -66,10 +68,9 @@ const WORKFLOW_SESSION_TYPES = new Set(['deliver', 'delivery', 'fix-bug', 'execu
66
68
  const SIDECAR_NAMES = new Set(['state.json', 'handoff.json', 'trust.bundle']);
67
69
  const OPTIONAL_SIDECAR_NAMES = new Set();
68
70
 
69
- // A workflow that has not started execution is EXPECTED to be incomplete, so the
70
- // Stop gate must not hard-block on its missing DOD / Goal Fit / not-done state.
71
- // Only genuine false-completion signals block a pre-execution task; execution
72
- // onward gates fully.
71
+ // A sidecar that has not started execution is normally expected to be incomplete.
72
+ // An active canonical Flow run is the exception: its current gate remains live and
73
+ // blocks Stop even while the projected sidecar is in pickup or planning.
73
74
  const PRE_EXECUTION_STATUSES = new Set(['new', 'planning', 'planned', 'backlog']);
74
75
  const PRE_EXECUTION_PHASES = new Set(['idea', 'backlog', 'pickup', 'planning']);
75
76
 
@@ -78,19 +79,48 @@ const PRE_EXECUTION_PHASES = new Set(['idea', 'backlog', 'pickup', 'planning']);
78
79
  // not block an unrelated session.
79
80
  const TERMINAL_STATUSES = new Set(['done', 'delivered', 'accepted', 'archived', 'complete', 'completed']);
80
81
 
82
+ function isTerminalDeliveredState(state) {
83
+ if (!state || typeof state !== 'object') return false;
84
+ const status = normalizedStatus(state.status || '');
85
+ const phase = normalizedStatus(state.phase || '');
86
+ return TERMINAL_STATUSES.has(status) || phase === 'done';
87
+ }
88
+
81
89
  function parseJson(raw) {
82
90
  try { return JSON.parse(raw || '{}'); } catch { return {}; }
83
91
  }
84
92
 
85
93
  function findRepoRoot(startDir) {
86
- let dir = path.resolve(startDir || process.cwd());
94
+ const resolvedStartDir = path.resolve(startDir || process.cwd());
95
+ // #357: prefer git's SHARED common-dir root (the primary checkout's repo root, resolved
96
+ // identically from any linked worktree) before falling back to the plain `.git`/AGENTS.md
97
+ // ancestor walk below. Without this, a worktree's OWN `.git` FILE (not the shared `.git`
98
+ // DIRECTORY) satisfies `fs.existsSync(path.join(dir, '.git'))` at the very first directory
99
+ // checked, so the walk always stopped at the worktree root instead of resolving to the
100
+ // shared primary checkout — see src/lib/local-artifact-root.ts's resolveSharedRepoRoot doc
101
+ // comment for the full rationale. Fails open to the walk below (unchanged) when git is
102
+ // unavailable, `resolvedStartDir` is not inside a git working tree, or the command fails —
103
+ // this is also exactly today's behavior in the single-checkout case, where
104
+ // `--git-common-dir` resolves to `.git` under `resolvedStartDir` itself.
105
+ const sharedRoot = resolveSharedRepoRoot(resolvedStartDir);
106
+ if (sharedRoot) return sharedRoot;
107
+ let dir = resolvedStartDir;
87
108
  const root = path.parse(dir).root;
88
109
  for (let depth = 0; dir && depth < 40; depth++) {
89
- if (fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, 'AGENTS.md'))) return dir;
110
+ if (fs.existsSync(path.join(dir, '.git'))) {
111
+ // #413 iteration-2 Fix 1: resolveSharedRepoRoot failed above even though a `.git` entry
112
+ // exists right here — git resolution was ATTEMPTED and FAILED (corrupted gitlink, bad
113
+ // GIT_DIR, git unavailable, etc.), not merely "no git repo at all". Loud, not silent —
114
+ // see warnIfFailingOpenInsideGitTree's own doc comment for the full rationale. This walk
115
+ // still returns `dir` (the ancestor-walk fallback is preserved unchanged) after warning.
116
+ warnIfFailingOpenInsideGitTree(resolvedStartDir, dir);
117
+ return dir;
118
+ }
119
+ if (fs.existsSync(path.join(dir, 'AGENTS.md'))) return dir;
90
120
  if (dir === root) break;
91
121
  dir = path.dirname(dir);
92
122
  }
93
- return path.resolve(startDir || process.cwd());
123
+ return resolvedStartDir;
94
124
  }
95
125
 
96
126
  function walkMarkdown(dir, out = []) {
@@ -577,6 +607,9 @@ function sidecarGuidance(root, artifactDir, activeFlowStep) {
577
607
  if (state && state.next_action && normalizedStatus(state.next_action.status) !== 'done') {
578
608
  const next = state.next_action;
579
609
  warnings.push(`${base} next action: ${safeOneLine(next.summary)}${next.target_phase ? ` (target phase: ${safeOneLine(next.target_phase, 80)})` : ''}`);
610
+ if (Array.isArray(next.skills) && next.skills.length) warnings.push(`${base} required skills: ${next.skills.map(skill => safeOneLine(skill, 80)).join(' -> ')}`);
611
+ if (Array.isArray(next.operations) && next.operations.length) warnings.push(`${base} required operations: ${next.operations.map(operation => safeOneLine(operation, 80)).join(' -> ')}`);
612
+ if (next.command) warnings.push(`${base} next command: ${safeOneLine(next.command, 240)}`);
580
613
  }
581
614
 
582
615
  // ── Evidence verdict + checks: bundle-first, fallback to evidence.json ────
@@ -686,10 +719,58 @@ function normalizeCommand(value) {
686
719
  return String(value || '').replace(/\s+/g, ' ').trim();
687
720
  }
688
721
 
722
+ /**
723
+ * #362 (iteration-2 fix item 4/LOW): single-sourced human-facing remediation phrase for an
724
+ * ambiguous bare grep/diff exit-1 command. Every emission site below (captureCrossReference's
725
+ * capture-log cross-reference branch, its live-backstop-re-run branch, and
726
+ * capturedFailReconciliation's ambiguous bucket) interpolates this constant rather than
727
+ * re-duplicating the literal string — mirrors how `isAmbiguousAbsenceCommand` itself is
728
+ * single-sourced (required once from ./lib/runnable-command.js, never reimplemented per call
729
+ * site). Cross-ref: src/cli/workflow-sidecar.ts keeps its OWN copy of the shared fragment
730
+ * (`AMBIGUOUS_REMEDIATION_ADVICE`) for its two record-time emission sites — the two files do not
731
+ * share a module for this string, so each file single-sources independently.
732
+ */
733
+ const AMBIGUOUS_REMEDIATION = "re-record this check as a self-asserting command ('! grep ...' or 'grep -c ... | grep -qx 0')";
734
+
735
+ /**
736
+ * #362: a bare (non-self-asserting) grep/diff exit 1 is exit-code-AMBIGUOUS (zero
737
+ * matches/no diff — could be the author's intended PASS for an absence check, or an
738
+ * unintended miss for a presence check), not a genuine FAIL. This is the SAME narrow,
739
+ * two-binary-only carve-out `isAmbiguousAbsenceCommand` already applies at capture time
740
+ * (`evidence-capture.js`'s `observeResult`) — reused here (not reimplemented) so a log
741
+ * entry's `observedResult` and this re-derivation from raw exitCode can never disagree.
742
+ * Exit codes >=2 for grep/diff remain hard FAILs (real tool errors) — this carve-out is
743
+ * exactly exitCode === 1, nothing wider.
744
+ *
745
+ * Iteration-2 (re-plan) finding #3 (HIGH): `ambiguous` now has TWO distinct origins, threaded
746
+ * via the additive `absenceAmbiguous` discriminator so existing #362 callers are unaffected:
747
+ * - absence-ambiguous (#362 carve-out): `exitCode===1` on a bare grep/diff — the command
748
+ * genuinely RAN and produced exit 1. `absenceAmbiguous:true`.
749
+ * - no-signal-ambiguous (rule-3 default): `observedResult==='ambiguous'` with no exit code
750
+ * recoverable (e.g. an unreadable codex host banner) — capture saw the command run but got
751
+ * NO usable pass/fail signal. `absenceAmbiguous:false`.
752
+ * Callers that need the #362-specific grep/diff wording/HARD_BLOCK branch on `absenceAmbiguous`;
753
+ * callers that just need "was this ambiguous at all" keep using `ambiguous`.
754
+ *
755
+ * @param {{command?: string, observedResult?: string, exitCode?: number}} entry
756
+ * @returns {{failed: boolean, ambiguous: boolean, absenceAmbiguous: boolean}}
757
+ */
758
+ function classifyLogEntry(entry) {
759
+ const exitCode = Number.isInteger(entry.exitCode) ? entry.exitCode : null;
760
+ const absenceAmbiguous = exitCode === 1 && isAmbiguousAbsenceCommand(entry.command);
761
+ const ambiguous = entry.observedResult === 'ambiguous' || absenceAmbiguous;
762
+ if (ambiguous) return { failed: false, ambiguous: true, absenceAmbiguous };
763
+ const failed = entry.observedResult === 'fail' || (exitCode !== null && exitCode !== 0);
764
+ return { failed, ambiguous: false, absenceAmbiguous: false };
765
+ }
766
+
689
767
  /**
690
768
  * Read command-log.jsonl into a map of normalized-command -> aggregate outcome.
691
769
  * If the same command was run more than once, a single FAIL makes the aggregate
692
- * a fail (a caught false-completion must not be masked by a later pass-claim).
770
+ * a fail (a caught false-completion must not be masked by a later pass-claim). A
771
+ * bare grep/diff exit-1 (#362) is carved out as `ambiguous` rather than `failed` — it
772
+ * is surfaced as a distinct warning class by callers, never silently dropped, and
773
+ * never treated as a hard block.
693
774
  */
694
775
  function readCommandLog(artifactDir) {
695
776
  const file = path.join(artifactDir, 'command-log.jsonl');
@@ -704,11 +785,13 @@ function readCommandLog(artifactDir) {
704
785
  if (!entry || typeof entry.command !== 'string') continue;
705
786
  const key = normalizeCommand(entry.command);
706
787
  if (!key) continue;
707
- const failed = entry.observedResult === 'fail' || (Number.isInteger(entry.exitCode) && entry.exitCode !== 0);
788
+ const { failed, ambiguous, absenceAmbiguous } = classifyLogEntry(entry);
708
789
  const prev = byCommand.get(key);
709
790
  byCommand.set(key, {
710
791
  ran: true,
711
792
  failed: failed || (prev ? prev.failed : false),
793
+ ambiguous: !failed && (ambiguous || (prev ? prev.ambiguous : false)),
794
+ absenceAmbiguous: !failed && (absenceAmbiguous || (prev ? prev.absenceAmbiguous : false)),
712
795
  exitCode: Number.isInteger(entry.exitCode) ? entry.exitCode : (prev ? prev.exitCode : null),
713
796
  });
714
797
  }
@@ -720,7 +803,9 @@ function readCommandLog(artifactDir) {
720
803
  * The LAST entry for each command wins (unlike readCommandLog which makes FAIL sticky).
721
804
  * Used for both capturedFailReconciliation and captureCrossReference (Fix C): we want to
722
805
  * know the LAST result, so a genuine re-run-to-pass clears the earlier FAIL. Only an actual
723
- * re-run (new PASS entry in the log) clears it — a new claim cannot change the log.
806
+ * re-run (new PASS entry in the log) clears it — a new claim cannot change the log. #362: a
807
+ * bare grep/diff exit-1 is carved out as `ambiguous` (via the shared `classifyLogEntry`), not
808
+ * `failed` — same rationale as `readCommandLog` above.
724
809
  */
725
810
  function readLatestCommandLog(artifactDir) {
726
811
  const file = path.join(artifactDir, 'command-log.jsonl');
@@ -735,11 +820,13 @@ function readLatestCommandLog(artifactDir) {
735
820
  if (!entry || typeof entry.command !== 'string') continue;
736
821
  const key = normalizeCommand(entry.command);
737
822
  if (!key) continue;
738
- const failed = entry.observedResult === 'fail' || (Number.isInteger(entry.exitCode) && entry.exitCode !== 0);
739
- // LAST entry wins — genuine re-run-to-pass overwrites the earlier FAIL.
823
+ const { failed, ambiguous, absenceAmbiguous } = classifyLogEntry(entry);
824
+ // LAST entry wins — genuine re-run-to-pass overwrites the earlier FAIL/ambiguous.
740
825
  byCommand.set(key, {
741
826
  ran: true,
742
827
  failed,
828
+ ambiguous,
829
+ absenceAmbiguous,
743
830
  exitCode: Number.isInteger(entry.exitCode) ? entry.exitCode : null,
744
831
  });
745
832
  }
@@ -915,28 +1002,8 @@ function verifyCommandLogChain(artifactDir) {
915
1002
  * FLOW_AGENTS_GOAL_FIT_RECHECK=true (the RCE-risky opt-in path).
916
1003
  * Returns { argv, cwd, source } or null when nothing trusted resolves.
917
1004
  */
918
- // WS8 (AC10b): a kind:"command" evidence ref's excerpt/command must be a literally runnable
919
- // shell command, not a prose description of a manual verification step. This heuristic
920
- // rejects prose so the goal-fit backstop never spawns `bash -lc "<a sentence>"` and
921
- // misreports the resulting shell error as a caught false-completion. Pairs with the rule
922
- // stated in context/contracts/planning-contract.md (AC11).
923
- function isRunnableCommandText(text) {
924
- const s = typeof text === 'string' ? text.trim() : '';
925
- if (!s) return false;
926
- // Sentence-like prose (a . ! or ? followed by more words) is not a single command.
927
- if (/[.!?]\s+\S/.test(s)) return false;
928
- const first = s.split(/\s+/)[0] || '';
929
- // WS8 (AC10b, iteration 2): the first-token allowlist is broadened to the common
930
- // real-world verify binaries a project's runnable evidence names (git, tsc, eslint,
931
- // python/python3, docker, curl, jq, diff, grep, test) so honest command evidence is not
932
- // misclassified as prose. Still fail-closed for genuinely non-command prose: a bare
933
- // sentence whose first token is none of these (and carries no path prefix or shell
934
- // metacharacter) is rejected rather than executed.
935
- const knownBinary = /^(npm|npx|pnpm|yarn|node|bash|sh|zsh|make|just|task|cargo|go|pytest|tox|deno|bun|ruby|rake|mvn|gradle|dotnet|swift|flutter|dart|git|tsc|eslint|python|python3|docker|curl|jq|diff|grep|test)$/.test(first)
936
- || first.startsWith('./') || first.startsWith('/') || first.includes('/');
937
- const hasShellMeta = /[|&;<>()$`]/.test(s);
938
- return knownBinary || hasShellMeta;
939
- }
1005
+ // WS8 (AC10b): isRunnableCommandText now lives in ./lib/runnable-command.js (single
1006
+ // source of truth shared with workflow-sidecar.ts's record-time validation, #412).
940
1007
 
941
1008
  function resolveTrustedCommand(root, artifactDir, check, acceptance) {
942
1009
  // (a) acceptance criterion command for the matching criterion.
@@ -946,6 +1013,32 @@ function resolveTrustedCommand(root, artifactDir, check, acceptance) {
946
1013
  // is not a runnable shell command is malformed-evidence — reported distinctly, not
947
1014
  // executed, and not conflated with a caught false-completion.
948
1015
  if (!isRunnableCommandText(fromAcceptance)) return { malformed: fromAcceptance };
1016
+ // DIALECT-PRESERVATION INVARIANT (#362): from THIS POINT ONWARD, `fromAcceptance` MUST be
1017
+ // passed through to argv[2] unmodified from `acceptanceCommandFor`'s return value — never
1018
+ // re-trimmed, re-escaped, re-quoted, or reconstructed from a parsed/tokenized form. The
1019
+ // recorded command's regex dialect and flags (e.g. `grep -E '(foo|bar)'`'s ERE alternation/
1020
+ // grouping, or any other -E/-P/-G flag combination) are part of the evidence-ref text itself;
1021
+ // re-deriving the command string from anything other than this exact `fromAcceptance` value
1022
+ // would silently reinterpret an ERE-recorded pattern as BRE (or vice versa) on re-run,
1023
+ // corrupting the very check this backstop exists to confirm.
1024
+ //
1025
+ // KNOWN ACCEPTED UPSTREAM BOUNDARY: this guarantee begins at `acceptanceCommandFor`'s return
1026
+ // value, NOT at the raw evidence-ref text — it is NOT "byte-for-byte, exactly as recorded in
1027
+ // the evidence ref" (a prior version of this comment overclaimed that). `acceptanceCommandFor`
1028
+ // derives `fromAcceptance` via `normalizeCommand` (~line 686: `.replace(/\s+/g, ' ').trim()`),
1029
+ // which collapses whitespace runs and trims BEFORE this invariant's scope begins. Regex
1030
+ // dialect metacharacters (-E, |, +, (), etc.) are single characters/short tokens, not
1031
+ // whitespace, so they are untouched by this collapse and survive intact — but a literal
1032
+ // multi-space or tab run *inside* a recorded pattern (e.g. two spaces in `grep -E 'foo bar'`)
1033
+ // does NOT survive verbatim; it is already collapsed to one space by the time it reaches this
1034
+ // function. That is a pre-existing, accepted normalization boundary (not introduced by this
1035
+ // invariant, and not something this invariant claims to cover) — not a dialect-preservation
1036
+ // gap.
1037
+ // `runBackstop` (below) preserves this invariant on its side by reading `trusted.argv[2]`
1038
+ // verbatim rather than re-joining `argv` with spaces — see runBackstop's own
1039
+ // DIALECT-PRESERVATION INVARIANT comment for that half of the contract.
1040
+ // A regression eval (evals/integration/test_goal_fit_hook.sh, "#362 dialect preservation")
1041
+ // pins this end-to-end with a `grep -E` ERE-only alternation construct.
949
1042
  return { argv: ['bash', '-lc', fromAcceptance], cwd: root, source: 'acceptance' };
950
1043
  }
951
1044
 
@@ -1045,6 +1138,33 @@ function resolveBackstopMode() {
1045
1138
  return 'block';
1046
1139
  }
1047
1140
 
1141
+ /**
1142
+ * #362: `classification` is a three-state result (`'pass'`/`'fail'`/`'ambiguous'`) rather than
1143
+ * the old boolean `passed` — `'ambiguous'` iff the re-run command text is a bare (non-negated,
1144
+ * non-count-asserted, non-chained) `grep`/`diff` invocation (`isAmbiguousAbsenceCommand`) AND
1145
+ * `exitCode === 1` (zero matches/no diff — could be the author's intended PASS for an absence
1146
+ * check, never silently coerced to pass OR fail). `'fail'` iff `exitCode !== 0` and not the
1147
+ * ambiguous case (this still includes exitCode >= 2 for grep/diff — a real tool error, e.g. a
1148
+ * missing file — which stays a hard fail, proving the carve-out is narrowly scoped). `'pass'`
1149
+ * iff `exitCode === 0`. `trusted.argv.join(' ')` is passed to `isAmbiguousAbsenceCommand` (it
1150
+ * already unwraps a `bash -lc '<command>'` wrapper on its own, so the `['bash', '-lc', cmd]`
1151
+ * shape `resolveTrustedCommand`'s acceptance/model-command sources produce is inspected
1152
+ * correctly; a `declared`-manifest-target argv like `['npm', 'test']` simply never matches
1153
+ * `grep`/`diff` as its first token, so it is never misclassified ambiguous).
1154
+ *
1155
+ * DIALECT-PRESERVATION INVARIANT (#362): `commandText` below MUST be `trusted.argv[2]`'s value,
1156
+ * verbatim — never re-derived, re-joined, or re-quoted from `trusted.argv`. The recorded
1157
+ * command's exact dialect/flags (e.g. `grep -E '(foo|bar)'`'s ERE alternation) are part of the
1158
+ * evidence ref text; re-joining `argv` with spaces (or any other reconstruction) would silently
1159
+ * reintroduce a BRE/ERE dialect mismatch — the exact bug this invariant exists to prevent. This
1160
+ * mirrors the equivalent invariant on `resolveTrustedCommand`'s `acceptance` branch below (search
1161
+ * DIALECT-PRESERVATION INVARIANT there, including its accepted whitespace-collapse boundary
1162
+ * note) — the same recorded string must survive unmodified from `acceptanceCommandFor`'s return
1163
+ * value through to this spawnSync call.
1164
+ *
1165
+ * @param {{argv: string[], cwd: string}} trusted
1166
+ * @returns {{ran: boolean, exitCode?: number, classification?: 'pass'|'fail'|'ambiguous', error?: string, timedOut?: boolean}}
1167
+ */
1048
1168
  function runBackstop(trusted) {
1049
1169
  const result = spawnSync(trusted.argv[0], trusted.argv.slice(1), {
1050
1170
  cwd: trusted.cwd,
@@ -1055,7 +1175,19 @@ function runBackstop(trusted) {
1055
1175
  });
1056
1176
  if (result.error) return { ran: false, error: result.error.code || result.error.message };
1057
1177
  if (result.signal) return { ran: false, error: `killed (${result.signal})`, timedOut: result.signal === 'SIGKILL' || result.signal === 'SIGTERM' };
1058
- return { ran: true, passed: result.status === 0, exitCode: result.status };
1178
+ const exitCode = result.status;
1179
+ // The `['bash', '-lc', <command-text>]` shape resolveTrustedCommand's acceptance/
1180
+ // model-command sources produce carries the ORIGINAL recorded command text verbatim in
1181
+ // argv[2] — pass THAT (not a re-joined/re-quoted `argv.join(' ')`, which would lose the
1182
+ // shell-quoting isAmbiguousAbsenceCommand's `bash -lc '...'` unwrap regex requires) to the
1183
+ // heuristic. A `declared`-manifest-target argv (e.g. `['npm', 'test']`) has no such wrapper;
1184
+ // joining it is fine since its first token is never `grep`/`diff` anyway.
1185
+ const commandText = (trusted.argv.length === 3 && trusted.argv[0] === 'bash' && trusted.argv[1] === '-lc')
1186
+ ? trusted.argv[2]
1187
+ : trusted.argv.join(' ');
1188
+ const ambiguous = exitCode === 1 && isAmbiguousAbsenceCommand(commandText);
1189
+ const classification = ambiguous ? 'ambiguous' : (exitCode === 0 ? 'pass' : 'fail');
1190
+ return { ran: true, exitCode, classification };
1059
1191
  }
1060
1192
 
1061
1193
  /**
@@ -1081,6 +1213,8 @@ function captureCrossReference(root, artifactDir, activeFlowStep) {
1081
1213
  const base = relative(root, artifactDir);
1082
1214
  const backstopMode = resolveBackstopMode();
1083
1215
  const warnings = [];
1216
+ const captureState = readJsonFile(path.join(artifactDir, 'state.json'));
1217
+ const terminalDelivered = isTerminalDeliveredState(captureState);
1084
1218
 
1085
1219
  // AC3 fail-closed: detect a missing command log in a post-execution session.
1086
1220
  // When state.json confirms the session is past the planning phase (commands should
@@ -1094,7 +1228,6 @@ function captureCrossReference(root, artifactDir, activeFlowStep) {
1094
1228
  {
1095
1229
  const logFileMissing = !fs.existsSync(path.join(artifactDir, 'command-log.jsonl'));
1096
1230
  if (logFileMissing) {
1097
- const captureState = readJsonFile(path.join(artifactDir, 'state.json'));
1098
1231
  if (captureState) {
1099
1232
  const capturePhase = normalizedStatus(captureState.phase || '');
1100
1233
  const captureStatus = normalizedStatus(captureState.status || '');
@@ -1194,6 +1327,23 @@ function captureCrossReference(root, artifactDir, activeFlowStep) {
1194
1327
  if (logged.failed) {
1195
1328
  const exit = Number.isInteger(logged.exitCode) ? ` (exitCode:${logged.exitCode})` : '';
1196
1329
  warnings.push(`${base} evidence check ${id}: capture log CONTRADICTS claimed pass — command "${safeOneLine(cmd, 120)}" was recorded as FAIL${exit}. This is a caught false-completion.`);
1330
+ } else if (logged.ambiguous && logged.absenceAmbiguous) {
1331
+ // #362: a bare grep/diff logged exit 1 — ambiguous (zero matches/no diff), not a
1332
+ // caught false-completion. Distinct warning class, never silently dropped, never a
1333
+ // hard block. UNCHANGED (byte-identical) — iteration-2 (re-plan) finding #3 Decision A
1334
+ // keeps this absence-ambiguous branch verbatim; only the sibling no-signal branch below
1335
+ // is new.
1336
+ const exit = Number.isInteger(logged.exitCode) ? ` (exitCode:${logged.exitCode})` : '';
1337
+ warnings.push(`${base} evidence check ${id}: capture log shows command "${safeOneLine(cmd, 120)}" exited 1${exit} — for bare grep/diff this may mean zero matches/no differences (PASS for an absence check) or an unintended miss (FAIL for a presence check); NOT_VERIFIED (ambiguous): ${AMBIGUOUS_REMEDIATION} to remove the ambiguity.`);
1338
+ } else if (logged.ambiguous) {
1339
+ // Iteration-2 (re-plan) finding #3 (HIGH), Decision A: generic no-signal-ambiguous — the
1340
+ // rule-3 default (`observedResult:'ambiguous'`, no exit code recoverable, e.g. an
1341
+ // unreadable/missing codex host banner). This is NOT the #362 grep/diff carve-out (no
1342
+ // `absenceAmbiguous`), so the message is grep/diff-FREE and accurate for any command.
1343
+ // Deliberately does NOT contain `NOT_VERIFIED (ambiguous)`, `caught false-completion`, or
1344
+ // `status:` so it MISSES HARD_BLOCK (warn-only on a terminal/pre-execution stop) while
1345
+ // `evidence check` / `NOT_VERIFIED —` still match FULL_BLOCK (blocks a non-terminal stop).
1346
+ warnings.push(`${base} evidence check ${id}: claimed pass but NOT_VERIFIED — command "${safeOneLine(cmd, 120)}" was captured with no deterministic pass/fail signal (no exit code observed on this host); a pass requires positive evidence. Re-run it to produce a definitive exit code, or record it as not_verified/accepted_gap.`);
1197
1347
  } else if (hasLaunderingOperator(cmd)) {
1198
1348
  // Fix D: exit-code laundering. The captured exit-0 is not trustworthy — the command
1199
1349
  // baked in '|| true' / '|| :' / '; true' / '; exit 0' / '| true' to mask the real result.
@@ -1219,17 +1369,30 @@ function captureCrossReference(root, artifactDir, activeFlowStep) {
1219
1369
  warnings.push(`${base} evidence check ${id}: claimed pass but NOT_VERIFIED — command "${safeOneLine(cmd, 120)}" was never captured and no trusted command (acceptance criterion / declared manifest target) resolves to re-run it. Set FLOW_AGENTS_GOAL_FIT_RECHECK=true to opt into re-running the model's free-form command.`);
1220
1370
  continue;
1221
1371
  }
1372
+ if (terminalDelivered && trusted.source === 'model-command (FLOW_AGENTS_GOAL_FIT_RECHECK)') {
1373
+ // Terminal delivered/done sessions have finalized evidence. Do not turn
1374
+ // model-asserted command text into a new RECHECK false-completion. Captured
1375
+ // execution evidence and CI/L2 checks remain the anchors for terminal tasks.
1376
+ warnings.push(`${base} evidence check ${id}: malformed-evidence — terminal delivered/done session recorded model-command text asserted by FLOW_AGENTS_GOAL_FIT_RECHECK ("${safeOneLine(cmd, 120)}"); it was NOT re-run on a terminal session. Captured-execution evidence and CI/L2 checks remain the anchors.`);
1377
+ continue;
1378
+ }
1222
1379
  const outcome = runBackstop(trusted);
1223
1380
  if (!outcome.ran) {
1224
1381
  warnings.push(`${base} evidence check ${id}: claimed pass but NOT_VERIFIED — trusted backstop (${trusted.source}) could not run (${safeOneLine(outcome.error, 80)}).`);
1225
1382
  continue;
1226
1383
  }
1227
- if (!outcome.passed) {
1384
+ if (outcome.classification === 'ambiguous') {
1385
+ // #362: bare grep/diff backstop re-run exited 1 — ambiguous, not a caught
1386
+ // false-completion. Never silently PASS (a presence check recorded without
1387
+ // negation could genuinely be a miss) and never silently dropped.
1388
+ const note = `${base} evidence check ${id}: trusted backstop (${trusted.source}) re-run of "${trusted.argv.join(' ')}" exited 1 — for grep/diff this may mean zero matches/no differences (PASS for an absence check) or an unintended miss (FAIL for a presence check); NOT_VERIFIED (ambiguous): ${AMBIGUOUS_REMEDIATION} to remove the ambiguity.`;
1389
+ warnings.push(note);
1390
+ } else if (outcome.classification === 'fail') {
1228
1391
  const note = `${base} evidence check ${id}: trusted backstop (${trusted.source}) re-run of "${trusted.argv.join(' ')}" FAILED with exit ${outcome.exitCode}, contradicting the claimed pass. This is a caught false-completion.`;
1229
1392
  if (backstopMode === 'off') warnings.push(`${note} [backstop in warn mode — not blocking]`);
1230
1393
  else warnings.push(note);
1231
1394
  }
1232
- // backstop passed → claim deterministically confirmed by re-run, no warning.
1395
+ // backstop classification 'pass' → claim deterministically confirmed by re-run, no warning.
1233
1396
  }
1234
1397
 
1235
1398
  return warnings;
@@ -1289,7 +1452,18 @@ function capturedFailReconciliation(root, artifactDir, taskStatus) {
1289
1452
  if (!info.failed && hasLaunderingOperator(cmd)) launderedPass.set(cmd, info);
1290
1453
  }
1291
1454
 
1292
- if (latestFails.size === 0 && launderedPass.size === 0) return []; // Nothing to flag
1455
+ // Iteration-2 fix item 4 (HIGH, ambiguity-must-not-slip-terminal-stops): a THIRD bucket
1456
+ // commands whose LATEST capture is `ambiguous` (a bare, non-self-asserting grep/diff exit 1;
1457
+ // #362) — collected separately from `latestFails`, never merged into it. An ambiguous capture
1458
+ // is NOT a caught false-completion (it may genuinely be the author's intended pass for an
1459
+ // absence check) — it gets its own distinct re-record-self-asserting message below, never the
1460
+ // "caught false-completion" accusation `latestFails`/`launderedPass` use.
1461
+ const latestAmbiguous = new Map(); // cmd -> {failed:false, ambiguous:true, exitCode}
1462
+ for (const [cmd, info] of latestLog) {
1463
+ if (!info.failed && info.ambiguous) latestAmbiguous.set(cmd, info);
1464
+ }
1465
+
1466
+ if (latestFails.size === 0 && launderedPass.size === 0 && latestAmbiguous.size === 0) return []; // Nothing to flag
1293
1467
 
1294
1468
  const base = relative(root, artifactDir);
1295
1469
  const warnings = [];
@@ -1305,8 +1479,8 @@ function capturedFailReconciliation(root, artifactDir, taskStatus) {
1305
1479
  if (c && c.id) claimById.set(c.id, c);
1306
1480
  }
1307
1481
 
1308
- // commandsToCheck: FAIL-latest commands + laundered-pass commands
1309
- const commandsToCheck = new Set([...latestFails.keys(), ...launderedPass.keys()]);
1482
+ // commandsToCheck: FAIL-latest commands + laundered-pass commands + ambiguous-latest commands
1483
+ const commandsToCheck = new Set([...latestFails.keys(), ...launderedPass.keys(), ...latestAmbiguous.keys()]);
1310
1484
 
1311
1485
  // For each relevant command, track what claims say about it.
1312
1486
  // cmdAcc: cmd → {passClaims: [...], ackClaims: []}
@@ -1377,6 +1551,43 @@ function capturedFailReconciliation(root, artifactDir, taskStatus) {
1377
1551
  }
1378
1552
  }
1379
1553
 
1554
+ // Iteration-2 fix item 4 (HIGH): THIRD bucket — commands whose latest capture is `ambiguous`
1555
+ // AND some claim asserts pass for them. This is deliberately gated on `acc.passClaims.length >
1556
+ // 0` (mirroring Case A / Fix D above) — an ambiguous capture with NO pass-claim at all is not
1557
+ // reconciled here (nothing was asserted against it to reconcile), matching the existing
1558
+ // no-over-block guarantee for incidental runs. When a claim DOES assert pass for an ambiguous
1559
+ // command, this is a DISTINCT re-record-self-asserting message — NEVER the "caught
1560
+ // false-completion" accusation Case A/Fix D use, since a bare grep/diff exit 1 may genuinely be
1561
+ // the author's intended pass (zero matches/no differences for an absence check). This bucket
1562
+ // still BLOCKS a terminal stop (via HARD_BLOCK's dedicated ambiguous pattern below) because the
1563
+ // claim is asserting something the capture cannot actually confirm — recording is not the same
1564
+ // as verified completion.
1565
+ for (const [cmd, ambiguousInfo] of latestAmbiguous) {
1566
+ const exit = Number.isInteger(ambiguousInfo.exitCode) ? ambiguousInfo.exitCode : null;
1567
+ const exitStr = exit !== null ? ` (exit ${exit})` : '';
1568
+ const acc = cmdAcc.get(cmd);
1569
+ if (acc && acc.passClaims.length > 0) {
1570
+ const claim = acc.passClaims[0];
1571
+ if (ambiguousInfo.absenceAmbiguous) {
1572
+ // #362 absence-ambiguous — UNCHANGED (byte-identical) message + HARD_BLOCK marker.
1573
+ warnings.push(
1574
+ `${base} captured command '${safeOneLine(cmd, 120)}' last ran AMBIGUOUS${exitStr} ` +
1575
+ `(bare grep/diff exit 1 — zero matches/no differences) and claim ${safeOneLine(claim.subjectId || claim.id, 80)} ` +
1576
+ `(${safeOneLine(claim.claimType, 48)}) asserts pass — NOT_VERIFIED (ambiguous): ${AMBIGUOUS_REMEDIATION} and re-run it, then re-record the claim.`
1577
+ );
1578
+ } else {
1579
+ // Iteration-2 (re-plan) finding #3 Decision A: generic no-signal-ambiguous (no exit code
1580
+ // ever recoverable) — grep/diff-FREE wording, misses HARD_BLOCK, still matches FULL_BLOCK
1581
+ // via `NOT_VERIFIED —` so a non-terminal stop still blocks.
1582
+ warnings.push(
1583
+ `${base} captured command '${safeOneLine(cmd, 120)}' last ran with no deterministic pass/fail signal${exitStr} ` +
1584
+ `(no exit code observed on this host) and claim ${safeOneLine(claim.subjectId || claim.id, 80)} ` +
1585
+ `(${safeOneLine(claim.claimType, 48)}) asserts pass — NOT_VERIFIED — re-run it to produce a definitive exit code, then re-record the claim.`
1586
+ );
1587
+ }
1588
+ }
1589
+ }
1590
+
1380
1591
  return warnings;
1381
1592
  }
1382
1593
 
@@ -1639,16 +1850,30 @@ function missingBundleOrStateSignal(artifactDir, activeFlowStep) {
1639
1850
  //
1640
1851
  // HARD_BLOCK: always blocks, even for pre-execution and terminal tasks.
1641
1852
  // Fires on genuine false-completion signals (a claimed pass the capture log or
1642
- // evidence.json contradicts), integrity failures, and gate misconfiguration.
1853
+ // evidence.json contradicts), integrity failures, gate misconfiguration, and iteration-2 fix
1854
+ // item 4 (HIGH) — the unified ambiguous-with-a-pass-claim signal (`NOT_VERIFIED (ambiguous)`,
1855
+ // built from the shared AMBIGUOUS_REMEDIATION const). ALL THREE emission sites — the capture-log
1856
+ // shortcut in captureCrossReference, the live re-run in runBackstop, and the THIRD bucket in
1857
+ // capturedFailReconciliation — emit this same marker, and it is DELIBERATELY distinct text from
1858
+ // `caught false-completion` (never the false-completion accusation for a bare grep/diff exit 1,
1859
+ // which may genuinely be the author's intended pass). All three DELIBERATELY match HARD_BLOCK,
1860
+ // so an ambiguous claimed-pass BLOCKS a terminal/pre-execution stop in every config
1861
+ // (evidence.json-only, never-captured, and captured+bundle) — a claim asserting pass for a
1862
+ // command whose exit code cannot actually confirm that is not a verified completion either. Do
1863
+ // NOT revert any of the three sites back to plain `NOT_VERIFIED —` text: that would desync them
1864
+ // from HARD_BLOCK and reopen the terminal-stop ambiguous bypass. (The genuinely non-ambiguous
1865
+ // `NOT_VERIFIED —` cases — never-captured, backstop-disabled, no-trust-bundle, etc. — are a
1866
+ // separate, unrelated class: they stay warn-only for a terminal/pre-execution stop and only
1867
+ // block a non-terminal stop via FULL_BLOCK's broader `NOT_VERIFIED —` pattern, unchanged.)
1643
1868
  //
1644
1869
  // FULL_BLOCK: fires for execution-onward tasks (post-planning, non-terminal).
1645
1870
  // Includes all HARD_BLOCK patterns plus completeness/hygiene and not-done state.
1646
1871
  //
1647
1872
  // Both are used in analyze() for blocking decisions AND in run() for the AC2
1648
1873
  // MAX_BLOCKS hard-block guard (preventing auto-release of hard blocks).
1649
- const HARD_BLOCK = /contradicts evidence\.json|caught false-completion|evidence verdict:|evidence check .+ status:|critique status|critique open|required sidecar is missing|command-log integrity check FAILED|gate misconfiguration:|exit-code-laundered/;
1874
+ const HARD_BLOCK = /contradicts evidence\.json|caught false-completion|evidence verdict:|evidence check .+ status:|critique status|critique open|required sidecar is missing|command-log integrity check FAILED|gate misconfiguration:|exit-code-laundered|NOT_VERIFIED \(ambiguous\)/;
1650
1875
  // FULL_BLOCK adds: workflow-state hygiene, surface-unavailable fail-closed, missing log.
1651
- const FULL_BLOCK = /status:|Definition Of Done|Goal Fit|sidecar validation:|contradicts evidence\.json|workflow state|evidence verdict|evidence check|NOT_VERIFIED gap|critique status|critique open|next action|caught false-completion|NOT_VERIFIED —|command-log integrity check FAILED|gate misconfiguration:|surface unavailable —|expected capture log is missing|exit-code-laundered|malformed-evidence/;
1876
+ const FULL_BLOCK = /status:|Definition Of Done|Goal Fit|sidecar validation:|contradicts evidence\.json|workflow state|evidence verdict|evidence check|NOT_VERIFIED gap|critique status|critique open|next action|caught false-completion|NOT_VERIFIED —|command-log integrity check FAILED|gate misconfiguration:|surface unavailable —|expected capture log is missing|exit-code-laundered|malformed-evidence|NOT_VERIFIED \(ambiguous\)/;
1652
1877
 
1653
1878
  async function analyze(root, now = Date.now()) {
1654
1879
  const flowAgentsDirs = flowAgentsArtifactRootsForRead(root);
@@ -1740,7 +1965,8 @@ async function analyze(root, now = Date.now()) {
1740
1965
  // Use module-scope HARD_BLOCK / FULL_BLOCK (defined above analyze()).
1741
1966
  // pre-execution/terminal tasks: only HARD_BLOCK signals cause a block.
1742
1967
  // execution-onward tasks: FULL_BLOCK signals cause a block.
1743
- const blockRe = (preExecution || terminal) ? HARD_BLOCK : FULL_BLOCK;
1968
+ const activeFlowRun = gateState && gateState.flow_run && normalizedStatus(gateState.flow_run.status) === 'active';
1969
+ const blockRe = ((preExecution && !activeFlowRun) || terminal) ? HARD_BLOCK : FULL_BLOCK;
1744
1970
  const blocking = warnings.some(w => {
1745
1971
  // Capture cross-reference warn-mode notes never block (operator opted out).
1746
1972
  if (/\[backstop in warn mode — not blocking\]/.test(w)) return false;
@@ -1994,6 +2220,10 @@ function releaseOnNonTerminalStop(root, artifactDir) {
1994
2220
 
1995
2221
  const state = readJsonFile(path.join(artifactDir, 'state.json'));
1996
2222
  if (!state) return; // AC5: no state.json — nothing to gate a release decision on.
2223
+ if (state.flow_run && normalizedStatus(state.flow_run.status) === 'active') {
2224
+ process.stderr.write(`[Hook] Goal Fit: stop-hook release skipped for active Flow run "${safeOneLine(state.flow_run.run_id || state.task_slug || 'unknown', 80)}"; continuation remains governed by Flow state.\n`);
2225
+ return;
2226
+ }
1997
2227
 
1998
2228
  const sidecar = loadWorkflowSidecarBuilt();
1999
2229
  if (!sidecar) {