@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
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Pure, side-effect-free decision logic for the guided "Connect to Kontour
3
+ * Console?" wizard (install-flow-console-connect PR2). Mirrors the doc-comment
4
+ * convention of `console-telemetry-validate.ts` (this literally is "the future
5
+ * guided console-connect wizard (PR2/PR3)" that file's own docstring names).
6
+ *
7
+ * No `fs`/`child_process`/network imports here — every export is a pure
8
+ * function or a thin orchestration over injected `ask`/`askHidden` callbacks,
9
+ * so `interactiveOptions()` in `src/cli/init.ts` stays a thin caller and this
10
+ * module is fully unit-testable without a TTY.
11
+ *
12
+ * `TelemetrySink` is re-declared as a local type alias (not imported from
13
+ * `init.ts`) to avoid a lib -> cli import direction. Keep this union in sync
14
+ * with `src/cli/init.ts`'s `TelemetrySink` by comment cross-reference:
15
+ * "local-files" | "local-kontour-console" | "kontour-hosted-console" |
16
+ * "user-hosted-console" | "kontour-cloud" | "hosted-kontour-console"
17
+ */
18
+ import { isValidConsoleUrl, isValidConsoleToken, isValidConsoleTenant } from "./console-telemetry-validate.js";
19
+
20
+ export type TelemetrySink =
21
+ | "local-files"
22
+ | "local-kontour-console"
23
+ | "kontour-hosted-console"
24
+ | "user-hosted-console"
25
+ | "kontour-cloud"
26
+ | "hosted-kontour-console";
27
+
28
+ export type ConsoleConnectChoice = "hosted" | "local" | "self-hosted" | "skip";
29
+
30
+ /**
31
+ * Case-insensitive match on the four accepted choice spellings. Blank or
32
+ * unrecognized input falls back to `fallback` (the caller decides the
33
+ * default, typically "hosted").
34
+ */
35
+ export function normalizeConsoleConnectChoice(answer: string, fallback: ConsoleConnectChoice): ConsoleConnectChoice {
36
+ const normalized = answer.trim().toLowerCase();
37
+ if (!normalized) return fallback;
38
+ if (normalized === "hosted" || normalized === "h") return "hosted";
39
+ if (normalized === "local" || normalized === "l") return "local";
40
+ if (normalized === "self-hosted" || normalized === "selfhosted" || normalized === "self" || normalized === "s") return "self-hosted";
41
+ if (normalized === "skip" || normalized === "none" || normalized === "n") return "skip";
42
+ return fallback;
43
+ }
44
+
45
+ /** Pure lookup table: choice -> the exact `telemetrySinks` array to install with. */
46
+ export function mapConsoleConnectChoiceToSinks(choice: ConsoleConnectChoice): TelemetrySink[] {
47
+ if (choice === "hosted") return ["kontour-hosted-console"];
48
+ if (choice === "local") return ["local-kontour-console"];
49
+ if (choice === "self-hosted") return ["user-hosted-console"];
50
+ return ["local-files"];
51
+ }
52
+
53
+ /** Only "self-hosted" requires an inline URL prompt. */
54
+ export function needsConsoleUrlPrompt(choice: ConsoleConnectChoice): boolean {
55
+ return choice === "self-hosted";
56
+ }
57
+
58
+ /**
59
+ * Mirrors `needsConsoleCredentials`'s existing semantics one-for-one
60
+ * (choice-based instead of sinks-based): every choice except "skip" wants
61
+ * token/tenant prompts.
62
+ */
63
+ export function needsConsoleCredentialPrompts(choice: ConsoleConnectChoice): boolean {
64
+ return choice !== "skip";
65
+ }
66
+
67
+ /**
68
+ * The one hard-fail guard this PR adds: self-hosted Console with a blank URL
69
+ * is a downstream `die` in `install-console-config.sh` (the `user-hosted-console`
70
+ * branch requires `--console-url`/`--console-endpoint`). Falls back to
71
+ * `local-files` with a one-line warning instead of letting that crash happen.
72
+ *
73
+ * Only handles the blank case — a non-blank-but-malformed URL (e.g. a typo)
74
+ * is NOT this guard's job; callers separately run `isValidConsoleUrl` for
75
+ * that format-only warn-and-continue case.
76
+ */
77
+ export function resolveSelfHostedUrlOrFallback(url: string): { sinks: TelemetrySink[]; url: string; fallbackWarning?: string } {
78
+ if (url.trim() === "") {
79
+ return {
80
+ sinks: ["local-files"],
81
+ url: "",
82
+ fallbackWarning: "Self-hosted Console requires a URL; no URL was given, so telemetry stays local-only. Re-run init or telemetry-doctor to add a Console URL later.",
83
+ };
84
+ }
85
+ return { sinks: ["user-hosted-console"], url };
86
+ }
87
+
88
+ export type ConsoleConnectIo = {
89
+ ask: (prompt: string) => Promise<string>;
90
+ askHidden: (prompt: string) => Promise<string>;
91
+ };
92
+
93
+ export type ConsoleConnectDefaults = {
94
+ hostedUrl: string;
95
+ localUrl: string;
96
+ };
97
+
98
+ export type ConsoleConnectResult = {
99
+ telemetrySinks: TelemetrySink[];
100
+ consoleUrl?: string;
101
+ consoleTokenValue?: string;
102
+ consoleTenant?: string;
103
+ warnings: string[];
104
+ };
105
+
106
+ // Token provisioning is console-side (CONSOLE_AUTH_TOKENS_JSON), not
107
+ // flow-agents-side. This hint is folded into the token prompt string itself
108
+ // (rather than printed directly via console.log) so the whole orchestration
109
+ // stays testable via the injected `askHidden` stub's captured prompt argument.
110
+ const TOKEN_PROVISIONING_HINT =
111
+ "Tokens are provisioned by your Console admin via CONSOLE_AUTH_TOKENS_JSON -- see docs/integrations/flow-agents-console.md.";
112
+
113
+ /**
114
+ * Full interactive orchestration used by `interactiveOptions()`: prompts the
115
+ * four-way "Connect to Kontour Console?" choice (default Hosted, showing the
116
+ * real `console-presets.sh`-resolved hosted URL), applies the self-hosted
117
+ * blank-URL guard, prompts hidden token + tenant when a console connection is
118
+ * wanted, runs PR1's pure validators (validate-and-warn, not
119
+ * validate-and-block), and returns any format warnings in `warnings` for the
120
+ * caller to print -- rather than printing directly, so this stays testable
121
+ * via return-value assertions, not console-spy assertions.
122
+ */
123
+ export async function runConsoleConnectWizard(
124
+ io: ConsoleConnectIo,
125
+ defaults: ConsoleConnectDefaults,
126
+ ): Promise<ConsoleConnectResult> {
127
+ const warnings: string[] = [];
128
+
129
+ const choiceAnswer = await io.ask(
130
+ `Connect to Kontour Console? [Hosted (${defaults.hostedUrl})/Local (${defaults.localUrl})/Self-hosted/Skip] [Hosted]: `,
131
+ );
132
+ const choice = normalizeConsoleConnectChoice(choiceAnswer, "hosted");
133
+
134
+ let sinks = mapConsoleConnectChoiceToSinks(choice);
135
+ let consoleUrl: string | undefined;
136
+
137
+ if (needsConsoleUrlPrompt(choice)) {
138
+ const urlAnswer = await io.ask("Self-hosted Console URL: ");
139
+ const resolved = resolveSelfHostedUrlOrFallback(urlAnswer);
140
+ sinks = resolved.sinks;
141
+ consoleUrl = resolved.url ? resolved.url : undefined;
142
+ if (resolved.fallbackWarning) {
143
+ warnings.push(resolved.fallbackWarning);
144
+ } else if (consoleUrl && !isValidConsoleUrl(consoleUrl)) {
145
+ warnings.push(
146
+ `Console URL '${consoleUrl}' does not look valid (expected https://, or http:// to localhost/127.0.0.1); continuing anyway.`,
147
+ );
148
+ }
149
+ }
150
+
151
+ let consoleTokenValue: string | undefined;
152
+ let consoleTenant: string | undefined;
153
+ // Only prompt for credentials when a console sink actually survived (the
154
+ // self-hosted blank-URL fallback above may have already downgraded to
155
+ // local-files, in which case there is nothing to authenticate).
156
+ const wantsConsole = needsConsoleCredentialPrompts(choice) && sinks.some((sink) => sink !== "local-files");
157
+ if (wantsConsole) {
158
+ const tokenAnswer = await io.askHidden(`${TOKEN_PROVISIONING_HINT}\nConsole telemetry token (blank to skip): `);
159
+ consoleTokenValue = tokenAnswer.trim() || undefined;
160
+ if (consoleTokenValue && !isValidConsoleToken(consoleTokenValue)) {
161
+ warnings.push(
162
+ "Console token format looks unusual (expected <=4096 chars, charset [A-Za-z0-9._~+/=-]); continuing anyway.",
163
+ );
164
+ }
165
+ const tenantAnswer = await io.ask("Console tenant ID (blank to skip): ");
166
+ consoleTenant = tenantAnswer.trim() || undefined;
167
+ if (consoleTenant && !isValidConsoleTenant(consoleTenant)) {
168
+ warnings.push(
169
+ "Console tenant format looks unusual (expected charset [A-Za-z0-9._:-]); continuing anyway.",
170
+ );
171
+ }
172
+ }
173
+
174
+ return { telemetrySinks: sinks, consoleUrl, consoleTokenValue, consoleTenant, warnings };
175
+ }
176
+
177
+ export type ConsoleStatus = "connected-verified" | "connected-unverified" | "local-only";
178
+
179
+ type DoctorConsoleShape = {
180
+ sink: "local-only" | "console";
181
+ reachability: { checked: boolean; ok: boolean | null; error?: string; statusCode?: number };
182
+ };
183
+
184
+ /**
185
+ * Pure classifier over the exact structural shape `telemetry-doctor.ts`'s
186
+ * `DoctorReport.console` already has. Deliberately accepts a narrow
187
+ * structural type rather than importing `DoctorReport` itself, keeping this
188
+ * module dependency-free (no lib -> cli import).
189
+ */
190
+ // Actionable detail shown when a Console sink's reachability was never
191
+ // attempted (checked:false). In practice this branch is reached ONLY via
192
+ // telemetry-doctor.ts's pre-attempt guard clauses (endpointAllowed() said no,
193
+ // or the endpoint URL was malformed) -- an actually-attempted check that
194
+ // fails always sets checked:true (see checkConsoleReachability's HEAD
195
+ // request path) and is handled by the branch above, which keeps surfacing
196
+ // error/statusCode unchanged. The most common guard-clause reason by far is
197
+ // a self-hosted/BYO HTTPS host that --allow-network was not passed for, so
198
+ // the hint below points at the one flag that unblocks it.
199
+ const NOT_CHECKED_DETAIL =
200
+ "not checked — self-hosted/BYO consoles need `flow-agents telemetry-doctor --allow-network` to verify reachability";
201
+
202
+ export function describeConsoleStatus(doctor: { console: DoctorConsoleShape }): { status: ConsoleStatus; detail?: string } {
203
+ const { sink, reachability } = doctor.console;
204
+ if (sink === "local-only") return { status: "local-only" };
205
+ if (reachability.checked && reachability.ok === true) return { status: "connected-verified" };
206
+ if (reachability.checked && reachability.ok === false) {
207
+ const detail = reachability.error ?? (reachability.statusCode !== undefined ? `HTTP ${reachability.statusCode}` : "reachability check failed");
208
+ return { status: "connected-unverified", detail };
209
+ }
210
+ // reachability.checked === false: never attempted (see NOT_CHECKED_DETAIL's
211
+ // doc comment for why this is always the not-allowed/skipped reason here,
212
+ // never an attempted-and-failed check).
213
+ return { status: "connected-unverified", detail: NOT_CHECKED_DETAIL };
214
+ }
215
+
216
+ export type PostInstallSummaryInput = {
217
+ runtime: string;
218
+ runtimeAutoDetected: boolean;
219
+ dest: string;
220
+ telemetrySinks: string[];
221
+ consoleStatus: { status: ConsoleStatus; detail?: string };
222
+ tokenConfigured: boolean;
223
+ tenantConfigured: boolean;
224
+ nextSteps: string[];
225
+ };
226
+
227
+ function consoleStatusLine(consoleStatus: { status: ConsoleStatus; detail?: string }): string {
228
+ if (consoleStatus.status === "connected-verified") return "✓ Console: connected + verified";
229
+ if (consoleStatus.status === "connected-unverified") {
230
+ return `✗ Console: connected, unverified${consoleStatus.detail ? `: ${consoleStatus.detail}` : ""}`;
231
+ }
232
+ return "- Console: local-only";
233
+ }
234
+
235
+ /**
236
+ * Pure string[]-builder for the post-install summary block: one array entry
237
+ * per line, so callers `console.log` each entry and tests assert on array
238
+ * contents rather than scraping stdout. Never receives or emits the raw
239
+ * console token value -- only the `tokenConfigured`/`tenantConfigured`
240
+ * booleans are shown.
241
+ */
242
+ export function buildPostInstallSummaryLines(input: PostInstallSummaryInput): string[] {
243
+ const lines: string[] = [];
244
+ lines.push("");
245
+ lines.push("Flow Agents install summary:");
246
+ const runtimeLine = input.runtimeAutoDetected ? `${input.runtime} (auto-detected)` : input.runtime;
247
+ lines.push(` ✓ Runtime: ${runtimeLine}`);
248
+ lines.push(` ✓ Destination: ${input.dest}`);
249
+ lines.push(` ✓ Telemetry sink: ${input.telemetrySinks.length ? input.telemetrySinks.join(", ") : "local-files"}`);
250
+ lines.push(` ${consoleStatusLine(input.consoleStatus)}`);
251
+ if (input.consoleStatus.status !== "local-only") {
252
+ lines.push(` Console token: ${input.tokenConfigured ? "configured" : "not configured"}`);
253
+ lines.push(` Console tenant: ${input.tenantConfigured ? "configured" : "not configured"}`);
254
+ }
255
+ if (input.nextSteps.length) {
256
+ lines.push("");
257
+ lines.push("Next steps:");
258
+ for (const step of input.nextSteps) lines.push(` - ${step}`);
259
+ }
260
+ return lines;
261
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Pure, side-effect-free validation predicates mirroring the bash Console
3
+ * telemetry validators exactly, for the future guided console-connect wizard
4
+ * (PR2/PR3) to reuse at prompt time. Deliberately NOT wired into `init.ts`'s
5
+ * interactive flow in this PR — see install-flow-foundations plan, Thread D.
6
+ *
7
+ * Every predicate returns a plain boolean (never throws), matching the
8
+ * "wizard re-prompts on false" use case, unlike the bash `die`-based
9
+ * validators these mirror (which exit non-zero on failure).
10
+ */
11
+
12
+ /**
13
+ * Mirrors `has_control_chars` (scripts/telemetry/install-console-config.sh:43-45).
14
+ * Rejects values containing a newline, carriage return, or tab.
15
+ */
16
+ export function hasControlChars(value: string): boolean {
17
+ return value.includes("\n") || value.includes("\r") || value.includes("\t");
18
+ }
19
+
20
+ /** Shared https/localhost/127.0.0.1 scheme rule used by both URL validators below. */
21
+ function isHttpsOrLocalhostUrl(value: string): boolean {
22
+ if (value.startsWith("https://")) return true;
23
+ if (value === "http://127.0.0.1" || value.startsWith("http://127.0.0.1:") || value.startsWith("http://127.0.0.1/")) return true;
24
+ if (value === "http://localhost" || value.startsWith("http://localhost:") || value.startsWith("http://localhost/")) return true;
25
+ return false;
26
+ }
27
+
28
+ /**
29
+ * Mirrors `validate_url` (scripts/telemetry/install-console-config.sh:47-62).
30
+ * Blank is valid (the field is optional at install time). Otherwise: no
31
+ * control characters, and must be `https://*`, or `http://` to localhost/127.0.0.1.
32
+ */
33
+ export function isValidConsoleUrl(value: string): boolean {
34
+ if (value === "") return true;
35
+ if (hasControlChars(value)) return false;
36
+ return isHttpsOrLocalhostUrl(value);
37
+ }
38
+
39
+ /**
40
+ * Mirrors `console_telemetry_endpoint_allowed` (scripts/telemetry/lib/transport.sh:20-28).
41
+ * Unlike `isValidConsoleUrl`, blank is INVALID (a non-empty endpoint is
42
+ * required at runtime-post time). This check is deliberately narrower than
43
+ * `hasControlChars`: the bash function only rejects `\n`, `\r`, and a
44
+ * literal `"` (the value is later embedded in a curl config file as a
45
+ * quoted string) — it does NOT reject tab, so this must not call
46
+ * `hasControlChars` (which also rejects tab).
47
+ */
48
+ export function isValidConsoleEndpointStrict(value: string): boolean {
49
+ if (value === "") return false;
50
+ if (value.includes("\n") || value.includes("\r") || value.includes('"')) return false;
51
+ return isHttpsOrLocalhostUrl(value);
52
+ }
53
+
54
+ const CONSOLE_TOKEN_CHARSET = /^[A-Za-z0-9._~+/=-]+$/;
55
+
56
+ /**
57
+ * Mirrors `validate_token` (scripts/telemetry/install-console-config.sh:64-69).
58
+ * Blank is valid (optional field). Otherwise: length <= 4096, no control
59
+ * characters, and charset restricted to `[A-Za-z0-9._~+/=-]`.
60
+ */
61
+ export function isValidConsoleToken(value: string): boolean {
62
+ if (value === "") return true;
63
+ if (value.length > 4096) return false;
64
+ if (hasControlChars(value)) return false;
65
+ return CONSOLE_TOKEN_CHARSET.test(value);
66
+ }
67
+
68
+ const CONSOLE_TENANT_CHARSET = /^[A-Za-z0-9._:-]+$/;
69
+
70
+ /**
71
+ * Mirrors `validate_tenant` (scripts/telemetry/install-console-config.sh:82-86).
72
+ * Blank is valid (optional field). Otherwise: charset restricted to
73
+ * `[A-Za-z0-9._:-]` (no length bound at install time).
74
+ */
75
+ export function isValidConsoleTenant(value: string): boolean {
76
+ if (value === "") return true;
77
+ return CONSOLE_TENANT_CHARSET.test(value);
78
+ }
79
+
80
+ /**
81
+ * Mirrors `console_telemetry_safe_tenant` (scripts/telemetry/lib/transport.sh:35-38).
82
+ * Unlike `isValidConsoleTenant`, non-empty is required and length is bounded
83
+ * to 1..128 (the runtime-safety gate, stricter than the install-time validator).
84
+ */
85
+ export function isSafeConsoleTenantForRuntime(value: string): boolean {
86
+ if (value.length < 1 || value.length > 128) return false;
87
+ return CONSOLE_TENANT_CHARSET.test(value);
88
+ }
@@ -57,6 +57,33 @@ function isAgentWritableDir(resolvedDir: string): boolean {
57
57
  }
58
58
  }
59
59
 
60
+ function installedPackageRoot(): string | null {
61
+ let directory = path.dirname(fileURLToPath(import.meta.url));
62
+ while (true) {
63
+ if (fs.existsSync(path.join(directory, "package.json")) && fs.existsSync(path.join(directory, "kits"))) {
64
+ return directory;
65
+ }
66
+ const parent = path.dirname(directory);
67
+ if (parent === directory) return null;
68
+ directory = parent;
69
+ }
70
+ }
71
+
72
+ function packagedFlowFile(kitId: string, flowName: string, consumerRoot: string): string | null {
73
+ const packageRoot = installedPackageRoot();
74
+ if (!packageRoot || path.resolve(packageRoot) === path.resolve(consumerRoot)) return null;
75
+ const kitsRoot = path.resolve(packageRoot, "kits");
76
+ const candidate = path.resolve(kitsRoot, kitId, "flows", `${flowName}.flow.json`);
77
+ if (!candidate.startsWith(kitsRoot + path.sep)) return null;
78
+ try {
79
+ const realKitsRoot = fs.realpathSync.native(kitsRoot);
80
+ const realCandidate = fs.realpathSync.native(candidate);
81
+ return realCandidate.startsWith(realKitsRoot + path.sep) ? realCandidate : null;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
60
87
  /**
61
88
  * Build and validate the FlowDefinition file path.
62
89
  *
@@ -65,30 +92,29 @@ function isAgentWritableDir(resolvedDir: string): boolean {
65
92
  * - FLOW_AGENTS_FLOW_DEFS_DIR resolves into a runtime artifact directory
66
93
  * - The resolved path escapes the expected root (belt-and-suspenders)
67
94
  *
68
- * When the override is unsafe, it is ignored and the resolver uses the
69
- * canonical repoRoot/kits/ source for legitimate flows.
95
+ * An unsafe explicit override fails closed. Package fallback applies only to
96
+ * canonical lookup when no override was supplied.
70
97
  */
71
98
  export function resolveFlowFilePath(
72
99
  kitId: string,
73
100
  flowName: string,
74
101
  flowId: string,
75
102
  repoRoot: string,
103
+ allowOverride = true,
76
104
  ): string | null {
77
105
  // Primary defense: reject any slug containing traversal chars or non-identifier chars.
78
106
  if (!SLUG_RE.test(kitId) || !SLUG_RE.test(flowName)) return null;
79
107
 
80
- const override = process.env["FLOW_AGENTS_FLOW_DEFS_DIR"];
108
+ const override = allowOverride ? process.env["FLOW_AGENTS_FLOW_DEFS_DIR"] : undefined;
81
109
 
82
110
  let expectedRoot: string;
83
111
  let flowFilePath: string;
112
+ let canonicalLookup = false;
84
113
 
85
114
  if (override) {
86
115
  const resolvedOverride = path.resolve(override);
87
116
  if (isAgentWritableDir(resolvedOverride)) {
88
- // Override targets an agent-writable runtime path; ignore it and use
89
- // the canonical kit root. The session will resolve the real kit flow.
90
- expectedRoot = path.resolve(repoRoot, "kits");
91
- flowFilePath = path.join(repoRoot, "kits", kitId, "flows", `${flowName}.flow.json`);
117
+ return null;
92
118
  } else {
93
119
  expectedRoot = resolvedOverride;
94
120
  // flowId = kitId + "." + flowName; after slug validation this contains only
@@ -98,6 +124,7 @@ export function resolveFlowFilePath(
98
124
  } else {
99
125
  expectedRoot = path.resolve(repoRoot, "kits");
100
126
  flowFilePath = path.join(repoRoot, "kits", kitId, "flows", `${flowName}.flow.json`);
127
+ canonicalLookup = true;
101
128
  }
102
129
 
103
130
  // Belt-and-suspenders: confirm the resolved path stays within the expected root.
@@ -119,6 +146,10 @@ export function resolveFlowFilePath(
119
146
  }
120
147
  return realPath;
121
148
  } catch {
149
+ if (canonicalLookup) {
150
+ const packaged = packagedFlowFile(kitId, flowName, repoRoot);
151
+ if (packaged) return packaged;
152
+ }
122
153
  return resolvedPath;
123
154
  }
124
155
  }
@@ -143,6 +174,7 @@ export type ActiveFlowStep = {
143
174
  stepId: string;
144
175
  gateId: string;
145
176
  gateExpects: GateExpectation[];
177
+ routeBackReasons: string[];
146
178
  /** When resolved through a parent step's uses_flow edge, names the child FlowDefinition that owns the gate. */
147
179
  sourceFlowId?: string;
148
180
  };
@@ -183,10 +215,10 @@ function flowIdParts(flowId: string): { kitId: string; flowName: string } | null
183
215
  return { kitId, flowName };
184
216
  }
185
217
 
186
- function readFlowDefinition(flowId: string, repoRoot: string): FlowDefinition | null {
218
+ function readFlowDefinition(flowId: string, repoRoot: string, allowOverride = true): FlowDefinition | null {
187
219
  const parts = flowIdParts(flowId);
188
220
  if (!parts) return null;
189
- const flowFilePath = resolveFlowFilePath(parts.kitId, parts.flowName, flowId, repoRoot);
221
+ const flowFilePath = resolveFlowFilePath(parts.kitId, parts.flowName, flowId, repoRoot, allowOverride);
190
222
  if (!flowFilePath) return null;
191
223
 
192
224
  try {
@@ -213,6 +245,116 @@ export function resolveFlowStep(flowId: string, stepId: string, repoRoot: string
213
245
  return publicStep;
214
246
  }
215
247
 
248
+ /**
249
+ * Compile Flow Agents' `uses_flow` kit extension into one effective definition
250
+ * that the Flow runtime can evaluate without understanding agent-layer composition.
251
+ */
252
+ export function resolveEffectiveFlowDefinition(flowId: string, repoRoot: string, options: { allowOverride?: boolean } = {}): Record<string, unknown> | null {
253
+ return resolveEffectiveFlowDefinitionInternal(flowId, repoRoot, new Set<string>(), options.allowOverride !== false);
254
+ }
255
+
256
+ function resolveEffectiveFlowDefinitionInternal(flowId: string, repoRoot: string, seen: Set<string>, allowOverride: boolean): Record<string, unknown> | null {
257
+ if (seen.has(flowId)) return null;
258
+ const nextSeen = new Set(seen);
259
+ nextSeen.add(flowId);
260
+ const source = readFlowDefinition(flowId, repoRoot, allowOverride);
261
+ if (!source || !Array.isArray(source.steps)) return null;
262
+ const effective = JSON.parse(JSON.stringify(source)) as FlowDefinition;
263
+ effective.gates = { ...(effective.gates ?? {}) };
264
+
265
+ for (let index = 0; index < source.steps.length; index += 1) {
266
+ const sourceStep = source.steps[index]!;
267
+ if (typeof sourceStep.uses_flow !== "string" || !sourceStep.uses_flow.trim()) continue;
268
+ const child = resolveEffectiveFlowDefinitionInternal(sourceStep.uses_flow, repoRoot, nextSeen, allowOverride) as FlowDefinition | null;
269
+ if (!child || !child.gates) return null;
270
+ const childGateEntry = Object.entries(child.gates).find(([, gate]) => gate?.step === sourceStep.id);
271
+ if (!childGateEntry) return null;
272
+ if (Object.values(effective.gates).some((gate) => gate?.step === sourceStep.id)) return null;
273
+ const [childGateId, childGate] = childGateEntry;
274
+ const childExpects = Array.isArray(childGate.expects) ? childGate.expects : [];
275
+ const exported = exportedExpectations(childExpects, child.exports);
276
+ if (!exported) return null;
277
+ effective.gates[`${sourceStep.uses_flow}:${childGateId}`] = {
278
+ ...childGate,
279
+ expects: exported,
280
+ };
281
+ const { uses_flow: _usesFlow, ...compiledStep } = effective.steps![index]!;
282
+ effective.steps![index] = compiledStep;
283
+ }
284
+ const effectiveSteps = effective.steps!;
285
+ const done = effectiveSteps.find((step) => step.id === "done" && step.next === null);
286
+ if (done && !Object.values(effective.gates).some((gate) => gate?.step === "done")) {
287
+ effective.steps = effectiveSteps
288
+ .filter((step) => step.id !== "done")
289
+ .map((step) => step.next === "done" ? { ...step, next: null } : step);
290
+ }
291
+ return effective as unknown as Record<string, unknown>;
292
+ }
293
+
294
+ /**
295
+ * A single (stepId, gateId) → expects[] tuple, as part of the FULL enumeration of every gate in
296
+ * a FlowDefinition (across every step — see resolveAllFlowGateExpects below), not just the
297
+ * currently-active one.
298
+ */
299
+ export type FlowGateExpectsEntry = {
300
+ stepId: string;
301
+ gateId: string;
302
+ gateExpects: GateExpectation[];
303
+ };
304
+
305
+ /**
306
+ * Enumerate the gate expects[] for EVERY step in a FlowDefinition (#270 CRITICAL/HIGH fix) —
307
+ * not just the currently-active step. This is what lets a stamped gate-claim's
308
+ * (expectation_id, claim_type, subject_type, step_id) tuple be validated against the FULL,
309
+ * declared shape of the flow, instead of only against whatever step happens to be active at
310
+ * validation time (which is a DIFFERENT, and usually wrong, question: the stamp names the step
311
+ * it was ORIGINALLY recorded at, which by design may not be the currently-active one — see
312
+ * buildTrustBundle's #270(a)/(c) step_id-freezing comments in workflow-sidecar.ts).
313
+ *
314
+ * Walks flowDef.steps[] (in declaration order) and resolves each step's gate via the same
315
+ * resolveFlowStepInternal used by resolveFlowStep/resolveActiveFlowStep — including the
316
+ * uses_flow composed-step case, so a gate that lives in a child FlowDefinition (e.g.
317
+ * builder.publish-learn's pr-open-gate, composed into builder.build's "pr-open" step) is
318
+ * enumerated too, exports-filtered exactly as a live resolution would be.
319
+ *
320
+ * Pure and synchronous — no throws, fail-open (returns []) on any error, mirroring every other
321
+ * resolver in this module.
322
+ *
323
+ * Callers MUST distinguish the two null-ish outcomes (#270 MEDIUM fix, iteration 3):
324
+ * - `null` → the FlowDefinition could not be LOADED at all (missing file, unreadable, invalid
325
+ * JSON, or no `steps[]` array) — the caller has no basis to validate anything against this
326
+ * flow and must fail closed with a dedicated "cannot be loaded" message, never the "stamp
327
+ * does not match any expects[]" (forged/corrupt) message; those are different failure
328
+ * classes with different remedies (a load failure means "fix/restore the FlowDefinition
329
+ * file or the flowId", not "this stamp was forged").
330
+ * - `[]` → the FlowDefinition LOADED successfully but genuinely declares no steps with
331
+ * matching gates (or no gates at all) — a real, if unusual, flow shape, not an error.
332
+ *
333
+ * @param flowId e.g. "builder.build" — kitId is extracted as the prefix before the first ".".
334
+ * @param repoRoot Absolute path to the repository root (kits/ lives here).
335
+ * Honored only when FLOW_AGENTS_FLOW_DEFS_DIR is not set.
336
+ * @returns Every (stepId, gateId, gateExpects) tuple in the flow; `[]` when the flow loads but
337
+ * declares no matching gates; `null` when the FlowDefinition cannot be loaded/parsed at all.
338
+ */
339
+ export function resolveAllFlowGateExpects(flowId: string, repoRoot: string): FlowGateExpectsEntry[] | null {
340
+ const flowDef = readFlowDefinition(flowId, repoRoot);
341
+ if (!flowDef || typeof flowDef !== "object" || !Array.isArray(flowDef.steps)) return null;
342
+ const out: FlowGateExpectsEntry[] = [];
343
+ const seenGateIds = new Set<string>();
344
+ for (const step of flowDef.steps) {
345
+ if (!step || typeof step.id !== "string" || !step.id) continue;
346
+ const resolved = resolveFlowStepInternal(flowId, step.id, repoRoot, new Set<string>());
347
+ if (!resolved) continue;
348
+ // A gate can be reached by more than one step declaration in degenerate/duplicate step
349
+ // lists; de-dupe by gateId so callers never see the same expects[] entries twice.
350
+ const dedupeKey = `${resolved.gateId}`;
351
+ if (seenGateIds.has(dedupeKey)) continue;
352
+ seenGateIds.add(dedupeKey);
353
+ out.push({ stepId: resolved.stepId, gateId: resolved.gateId, gateExpects: resolved.gateExpects });
354
+ }
355
+ return out;
356
+ }
357
+
216
358
  function expectationExportKeys(expectation: GateExpectation): string[] {
217
359
  const keys: string[] = [];
218
360
  if (typeof expectation.id === "string" && expectation.id) keys.push(expectation.id);
@@ -249,7 +391,7 @@ function resolveFlowStepInternal(flowId: string, stepId: string, repoRoot: strin
249
391
  for (const [gateId, gate] of Object.entries(flowDef.gates)) {
250
392
  if (!gate || gate.step !== stepId) continue;
251
393
  const expects = Array.isArray(gate.expects) ? gate.expects : [];
252
- return { flowId, stepId, gateId, gateExpects: expects, flowExports: flowDef.exports };
394
+ return { flowId, stepId, gateId, gateExpects: expects, routeBackReasons: Object.keys(gate.on_route_back ?? {}), flowExports: flowDef.exports };
253
395
  }
254
396
  }
255
397
 
@@ -266,6 +408,7 @@ function resolveFlowStepInternal(flowId: string, stepId: string, repoRoot: strin
266
408
  stepId,
267
409
  gateId: `${child.flowId}:${child.gateId}`,
268
410
  gateExpects: childGateExpects,
411
+ routeBackReasons: child.routeBackReasons,
269
412
  sourceFlowId: child.flowId,
270
413
  flowExports: flowDef.exports,
271
414
  };