@kontourai/flow-agents 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +41 -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 +54 -1
  34. package/build/src/lib/flow-resolver.js +112 -5
  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 +72 -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 +117 -4
  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
package/src/cli/init.ts CHANGED
@@ -10,7 +10,9 @@ import { parseArgs, flagBool, flagList, flagString } from "../lib/args.js";
10
10
  import { activateCodexLocal } from "../runtime-adapters.js";
11
11
  import { main as buildBundles } from "../tools/build-universal-bundles.js";
12
12
  import { root } from "../tools/common.js";
13
- import { defaultCodexHome, durableInstallRecordPath } from "../lib/local-artifact-root.js";
13
+ import { defaultCodexHome, durableInstallRecordPath, skillsManifestPath } from "../lib/local-artifact-root.js";
14
+ import { runConsoleConnectWizard, describeConsoleStatus, buildPostInstallSummaryLines } from "../lib/console-connect-options.js";
15
+ import { buildReport } from "./telemetry-doctor.js";
14
16
 
15
17
  type Runtime = "base" | "codex" | "claude-code" | "kiro" | "opencode" | "pi";
16
18
  type TelemetrySink = "local-files" | "local-kontour-console" | "kontour-hosted-console" | "user-hosted-console" | "kontour-cloud" | "hosted-kontour-console";
@@ -26,6 +28,15 @@ type InitOptions = {
26
28
  consoleTenant?: string;
27
29
  telemetrySinks: TelemetrySink[];
28
30
  activateKits: boolean;
31
+ activeKitIds?: string[];
32
+ // Informational only -- never read by installBundle()/install-console-config.sh
33
+ // (not part of the install writer contract). Set when the runtime was not
34
+ // given via an explicit --runtime flag/typed answer and the layered
35
+ // detector (detectRuntimeFromProcessEnv/detectRuntimeFromFilesystem)
36
+ // actually matched a signal, for the post-install summary's
37
+ // "(auto-detected)" annotation. headlessOptions() itself never sets this
38
+ // (untouched) -- main() merges it onto the headless result separately.
39
+ runtimeAutoDetected?: boolean;
29
40
  };
30
41
 
31
42
  const runtimeBundles: Record<Runtime, string> = {
@@ -111,10 +122,39 @@ Options:
111
122
  --console-token-file PATH
112
123
  --console-tenant ID
113
124
  --activate-kits
125
+ --activate-kit KIT_ID Activate one catalog kit by id. Repeat for multiple kits.
114
126
  --yes, --headless
115
127
  `);
116
128
  }
117
129
 
130
+ function catalogKitIds(): string[] {
131
+ const catalogPath = path.join(root, "kits", "catalog.json");
132
+ if (!fs.existsSync(catalogPath)) return [];
133
+ const catalog = JSON.parse(fs.readFileSync(catalogPath, "utf8")) as { kits?: unknown[] };
134
+ if (!Array.isArray(catalog.kits)) return [];
135
+ return catalog.kits
136
+ .map((entry) => typeof entry === "object" && entry !== null ? String((entry as Record<string, unknown>).id ?? "") : "")
137
+ .filter(Boolean);
138
+ }
139
+
140
+ function selectedKitIdsFromFlags(flags: ReturnType<typeof parseArgs>["flags"]): string[] {
141
+ const explicit = flagList(flags, "activate-kit").map((id) => id.trim()).filter(Boolean);
142
+ if (explicit.length) return [...new Set(explicit)];
143
+ if (flagBool(flags, "activate-kits")) return catalogKitIds();
144
+ return [];
145
+ }
146
+
147
+ function writeInstallRecord(dest: string, runtime: Runtime, global: boolean | undefined, activeKitIds: string[] = []): void {
148
+ const recordPath = durableInstallRecordPath(dest);
149
+ const installRecordDir = path.dirname(recordPath);
150
+ fs.mkdirSync(installRecordDir, { recursive: true });
151
+ const pkgJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")) as Record<string, string>;
152
+ const record = { version: pkgJson["version"] ?? "0.0.0", installedAt: new Date().toISOString(), runtime, ...(global ? { global: true } : {}), active_kit_ids: activeKitIds };
153
+ const recordTmp = `${recordPath}.tmp.${process.pid}`;
154
+ fs.writeFileSync(recordTmp, `${JSON.stringify(record, null, 2)}\n`, "utf8");
155
+ fs.renameSync(recordTmp, recordPath);
156
+ }
157
+
118
158
  function normalizeRuntime(value: string | undefined): Runtime | undefined {
119
159
  if (!value) return undefined;
120
160
  if (value === "claude") return "claude-code";
@@ -122,6 +162,105 @@ function normalizeRuntime(value: string | undefined): Runtime | undefined {
122
162
  throw new Error(`unknown runtime '${value}'; expected base, codex, claude-code, kiro, opencode, or pi`);
123
163
  }
124
164
 
165
+ type ActorIdentityModule = { detectRuntime: (env: NodeJS.ProcessEnv) => string };
166
+
167
+ /**
168
+ * The three candidate global config directories a runtime's own tooling uses,
169
+ * shared between `globalDest` (the `--global` install target) and
170
+ * `detectRuntimeFromFilesystem` (auto-detection fallback) so "where does
171
+ * runtime X's global config live" is defined in exactly one place.
172
+ */
173
+ function runtimeGlobalConfigCandidates(
174
+ homedir: string,
175
+ env: NodeJS.ProcessEnv,
176
+ ): { "claude-code": string; codex: string; opencode: string } {
177
+ return {
178
+ "claude-code": path.join(homedir, ".claude"),
179
+ codex: defaultCodexHome(env, homedir),
180
+ opencode: path.join(env["XDG_CONFIG_HOME"] ?? path.join(homedir, ".config"), "opencode"),
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Detect the current runtime from environment-variable signals already
186
+ * ambient in the invoking process (Claude Code / Codex / opencode session
187
+ * markers). Reuses the single existing `detectRuntime` implementation from
188
+ * `scripts/hooks/lib/actor-identity.js` (via the same `createRequire` pattern
189
+ * used elsewhere in this file for `install-merge.js`/`skill-drift.js`)
190
+ * instead of forking a second copy of the env-var contract. `pi`'s env-var
191
+ * contract is unverified there (see actor-identity.js's own docstring) and is
192
+ * intentionally out of scope for auto-detection, so it maps down to
193
+ * `"unknown"` here (pi still falls back to `"base"` like today).
194
+ */
195
+ export function detectRuntimeFromProcessEnv(env: NodeJS.ProcessEnv = process.env): Runtime | "unknown" {
196
+ const actorIdentityPath = path.join(root, "scripts", "hooks", "lib", "actor-identity.js");
197
+ const _require = createRequire(import.meta.url);
198
+ const { detectRuntime } = _require(actorIdentityPath) as ActorIdentityModule;
199
+ const detected = detectRuntime(env);
200
+ if (detected === "claude-code" || detected === "codex" || detected === "opencode") return detected;
201
+ return "unknown";
202
+ }
203
+
204
+ /**
205
+ * Detect the current runtime by probing for exactly one runtime's own global
206
+ * config directory on disk (the same paths `globalDest` computes for
207
+ * `--global` installs — see `runtimeGlobalConfigCandidates`). Zero matches or
208
+ * 2+ matches are both ambiguous and must never guess: only a single
209
+ * unambiguous hit returns a runtime; everything else is `"unknown"`.
210
+ */
211
+ export function detectRuntimeFromFilesystem(
212
+ homedir: string = os.homedir(),
213
+ env: NodeJS.ProcessEnv = process.env,
214
+ ): Runtime | "unknown" {
215
+ const candidates = runtimeGlobalConfigCandidates(homedir, env);
216
+ const matches = (Object.keys(candidates) as Array<keyof typeof candidates>).filter((runtime) => {
217
+ try {
218
+ return fs.existsSync(candidates[runtime]);
219
+ } catch {
220
+ return false;
221
+ }
222
+ });
223
+ return matches.length === 1 ? matches[0] : "unknown";
224
+ }
225
+
226
+ /**
227
+ * Layered runtime auto-detection default used to pre-fill both the
228
+ * interactive prompt and the headless `--runtime` fallback: (1) env-var
229
+ * signals from the invoking process, (2) failing that, an unambiguous
230
+ * filesystem probe, (3) failing that, today's `"base"` default. An explicit
231
+ * `--runtime` flag or interactive answer always overrides this — this
232
+ * function only supplies the fallback default (see `interactiveOptions` /
233
+ * `headlessOptions`).
234
+ */
235
+ export function detectDefaultRuntime(
236
+ env: NodeJS.ProcessEnv = process.env,
237
+ homedir: string = os.homedir(),
238
+ ): Runtime {
239
+ const fromEnv = detectRuntimeFromProcessEnv(env);
240
+ if (fromEnv !== "unknown") return fromEnv;
241
+ const fromFilesystem = detectRuntimeFromFilesystem(homedir, env);
242
+ if (fromFilesystem !== "unknown") return fromFilesystem;
243
+ return "base";
244
+ }
245
+
246
+ /**
247
+ * Returns the runtime the layered detector actually matched (env signal
248
+ * first, then an unambiguous filesystem probe), or "unknown" if neither
249
+ * fired. Distinct from `detectDefaultRuntime()`, which additionally falls
250
+ * back to `"base"` when detection is unknown -- the post-install summary's
251
+ * "(auto-detected)" annotation needs to distinguish "detection fired" from
252
+ * "detection defaulted to base," which `detectDefaultRuntime()`'s return
253
+ * value alone cannot express.
254
+ */
255
+ function detectedRuntimeSignal(
256
+ env: NodeJS.ProcessEnv = process.env,
257
+ homedir: string = os.homedir(),
258
+ ): Runtime | "unknown" {
259
+ const fromEnv = detectRuntimeFromProcessEnv(env);
260
+ if (fromEnv !== "unknown") return fromEnv;
261
+ return detectRuntimeFromFilesystem(homedir, env);
262
+ }
263
+
125
264
  function normalizeTelemetrySink(value: string): TelemetrySink {
126
265
  if (value === "local-files" || value === "local-kontour-console" || value === "kontour-hosted-console" || value === "user-hosted-console" || value === "kontour-cloud" || value === "hosted-kontour-console") return value;
127
266
  throw new Error(`unknown telemetry sink '${value}'`);
@@ -140,6 +279,26 @@ function needsConsoleCredentials(sinks: TelemetrySink[]): boolean {
140
279
  return sinks.some((sink) => sink !== "local-files");
141
280
  }
142
281
 
282
+ const DEFAULT_HOSTED_CONSOLE_URL = "https://console.kontourai.io";
283
+ const DEFAULT_LOCAL_CONSOLE_URL = "http://127.0.0.1:3737";
284
+
285
+ /**
286
+ * Resolve one of `console-presets.sh`'s bash functions (by name) against the
287
+ * source root, so the interactive wizard's printed defaults are never a
288
+ * second hardcoded literal competing with the one bash source of truth
289
+ * (itself overridable via FLOW_AGENTS_KONTOUR_CLOUD_CONSOLE_URL /
290
+ * FLOW_AGENTS_LOCAL_KONTOUR_CONSOLE_URL, depending on which function is
291
+ * named). `fallback` is only a defensive floor for the rare case the
292
+ * subprocess produces no output (e.g. an unreadable source tree) -- it is not
293
+ * a second default.
294
+ */
295
+ function presetConsoleUrl(bashFnName: string, fallback: string): string {
296
+ const scriptPath = path.join(root, "scripts", "telemetry", "console-presets.sh");
297
+ const result = spawnSync("bash", ["-c", `source "${scriptPath}" && ${bashFnName}`], { encoding: "utf8" });
298
+ const stdout = (result.stdout ?? "").trim();
299
+ return stdout || fallback;
300
+ }
301
+
143
302
  async function questionHidden(prompt: string): Promise<string> {
144
303
  if (!process.stdin.isTTY) return "";
145
304
  return await new Promise((resolve) => {
@@ -178,12 +337,15 @@ function defaultDest(runtime: Runtime): string {
178
337
  return process.cwd();
179
338
  }
180
339
 
181
- function globalDest(runtime: Runtime): string {
340
+ export function globalDest(runtime: Runtime): string {
341
+ // Shared with detectRuntimeFromFilesystem so the "where is runtime X's global
342
+ // config" path logic is defined exactly once.
343
+ const candidates = runtimeGlobalConfigCandidates(os.homedir(), process.env);
182
344
  if (runtime === "claude-code") {
183
345
  // Honor FLOW_AGENTS_USER_CLAUDE_SETTINGS for test isolation (same as checkScopeCollision).
184
346
  const override = process.env["FLOW_AGENTS_USER_CLAUDE_SETTINGS"];
185
347
  if (override) return path.dirname(override);
186
- return path.join(os.homedir(), ".claude");
348
+ return candidates["claude-code"];
187
349
  }
188
350
  if (runtime === "opencode") {
189
351
  // Honor FLOW_AGENTS_USER_OPENCODE_CONFIG (points to the opencode.json FILE) for test isolation,
@@ -191,11 +353,15 @@ function globalDest(runtime: Runtime): string {
191
353
  const override = process.env["FLOW_AGENTS_USER_OPENCODE_CONFIG"];
192
354
  if (override) return path.dirname(override);
193
355
  // Global opencode config: ~/.config/opencode/ (honor XDG_CONFIG_HOME when set, else ~/.config).
194
- return path.join(process.env["XDG_CONFIG_HOME"] ?? path.join(os.homedir(), ".config"), "opencode");
356
+ return candidates.opencode;
195
357
  }
196
358
  if (runtime === "codex") {
197
359
  // codex --global routes to the standard Codex home. --dest remains an explicit override.
198
- return defaultCodexHome();
360
+ // `candidates.codex` is built from the same `CODEX_HOME || ~/.codex` resolution as
361
+ // `defaultCodexHome()` (see runtimeGlobalConfigCandidates above), so this is byte-identical
362
+ // to calling `defaultCodexHome()` directly while keeping the candidate map the single
363
+ // source of truth for "where does runtime X's global config live".
364
+ return candidates.codex;
199
365
  }
200
366
  if (runtime === "pi") {
201
367
  // pi has no documented global config dir.
@@ -205,44 +371,109 @@ function globalDest(runtime: Runtime): string {
205
371
  return defaultDest(runtime);
206
372
  }
207
373
 
208
- function parseYesNo(value: string, fallback: boolean): boolean {
209
- const normalized = value.trim().toLowerCase();
210
- if (!normalized) return fallback;
211
- if (["y", "yes", "true", "1"].includes(normalized)) return true;
212
- if (["n", "no", "false", "0"].includes(normalized)) return false;
213
- return fallback;
374
+ type ConsoleConnectGatherResult = {
375
+ telemetrySinks: TelemetrySink[];
376
+ consoleUrl: string | undefined;
377
+ consoleTokenValue: string | undefined;
378
+ consoleTenant: string | undefined;
379
+ };
380
+
381
+ /**
382
+ * Console-connect sink/credential gathering for interactiveOptions(),
383
+ * extracted as a pure refactor (identical behavior, identical returned
384
+ * shape) so interactiveOptions() itself stays a thin top-level orchestration.
385
+ * Covers both branches of "did the caller already give --telemetry-sink(s)
386
+ * on argv":
387
+ * - sinkFlagsGiven: the guided wizard is bypassed entirely (same as the old
388
+ * hidden sinkAnswer skip); only whichever of URL/token/tenant was not
389
+ * already supplied via flag gets its own prompt.
390
+ * - not given: the full guided wizard runs, with askWithFlagOverrides /
391
+ * askHiddenWithFlagOverrides short-circuiting any individual sub-prompt
392
+ * whose answer was already supplied via flag -- same flag-wins precedence
393
+ * as the sinkFlagsGiven branch, just applied per-sub-prompt instead of
394
+ * skipping the whole wizard.
395
+ * Flag-wins-over-prompt precedence is unchanged from before this wizard
396
+ * existed.
397
+ */
398
+ async function gatherConsoleConnectOptions(
399
+ rl: ReturnType<typeof createInterface>,
400
+ args: ReturnType<typeof parseArgs>,
401
+ consoleTokenFile: string | undefined,
402
+ ): Promise<ConsoleConnectGatherResult> {
403
+ const sinkFlagsGiven = flagList(args.flags, "telemetry-sink").length > 0 || flagList(args.flags, "telemetry-sinks").length > 0;
404
+ const consoleUrlFlag = flagString(args.flags, "console-url");
405
+ const consoleTenantFlag = flagString(args.flags, "console-tenant") ?? flagString(args.flags, "console-tenant-id");
406
+
407
+ if (sinkFlagsGiven) {
408
+ const telemetrySinks = telemetrySinksFromFlags(args.flags);
409
+ const needsUserHostedUrl = telemetrySinks.includes("user-hosted-console") || telemetrySinks.includes("hosted-kontour-console");
410
+ const consoleUrl = consoleUrlFlag ?? (needsUserHostedUrl ? await rl.question("User-hosted Console URL: ") : undefined);
411
+ const consoleTokenValue = consoleTokenFile ? undefined : (needsConsoleCredentials(telemetrySinks) ? await questionHidden("Console telemetry token (blank to skip): ") : undefined);
412
+ const consoleTenant = consoleTenantFlag ?? (needsConsoleCredentials(telemetrySinks) ? await rl.question("Console tenant ID (blank to skip): ") : undefined);
413
+ return { telemetrySinks, consoleUrl, consoleTokenValue, consoleTenant };
414
+ }
415
+
416
+ const askWithFlagOverrides = async (prompt: string): Promise<string> => {
417
+ if (prompt.includes("Self-hosted Console URL") && consoleUrlFlag !== undefined) return consoleUrlFlag;
418
+ if (prompt.includes("Console tenant ID") && consoleTenantFlag !== undefined) return consoleTenantFlag;
419
+ return rl.question(prompt);
420
+ };
421
+ const askHiddenWithFlagOverrides = async (prompt: string): Promise<string> => {
422
+ if (consoleTokenFile) return "";
423
+ return questionHidden(prompt);
424
+ };
425
+ const wizardResult = await runConsoleConnectWizard(
426
+ { ask: askWithFlagOverrides, askHidden: askHiddenWithFlagOverrides },
427
+ {
428
+ hostedUrl: presetConsoleUrl("flow_agents_kontour_hosted_console_url", DEFAULT_HOSTED_CONSOLE_URL),
429
+ localUrl: presetConsoleUrl("flow_agents_local_kontour_console_url", DEFAULT_LOCAL_CONSOLE_URL),
430
+ },
431
+ );
432
+ for (const warning of wizardResult.warnings) console.warn(`flow-agents init: ${warning}`);
433
+ return {
434
+ telemetrySinks: wizardResult.telemetrySinks,
435
+ consoleUrl: consoleUrlFlag ?? wizardResult.consoleUrl,
436
+ consoleTokenValue: consoleTokenFile ? undefined : wizardResult.consoleTokenValue,
437
+ consoleTenant: consoleTenantFlag ?? wizardResult.consoleTenant,
438
+ };
214
439
  }
215
440
 
216
441
  async function interactiveOptions(argv: string[]): Promise<InitOptions> {
217
442
  const args = parseArgs(argv);
218
443
  const rl = createInterface({ input, output });
219
444
  try {
220
- const runtimeDefault = normalizeRuntime(flagString(args.flags, "runtime")) ?? "base";
221
- const runtimeAnswer = flagString(args.flags, "runtime") ?? await rl.question(`Runtime [${runtimeDefault}]: `);
222
- const runtime = normalizeRuntime(runtimeAnswer.trim() || runtimeDefault) ?? runtimeDefault;
445
+ const explicitRuntimeFlag = flagString(args.flags, "runtime");
446
+ const runtimeDefault = normalizeRuntime(explicitRuntimeFlag) ?? detectDefaultRuntime();
447
+ const runtimeAnswer = explicitRuntimeFlag ?? await rl.question(`Runtime [${runtimeDefault}]: `);
448
+ const runtimeAnswerTrimmed = runtimeAnswer.trim();
449
+ const runtime = normalizeRuntime(runtimeAnswerTrimmed || runtimeDefault) ?? runtimeDefault;
450
+ // "(auto-detected)" is only accurate when the runtime was neither an
451
+ // explicit --runtime flag nor a typed interactive answer AND the layered
452
+ // detector actually matched a signal (not merely defaulted to "base").
453
+ const runtimeAutoDetected = !explicitRuntimeFlag && !runtimeAnswerTrimmed && detectedRuntimeSignal() === runtime;
223
454
  const isGlobal = flagBool(args.flags, "global");
224
455
  const destBase = isGlobal ? globalDest(runtime) : defaultDest(runtime);
225
456
  const destDefault = flagString(args.flags, "dest", destBase) ?? destBase;
226
457
  const destAnswer = flagString(args.flags, "dest") ?? await rl.question(`Install destination [${destDefault}]: `);
227
- const sinkDefault = telemetrySinksFromFlags(args.flags);
228
- const sinkAnswer = flagList(args.flags, "telemetry-sink").length || flagList(args.flags, "telemetry-sinks").length
229
- ? sinkDefault.join(",")
230
- : await rl.question(`Telemetry sinks [${sinkDefault.join(",")}]: `);
231
- const telemetrySinks = telemetrySinksFromValues([sinkAnswer || sinkDefault.join(",")]);
232
- const needsUserHostedUrl = telemetrySinks.includes("user-hosted-console") || telemetrySinks.includes("hosted-kontour-console");
233
- const consoleUrl = flagString(args.flags, "console-url") ?? (needsUserHostedUrl ? await rl.question("User-hosted Console URL: ") : undefined);
458
+
459
+ // Guided "Connect to Kontour Console?" wizard (Hosted/Local/Self-hosted/Skip)
460
+ // replaces the old hidden sink-keyword prompt -- see gatherConsoleConnectOptions
461
+ // for the flag-override / sink-flags-given branching.
234
462
  const consoleEndpoint = flagString(args.flags, "console-endpoint") ?? flagString(args.flags, "console-endpoint-url");
235
463
  const consoleTokenFile = flagString(args.flags, "console-token-file");
236
- const consoleTokenValue = consoleTokenFile ? undefined : (needsConsoleCredentials(telemetrySinks) ? await questionHidden("Console telemetry token (blank to skip): ") : undefined);
237
- const consoleTenant = flagString(args.flags, "console-tenant") ?? flagString(args.flags, "console-tenant-id") ?? (needsConsoleCredentials(telemetrySinks) ? await rl.question("Console tenant ID (blank to skip): ") : undefined);
238
- const activateDefault = false;
239
- const activateAnswer = flagBool(args.flags, "activate-kits")
240
- ? "yes"
241
- : runtime === "codex"
242
- ? await rl.question(`Activate local Builder Kit runtime assets [${activateDefault ? "Y/n" : "y/N"}]: `)
243
- : "no";
464
+ const { telemetrySinks, consoleUrl, consoleTokenValue, consoleTenant } = await gatherConsoleConnectOptions(rl, args, consoleTokenFile);
465
+
466
+ const activeKitIds = selectedKitIdsFromFlags(args.flags);
467
+ if (!activeKitIds.length && !flagBool(args.flags, "activate-kits") && !flagList(args.flags, "activate-kit").length) {
468
+ const available = catalogKitIds();
469
+ if (available.length) {
470
+ const answer = await rl.question(`Activate kits by id (comma-separated, default none) [none]: `);
471
+ activeKitIds.push(...answer.split(",").map((id) => id.trim()).filter((id) => available.includes(id)));
472
+ }
473
+ }
244
474
  return {
245
475
  runtime,
476
+ runtimeAutoDetected,
246
477
  global: isGlobal,
247
478
  dest: path.resolve(destAnswer.trim() || destDefault),
248
479
  consoleUrl: consoleUrl?.trim() || undefined,
@@ -251,7 +482,8 @@ async function interactiveOptions(argv: string[]): Promise<InitOptions> {
251
482
  consoleTokenValue: consoleTokenValue?.trim() || undefined,
252
483
  consoleTenant: consoleTenant?.trim() || undefined,
253
484
  telemetrySinks,
254
- activateKits: runtime === "codex" && parseYesNo(activateAnswer, activateDefault),
485
+ activateKits: activeKitIds.length > 0,
486
+ activeKitIds,
255
487
  };
256
488
  } finally {
257
489
  rl.close();
@@ -260,7 +492,7 @@ async function interactiveOptions(argv: string[]): Promise<InitOptions> {
260
492
 
261
493
  function headlessOptions(argv: string[]): InitOptions {
262
494
  const args = parseArgs(argv);
263
- const runtime = normalizeRuntime(flagString(args.flags, "runtime")) ?? "base";
495
+ const runtime = normalizeRuntime(flagString(args.flags, "runtime")) ?? detectDefaultRuntime();
264
496
  const isGlobal = flagBool(args.flags, "global");
265
497
  const destBase = isGlobal ? globalDest(runtime) : defaultDest(runtime);
266
498
  return {
@@ -272,11 +504,12 @@ function headlessOptions(argv: string[]): InitOptions {
272
504
  consoleTokenFile: flagString(args.flags, "console-token-file"),
273
505
  consoleTenant: flagString(args.flags, "console-tenant") ?? flagString(args.flags, "console-tenant-id"),
274
506
  telemetrySinks: telemetrySinksFromFlags(args.flags),
275
- activateKits: runtime === "codex" && flagBool(args.flags, "activate-kits"),
507
+ activateKits: selectedKitIdsFromFlags(args.flags).length > 0,
508
+ activeKitIds: selectedKitIdsFromFlags(args.flags),
276
509
  };
277
510
  }
278
511
 
279
- function ensureBundle(runtime: Runtime): string {
512
+ export function ensureBundle(runtime: Runtime): string {
280
513
  const bundle = path.join(root, "dist", runtimeBundles[runtime]);
281
514
  if (!fs.existsSync(path.join(bundle, "install.sh"))) {
282
515
  const rc = buildBundles();
@@ -297,6 +530,35 @@ function ensureBundle(runtime: Runtime): string {
297
530
  const GLOBAL_INSTALL_PROJECT_DIR_PREFIX = /root="\$\{CLAUDE_PROJECT_DIR:-\$\(pwd\)\}";\s*/g;
298
531
  const GLOBAL_INSTALL_PROJECT_DIR_VAR = /"\$root\//g;
299
532
 
533
+ type InstallMergeConflict = {
534
+ path: string;
535
+ existingValue: unknown;
536
+ managedValue: unknown;
537
+ };
538
+
539
+ type MergeSettingsFn = (
540
+ existing: Record<string, unknown>,
541
+ managed: Record<string, unknown>,
542
+ options?: { onConflict?: (conflict: InstallMergeConflict) => void },
543
+ ) => Record<string, unknown>;
544
+
545
+ function warnInstallMergeConflicts(conflicts: InstallMergeConflict[]): void {
546
+ for (const conflict of conflicts) {
547
+ console.warn(`install-merge: conflict: preserving existing setting '${conflict.path}' and not applying Flow Agents managed value`);
548
+ }
549
+ }
550
+
551
+ function mergeInstallSettings(
552
+ mergeSettings: MergeSettingsFn,
553
+ existing: Record<string, unknown>,
554
+ managed: Record<string, unknown>,
555
+ ): Record<string, unknown> {
556
+ const conflicts: InstallMergeConflict[] = [];
557
+ const merged = mergeSettings(existing, managed, { onConflict: (conflict) => conflicts.push(conflict) });
558
+ warnInstallMergeConflicts(conflicts);
559
+ return merged;
560
+ }
561
+
300
562
  function rewriteCommandForGlobalInstall(command: string, sourceRoot: string): string {
301
563
  return command
302
564
  .replace(GLOBAL_INSTALL_PROJECT_DIR_PREFIX, "")
@@ -379,8 +641,9 @@ function installBundle(bundle: string, options: InitOptions): number {
379
641
  }
380
642
 
381
643
  function activateKits(options: InitOptions): number {
382
- if (!options.activateKits) return 0;
383
- const result = activateCodexLocal(options.dest, options.dest);
644
+ const activeKitIds = options.activeKitIds ?? [];
645
+ if (!options.activateKits || activeKitIds.length === 0 || options.runtime !== "codex") return 0;
646
+ const result = activateCodexLocal(options.dest, options.dest, { kitIdFilter: activeKitIds });
384
647
  if (Array.isArray(result.errors) && result.errors.length > 0) {
385
648
  console.error(JSON.stringify(result, null, 2));
386
649
  return 1;
@@ -390,6 +653,72 @@ function activateKits(options: InitOptions): number {
390
653
  return 0;
391
654
  }
392
655
 
656
+ /**
657
+ * Headless counterpart of interactiveOptions()'s inline `runtimeAutoDetected`
658
+ * computation. headlessOptions() itself is untouched (see its doc note) --
659
+ * this is computed separately in main() and merged onto its result, so the
660
+ * "(auto-detected)" annotation applies to both install paths without adding
661
+ * a new required prompt or changing headlessOptions()'s own behavior.
662
+ */
663
+ function headlessRuntimeAutoDetected(argv: string[]): boolean {
664
+ const args = parseArgs(argv);
665
+ if (flagString(args.flags, "runtime")) return false;
666
+ return detectedRuntimeSignal() !== "unknown";
667
+ }
668
+
669
+ /**
670
+ * G2 auto-verify + G3 post-install summary, shared by both the headless and
671
+ * interactive install paths (applies identically regardless of which one
672
+ * reached main()'s successful non-global install tail). Calls
673
+ * telemetry-doctor.ts's buildReport() in-process against the endpoint
674
+ * install-console-config.sh just wrote. Never allowed to change the exit
675
+ * code or hang the install: buildReport already has its own bounded default
676
+ * reachability timeout (2000ms unless overridden), and any thrown/rejected
677
+ * error here is caught and downgraded to a warning line only.
678
+ */
679
+ async function printPostInstallSummary(options: InitOptions): Promise<void> {
680
+ let doctorConsole: {
681
+ sink: "local-only" | "console";
682
+ reachability: { checked: boolean; ok: boolean | null; error?: string; statusCode?: number };
683
+ } = { sink: "local-only", reachability: { checked: false, ok: null } };
684
+ let tokenConfigured = false;
685
+ let tenantConfigured = false;
686
+ try {
687
+ const report = await buildReport(["--dest", options.dest]);
688
+ doctorConsole = report.console;
689
+ tokenConfigured = report.console.tokenConfigured;
690
+ tenantConfigured = report.console.tenantConfigured;
691
+ } catch (error) {
692
+ console.warn(`flow-agents init: WARNING: could not verify the Console connection: ${(error as Error).message} (telemetry still installed; run 'flow-agents telemetry-doctor' to re-check).`);
693
+ }
694
+ const consoleStatus = describeConsoleStatus({ console: doctorConsole });
695
+ const nextSteps = [`Run your agent inside ${options.dest} -- Flow Agents hooks are already wired.`];
696
+ if (consoleStatus.status !== "local-only") {
697
+ nextSteps.push("Telemetry now flows to Kontour Console; visit the Console for ROI/economics views.");
698
+ }
699
+ // Only for the "reachability was never attempted" case (self-hosted/BYO
700
+ // HTTPS hosts default to not-allowed without --allow-network) -- never for
701
+ // local-only (nothing to verify) or the already-verified case (nothing left
702
+ // to do). doctorConsole.reachability.checked is the same raw signal
703
+ // describeConsoleStatus used to pick its NOT_CHECKED_DETAIL branch, so this
704
+ // stays in sync with that classifier without re-parsing its detail string.
705
+ if (consoleStatus.status === "connected-unverified" && doctorConsole.reachability.checked === false) {
706
+ nextSteps.push("Self-hosted/BYO Console reachability was not checked; run `flow-agents telemetry-doctor --allow-network` to verify it.");
707
+ }
708
+ nextSteps.push("Re-run `flow-agents init` anytime to reconfigure runtime, destination, or Console connection.");
709
+ const summaryLines = buildPostInstallSummaryLines({
710
+ runtime: options.runtime,
711
+ runtimeAutoDetected: Boolean(options.runtimeAutoDetected),
712
+ dest: options.dest,
713
+ telemetrySinks: options.telemetrySinks,
714
+ consoleStatus,
715
+ tokenConfigured,
716
+ tenantConfigured,
717
+ nextSteps,
718
+ });
719
+ for (const line of summaryLines) console.log(line);
720
+ }
721
+
393
722
  export async function main(argv = process.argv.slice(2)): Promise<number> {
394
723
  if (argv.includes("--help") || argv.includes("-h")) {
395
724
  usage();
@@ -397,7 +726,9 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
397
726
  }
398
727
  const headless = argv.includes("--yes") || argv.includes("--headless") || !process.stdin.isTTY;
399
728
  try {
400
- const options = headless ? headlessOptions(argv) : await interactiveOptions(argv);
729
+ const options = headless
730
+ ? { ...headlessOptions(argv), runtimeAutoDetected: headlessRuntimeAutoDetected(argv) }
731
+ : await interactiveOptions(argv);
401
732
  // Scope-collision check for claude-code: Claude Code merges user-level
402
733
  // (~/.claude/settings.json) and project-level (.claude/settings.json) settings
403
734
  // and runs ALL matching hooks from both files. If a user-level settings file
@@ -442,12 +773,12 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
442
773
  const destSettingsPath = path.join(options.dest, "settings.json");
443
774
  const installMergePath = path.join(root, "scripts", "install-merge.js");
444
775
  const _require = createRequire(import.meta.url);
445
- const { mergeSettings } = _require(installMergePath) as { mergeSettings: (a: Record<string, unknown>, b: Record<string, unknown>) => Record<string, unknown> };
776
+ const { mergeSettings } = _require(installMergePath) as { mergeSettings: MergeSettingsFn };
446
777
  let existing: Record<string, unknown> = {};
447
778
  if (fs.existsSync(destSettingsPath)) {
448
779
  try { existing = JSON.parse(fs.readFileSync(destSettingsPath, "utf8")) as Record<string, unknown>; } catch { existing = {}; }
449
780
  }
450
- const merged = mergeSettings(existing, managed);
781
+ const merged = mergeInstallSettings(mergeSettings, existing, managed);
451
782
  const tmp = `${destSettingsPath}.tmp.${process.pid}`;
452
783
  fs.writeFileSync(tmp, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
453
784
  fs.renameSync(tmp, destSettingsPath);
@@ -456,16 +787,31 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
456
787
  const skillsSync = copyDirMerge(path.join(bundle, ".claude", "skills"), path.join(options.dest, "skills"));
457
788
  const agentsSync = copyDirMerge(path.join(bundle, ".claude", "agents"), path.join(options.dest, "agents"));
458
789
  // Write version stamp.
459
- const recordPath = durableInstallRecordPath(options.dest);
460
- const installRecordDir = path.dirname(recordPath);
461
- fs.mkdirSync(installRecordDir, { recursive: true });
462
- const pkgJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")) as Record<string, string>;
463
- const record = { version: pkgJson["version"] ?? "0.0.0", installedAt: new Date().toISOString(), runtime: "claude-code", global: true };
464
- const recordTmp = `${recordPath}.tmp.${process.pid}`;
465
- fs.writeFileSync(recordTmp, `${JSON.stringify(record, null, 2)}\n`, "utf8");
466
- fs.renameSync(recordTmp, recordPath);
790
+ writeInstallRecord(options.dest, "claude-code", true, options.activeKitIds ?? []);
467
791
  console.log(`Flow Agents global hooks merged for claude-code in ${options.dest}`);
468
792
  console.log(`Synced skills (+${skillsSync.added} new, ~${skillsSync.updated} updated) and agents (+${agentsSync.added} new, ~${agentsSync.updated} updated) in ${options.dest}`);
793
+ // Write a per-skill-file sha256 content-hash manifest, sibling of install.json, so
794
+ // `flow-agents skill-drift-check` and the SessionStart advisory can classify installed
795
+ // skill files as in_sync/kit_updated/user_modified/unbaselined/missing_install/kit_removed
796
+ // without re-deriving a second hashing convention (kontourai/flow-agents#439). This is an
797
+ // additive convenience feature layered on top of the install that already succeeded above
798
+ // (settings merge + skills/agents sync) — a failure writing this manifest must never fail
799
+ // the whole `init --global` run, so it is contained here: warn and continue.
800
+ try {
801
+ const skillDriftLibPath = path.join(root, "scripts", "hooks", "lib", "skill-drift.js");
802
+ const _requireSkillDrift = createRequire(import.meta.url);
803
+ const { buildManifest, writeManifestAtomic } = _requireSkillDrift(skillDriftLibPath) as {
804
+ buildManifest: (params: { skillsSourceDir: string; runtime: string }) => unknown;
805
+ writeManifestAtomic: (manifestPath: string, manifest: unknown) => void;
806
+ };
807
+ const skillsManifest = buildManifest({ skillsSourceDir: path.join(bundle, ".claude", "skills"), runtime: "claude-code" });
808
+ writeManifestAtomic(skillsManifestPath(options.dest), skillsManifest);
809
+ } catch (error) {
810
+ console.error(
811
+ `flow-agents init: WARNING: could not write skills drift-detection manifest: ${(error as Error).message} ` +
812
+ "(drift detection will report unbaselined until the next successful --global sync)."
813
+ );
814
+ }
469
815
  return 0;
470
816
  }
471
817
  // --global for opencode: merge FA opencode.json into the global opencode config dir.
@@ -484,26 +830,18 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
484
830
  const destConfigPath = path.join(options.dest, "opencode.json");
485
831
  const installMergePath = path.join(root, "scripts", "install-merge.js");
486
832
  const _require = createRequire(import.meta.url);
487
- const { mergeSettings } = _require(installMergePath) as { mergeSettings: (a: Record<string, unknown>, b: Record<string, unknown>) => Record<string, unknown> };
833
+ const { mergeSettings } = _require(installMergePath) as { mergeSettings: MergeSettingsFn };
488
834
  let existing: Record<string, unknown> = {};
489
835
  if (fs.existsSync(destConfigPath)) {
490
836
  try { existing = JSON.parse(fs.readFileSync(destConfigPath, "utf8")) as Record<string, unknown>; } catch { existing = {}; }
491
837
  }
492
- const merged = mergeSettings(existing, managed);
838
+ const merged = mergeInstallSettings(mergeSettings, existing, managed);
493
839
  const tmp = `${destConfigPath}.tmp.${process.pid}`;
494
840
  fs.writeFileSync(tmp, `${JSON.stringify(merged, null, 2)}
495
841
  `, "utf8");
496
842
  fs.renameSync(tmp, destConfigPath);
497
843
  // Write version stamp.
498
- const recordPath = durableInstallRecordPath(options.dest);
499
- const installRecordDir = path.dirname(recordPath);
500
- fs.mkdirSync(installRecordDir, { recursive: true });
501
- const pkgJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")) as Record<string, string>;
502
- const record = { version: pkgJson["version"] ?? "0.0.0", installedAt: new Date().toISOString(), runtime: "opencode", global: true };
503
- const recordTmp = `${recordPath}.tmp.${process.pid}`;
504
- fs.writeFileSync(recordTmp, `${JSON.stringify(record, null, 2)}
505
- `, "utf8");
506
- fs.renameSync(recordTmp, recordPath);
844
+ writeInstallRecord(options.dest, "opencode", true, options.activeKitIds ?? []);
507
845
  console.log(`Flow Agents global config merged for opencode in ${options.dest}`);
508
846
  return 0;
509
847
  }
@@ -528,6 +866,7 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
528
866
  }
529
867
  const installed = result.status ?? 1;
530
868
  if (installed !== 0) return installed;
869
+ writeInstallRecord(options.dest, "codex", true, options.activeKitIds ?? []);
531
870
  return activateKits(options);
532
871
  }
533
872
  // --global for pi: NOT_VERIFIED (no documented global dir). Warn and fall through to workspace install.
@@ -541,7 +880,13 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
541
880
  const bundle = ensureBundle(options.runtime);
542
881
  const installed = installBundle(bundle, options);
543
882
  if (installed !== 0) return installed;
544
- return activateKits(options);
883
+ writeInstallRecord(options.dest, options.runtime, options.global, options.activeKitIds ?? []);
884
+ const activated = activateKits(options);
885
+ // G2/G3: shared post-install auto-verify + summary tail. Applies
886
+ // identically whether main() reached here via headlessOptions() or
887
+ // interactiveOptions() -- never changes `activated`'s exit code.
888
+ await printPostInstallSummary(options);
889
+ return activated;
545
890
  } catch (error) {
546
891
  console.error(`flow-agents init: ${(error as Error).message}`);
547
892
  return 2;
@@ -604,12 +949,12 @@ function dogfoodClaudeCode(bundleRoot: string, dest: string): void {
604
949
  // Merge: read existing, strip FA hooks, append new FA hooks, preserve all other keys.
605
950
  const installMergePath = path.join(root, "scripts", "install-merge.js");
606
951
  const _require = createRequire(import.meta.url);
607
- const { mergeSettings } = _require(installMergePath) as { mergeSettings: (a: Record<string, unknown>, b: Record<string, unknown>) => Record<string, unknown> };
952
+ const { mergeSettings } = _require(installMergePath) as { mergeSettings: MergeSettingsFn };
608
953
  let existing: Record<string, unknown> = {};
609
954
  if (fs.existsSync(destSettingsPath)) {
610
955
  try { existing = JSON.parse(fs.readFileSync(destSettingsPath, "utf8")) as Record<string, unknown>; } catch { existing = {}; }
611
956
  }
612
- const merged = mergeSettings(existing, managed);
957
+ const merged = mergeInstallSettings(mergeSettings, existing, managed);
613
958
  const tmp = `${destSettingsPath}.tmp.${process.pid}`;
614
959
  fs.writeFileSync(tmp, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
615
960
  fs.renameSync(tmp, destSettingsPath);
@@ -642,12 +987,12 @@ function dogfoodCodex(bundleRoot: string, dest: string): void {
642
987
  // Merge: read existing, strip FA hook-groups, append new FA hook-groups, preserve user groups.
643
988
  const installMergePath = path.join(root, "scripts", "install-merge.js");
644
989
  const _require = createRequire(import.meta.url);
645
- const { mergeSettings } = _require(installMergePath) as { mergeSettings: (a: Record<string, unknown>, b: Record<string, unknown>) => Record<string, unknown> };
990
+ const { mergeSettings } = _require(installMergePath) as { mergeSettings: MergeSettingsFn };
646
991
  let existing: Record<string, unknown> = {};
647
992
  if (fs.existsSync(destHooksPath)) {
648
993
  try { existing = JSON.parse(fs.readFileSync(destHooksPath, "utf8")) as Record<string, unknown>; } catch { existing = {}; }
649
994
  }
650
- const merged = mergeSettings(existing, managed);
995
+ const merged = mergeInstallSettings(mergeSettings, existing, managed);
651
996
  const tmp = `${destHooksPath}.tmp.${process.pid}`;
652
997
  fs.writeFileSync(tmp, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
653
998
  fs.renameSync(tmp, destHooksPath);