@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
@@ -18,8 +18,9 @@ const fs = require('fs');
18
18
  const path = require('path');
19
19
  const { readLivenessEvents, freshHolders } = require('./lib/liveness-read');
20
20
  const { resolveActor } = require('./lib/actor-identity');
21
- const { flowAgentsArtifactRootsForRead } = require('./lib/local-artifact-paths');
21
+ const { flowAgentsArtifactRootsForRead, resolveSharedRepoRoot, warnIfFailingOpenInsideGitTree } = require('./lib/local-artifact-paths');
22
22
  const { readCurrentPointer } = require('./lib/current-pointer');
23
+ const { workflowTriggersFor } = require('./lib/kit-catalog');
23
24
 
24
25
  const STEERING = {
25
26
  'tool-planner': [
@@ -71,14 +72,36 @@ const ACTIVE_STATE_STATUSES = new Set([
71
72
  ]);
72
73
 
73
74
  function findRepoRoot(startDir) {
74
- let dir = path.resolve(startDir || process.cwd());
75
+ const resolvedStartDir = path.resolve(startDir || process.cwd());
76
+ // #357: prefer git's SHARED common-dir root (the primary checkout's repo root, resolved
77
+ // identically from any linked worktree) before falling back to the plain `.git`/AGENTS.md
78
+ // ancestor walk below. Without this, a worktree's OWN `.git` FILE (not the shared `.git`
79
+ // DIRECTORY) satisfies `fs.existsSync(path.join(dir, '.git'))` at the very first directory
80
+ // checked, so the walk always stopped at the worktree root instead of resolving to the
81
+ // shared primary checkout — see src/lib/local-artifact-root.ts's resolveSharedRepoRoot doc
82
+ // comment for the full rationale. Fails open to the walk below (unchanged) when git is
83
+ // unavailable, `resolvedStartDir` is not inside a git working tree, or the command fails —
84
+ // this is also exactly today's behavior in the single-checkout case, where
85
+ // `--git-common-dir` resolves to `.git` under `resolvedStartDir` itself.
86
+ const sharedRoot = resolveSharedRepoRoot(resolvedStartDir);
87
+ if (sharedRoot) return sharedRoot;
88
+ let dir = resolvedStartDir;
75
89
  const root = path.parse(dir).root;
76
90
  for (let depth = 0; dir && depth < 40; depth++) {
77
- if (fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, 'AGENTS.md'))) return dir;
91
+ if (fs.existsSync(path.join(dir, '.git'))) {
92
+ // #413 iteration-2 Fix 1: resolveSharedRepoRoot failed above even though a `.git` entry
93
+ // exists right here — git resolution was ATTEMPTED and FAILED (corrupted gitlink, bad
94
+ // GIT_DIR, git unavailable, etc.), not merely "no git repo at all". Loud, not silent —
95
+ // see warnIfFailingOpenInsideGitTree's own doc comment for the full rationale. This walk
96
+ // still returns `dir` (the ancestor-walk fallback is preserved unchanged) after warning.
97
+ warnIfFailingOpenInsideGitTree(resolvedStartDir, dir);
98
+ return dir;
99
+ }
100
+ if (fs.existsSync(path.join(dir, 'AGENTS.md'))) return dir;
78
101
  if (dir === root) break;
79
102
  dir = path.dirname(dir);
80
103
  }
81
- return path.resolve(startDir || process.cwd());
104
+ return resolvedStartDir;
82
105
  }
83
106
 
84
107
  function walkStateFiles(dir, out = []) {
@@ -244,6 +267,9 @@ function stateSteering(root) {
244
267
  `STATE: ${state.task_slug || path.basename(path.dirname(current.file))} is status:${state.status} phase:${state.phase}.`,
245
268
  ];
246
269
  if (next.summary) parts.push(`Recorded next_action.summary: "${safeStateText(next.summary)}"`);
270
+ if (Array.isArray(next.skills) && next.skills.length) parts.push(`Required skills: ${next.skills.map(skill => safeStateText(skill, 80)).join(' -> ')}.`);
271
+ if (Array.isArray(next.operations) && next.operations.length) parts.push(`Required operations: ${next.operations.map(operation => safeStateText(operation, 80)).join(' -> ')}.`);
272
+ if (next.command) parts.push(`Run: ${safeStateText(next.command, 240)}`);
247
273
  if (next.target_phase) parts.push(`Target phase: ${safeStateText(next.target_phase, 80)}.`);
248
274
  if (next.status === 'needs_user' || state.status === 'needs_decision' || state.status === 'not_verified') {
249
275
  parts.push('Do not deliver as complete until the user decision or accepted gap is recorded.');
@@ -265,6 +291,52 @@ function contextMapSteering(root) {
265
291
  ].join(' ');
266
292
  }
267
293
 
294
+ /**
295
+ * Compose the SessionStart advisory line for installed-skill drift (#439). Fires only inside a
296
+ * kit-bearing checkout (repo has `kits/` AND a built `dist/claude-code/.claude/skills` bundle) —
297
+ * never triggers a build itself, staying read-only and cheap on every SessionStart. Returns ''
298
+ * (never throws past this function) in every other case: not kit-bearing, no manifest yet, no
299
+ * drift found, or any internal error.
300
+ *
301
+ * Reuses the SINGLE shared classification choke point, `scripts/hooks/lib/skill-drift.js`'s
302
+ * `compareSkillDrift()` — this function must never hand-roll its own drift comparison (same
303
+ * convention the CLI check and the manifest writer follow, see #439 plan).
304
+ *
305
+ * @param {string} root Repository root
306
+ * @returns {string}
307
+ */
308
+ function skillDriftSteering(root) {
309
+ try {
310
+ if (!fs.existsSync(path.join(root, 'kits'))) return '';
311
+ if (!fs.existsSync(path.join(root, 'dist', 'claude-code', '.claude', 'skills'))) return '';
312
+
313
+ const { loadManifest, compareSkillDrift, resolveClaudeGlobalSkillsDir } = require('./lib/skill-drift');
314
+
315
+ const destDir = resolveClaudeGlobalSkillsDir(process.env);
316
+ // Sibling layout: <destDir>/.flow-agents/skills-manifest.json — mirrors
317
+ // src/lib/local-artifact-root.ts's durableFlowAgentsRoot(destDir)/skillsManifestPath exactly
318
+ // (durableFlowAgentsRoot(cwd) resolves to `${cwd}/.flow-agents`, no parent traversal).
319
+ const manifestPath = path.join(destDir, '.flow-agents', 'skills-manifest.json');
320
+ const manifest = loadManifest(manifestPath);
321
+
322
+ const installedDir = path.join(destDir, 'skills');
323
+ const kitSourceDir = path.join(root, 'dist', 'claude-code', '.claude', 'skills');
324
+ const report = compareSkillDrift({ installedDir, kitSourceDir, manifest });
325
+ if (!report.hasDrift) return '';
326
+
327
+ const { kitUpdated, userModified, unbaselined, missingInstall, kitRemoved } = report.summary;
328
+ // kit_removed (#439 review fix) folds into staleCount alongside the other non-in_sync
329
+ // states — this advisory line only ever surfaces kit-updated/user-modified counts by name
330
+ // (see the template string below), so kit_removed does not get its own named count here; it
331
+ // is still reflected in the total. Full per-state detail is always available via
332
+ // `flow-agents skill-drift-check`.
333
+ const staleCount = kitUpdated + userModified + unbaselined + missingInstall + kitRemoved;
334
+ return `[SKILL DRIFT] ${staleCount} installed Claude Code skill file(s) are stale vs this kit (${kitUpdated} kit-updated, ${userModified} user-modified). Run \`flow-agents init --runtime claude-code --global\` to refresh (user-modified files are reported, not overwritten). See \`flow-agents skill-drift-check\` for details.`;
335
+ } catch {
336
+ return '';
337
+ }
338
+ }
339
+
268
340
  function promptText(input) {
269
341
  const candidates = [
270
342
  input && input.prompt,
@@ -279,7 +351,7 @@ function promptText(input) {
279
351
  return '';
280
352
  }
281
353
 
282
- function looksLikeBuilderWork(text) {
354
+ function looksLikeImplementationWork(text) {
283
355
  const normalized = safeStateText(text, 2000).toLowerCase();
284
356
  if (!normalized) return false;
285
357
  const readOnlyIntent = /\b(read[- ]only|review[- ]only|no (source |code |file )?(changes?|edits?|modifications?)|do not (modify|edit|change|write|fix|implement|touch)|don't (modify|edit|change|write|fix|implement|touch)|dont (modify|edit|change|write|fix|implement|touch))\b/.test(normalized);
@@ -298,17 +370,29 @@ function looksLikeBuilderWork(text) {
298
370
  return hasWorkVerb || (hasWorkObject && hasRequestLanguage);
299
371
  }
300
372
 
301
- function builderWorkflowSteering(input) {
302
- if (!looksLikeBuilderWork(promptText(input))) return '';
303
- return [
304
- 'BUILDER WORKFLOW ROUTE: this user prompt looks like coding/build work.',
305
- 'Builder lifecycle: shape raw ideas -> build selected work -> publish verified branch/PR -> learn correction feedback.',
306
- 'Before source edits or implementation commands, activate Builder Kit delivery workflow.',
307
- 'If the user explicitly requested TDD, activate `tdd-workflow`; otherwise activate `deliver` and keep the session on `builder.build`.',
308
- 'Use `npm run workflow:sidecar -- ensure-session --flow-id builder.build ...` when the repo provides the sidecar writer.',
309
- 'After local verification, continue to publish/release-readiness and learning-review; do not treat local verification as terminal delivery.',
310
- 'Do not bypass plan-work -> execute-plan -> review-work -> verify-work for coding tasks; direct implementation is only acceptable when the user explicitly asks for no workflow or explanation-only help.',
311
- ].join(' ');
373
+ function looksLikeKnowledgeWork(text) {
374
+ const normalized = safeStateText(text, 2000).toLowerCase();
375
+ if (!normalized) return false;
376
+ return /\b(remember|capture|save|file|bookmark)\s+(this|that|the|my|our)\b/.test(normalized) ||
377
+ /\bcapture\s+(this\s+)?(decision|learning|lesson|note|context)\b/.test(normalized) ||
378
+ /\bwhat did we learn about\b/.test(normalized);
379
+ }
380
+
381
+ function kitWorkflowSteering(input, root) {
382
+ const prompt = promptText(input);
383
+ const categories = [];
384
+ if (looksLikeImplementationWork(prompt)) categories.push('implementation-work-detected');
385
+ if (looksLikeKnowledgeWork(prompt)) categories.push('knowledge-capture-detected');
386
+ if (!categories.length) return '';
387
+ try {
388
+ return categories
389
+ .flatMap(category => workflowTriggersFor(root, category))
390
+ .map(trigger => trigger.steering)
391
+ .filter(Boolean)
392
+ .join('\n');
393
+ } catch {
394
+ return '';
395
+ }
312
396
  }
313
397
 
314
398
  /**
@@ -341,6 +425,13 @@ function resumeSteering(root, current) {
341
425
  // Full next action (240-char display path, not the 80-char normalization)
342
426
  const nextSummary = next.summary ? safeStateText(next.summary, 240) : 'none';
343
427
  lines.push(`Next action: ${nextSummary}`);
428
+ if (Array.isArray(next.skills) && next.skills.length) {
429
+ lines.push(`Required skills: ${next.skills.map(skill => safeStateText(skill, 80)).join(' -> ')}`);
430
+ }
431
+ if (Array.isArray(next.operations) && next.operations.length) {
432
+ lines.push(`Required operations: ${next.operations.map(operation => safeStateText(operation, 80)).join(' -> ')}`);
433
+ }
434
+ if (next.command) lines.push(`Run: ${safeStateText(next.command, 240)}`);
344
435
 
345
436
  // Plan artifact path
346
437
  let planPath = 'not found';
@@ -505,9 +596,9 @@ function run(rawInput) {
505
596
  }
506
597
 
507
598
  if (event === 'UserPromptSubmit') {
508
- const builderHint = builderWorkflowSteering(input);
509
- if (builderHint) {
510
- hints.push(builderHint);
599
+ const kitHint = kitWorkflowSteering(input, root);
600
+ if (kitHint) {
601
+ hints.push(kitHint);
511
602
  const contextHint = contextMapSteering(root);
512
603
  if (contextHint) hints.push(contextHint);
513
604
  }
@@ -528,6 +619,14 @@ function run(rawInput) {
528
619
  if (supersessionHint) hints.push(supersessionHint);
529
620
  }
530
621
 
622
+ // SessionStart only, unconditional of `current` (#439): the installed-skill drift advisory
623
+ // fires on every SessionStart inside a kit-bearing checkout, independent of whether an active
624
+ // workflow session exists — do NOT fold this into the `current`-gated block above.
625
+ if (event === 'SessionStart') {
626
+ const driftHint = skillDriftSteering(root);
627
+ if (driftHint) hints.push(driftHint);
628
+ }
629
+
531
630
  if (shouldAppendWorkflowContext) {
532
631
  const stateHint = stateSteering(root);
533
632
  if (stateHint) hints.push(stateHint);
@@ -558,6 +657,7 @@ module.exports = {
558
657
  stateSteering,
559
658
  critiqueSteering,
560
659
  contextMapSteering,
660
+ skillDriftSteering,
561
661
  latestWorkflowState,
562
662
  findRepoRoot,
563
663
  safeStateText,
@@ -565,6 +665,6 @@ module.exports = {
565
665
  resumeSteering,
566
666
  supersessionSteering,
567
667
  promptText,
568
- looksLikeBuilderWork,
569
- builderWorkflowSteering,
668
+ looksLikeImplementationWork,
669
+ kitWorkflowSteering,
570
670
  };
@@ -51,6 +51,41 @@ while [[ $# -gt 0 ]]; do
51
51
  esac
52
52
  done
53
53
  REAL_CODEX_HOME="${CODEX_REAL_HOME:-$HOME/.codex}"
54
+
55
+ canonicalize_path() {
56
+ node - "$1" <<'NODE'
57
+ const fs = require("node:fs");
58
+ const path = require("node:path");
59
+ let current = path.resolve(process.argv[2]);
60
+ const missing = [];
61
+ while (!fs.existsSync(current)) {
62
+ missing.unshift(path.basename(current));
63
+ const parent = path.dirname(current);
64
+ if (parent === current) break;
65
+ current = parent;
66
+ }
67
+ process.stdout.write(path.resolve(fs.realpathSync(current), ...missing));
68
+ NODE
69
+ }
70
+
71
+ [[ ! -L "$DEST" ]] || { echo "install-codex-home.sh: refusing symlink destination root: $DEST" >&2; exit 1; }
72
+ ROOT_REAL="$(canonicalize_path "$ROOT_DIR")"
73
+ BUNDLE_SOURCE_REAL="$(canonicalize_path "$ROOT_DIR/dist/codex")"
74
+ DEST_REAL="$(canonicalize_path "$DEST")"
75
+ REAL_CODEX_HOME_REAL="$(canonicalize_path "$REAL_CODEX_HOME")"
76
+ case "$DEST_REAL/" in
77
+ "$ROOT_REAL/"*|"$BUNDLE_SOURCE_REAL/"*)
78
+ echo "install-codex-home.sh: destination overlaps Flow Agents source: $DEST_REAL" >&2
79
+ exit 1
80
+ ;;
81
+ esac
82
+ case "$ROOT_REAL/" in
83
+ "$DEST_REAL/"*)
84
+ echo "install-codex-home.sh: Flow Agents source overlaps destination: $DEST_REAL" >&2
85
+ exit 1
86
+ ;;
87
+ esac
88
+
54
89
  if command -v npm >/dev/null 2>&1; then
55
90
  (cd "$ROOT_DIR" && FLOW_AGENTS_EXPORT_DIAGNOSTICS=0 npm run build:bundles --silent >/dev/null)
56
91
  else
@@ -59,7 +94,8 @@ else
59
94
  fi
60
95
 
61
96
  mkdir -p "$DEST"
62
- DEST_REAL="$(cd "$DEST" && pwd -P)"
97
+ DEST="$(cd "$DEST" && pwd -P)"
98
+ DEST_REAL="$DEST"
63
99
 
64
100
  assert_safe_dest_path() {
65
101
  local rel="$1"
@@ -95,10 +131,17 @@ if [[ -f "$DEST/hooks.json" ]]; then
95
131
  cp "$DEST/hooks.json" "$FA_USER_HOOKS_STASH"
96
132
  fi
97
133
 
98
- # A real Codex home can contain user-owned config, profiles, auth, hooks, and
99
- # locally installed kits. Update Flow Agents-owned bundle trees in place without
100
- # deleting that state. `kits/local` is user/runtime state; everything else under
101
- # the generated bundle paths is managed by this installer.
134
+ # A real Codex home can contain user-owned files in every shared directory.
135
+ # Prepare an exact Flow Agents overlay, then synchronize only files recorded in
136
+ # the ownership manifest. The synchronizer never uses directory-wide --delete:
137
+ # it removes only unchanged files from its prior manifest and refuses collisions.
138
+ FA_OWNED_OVERLAY="$(mktemp -d /tmp/fa-codex-overlay.XXXXXX)"
139
+ cleanup_install_temps() {
140
+ rm -rf "$FA_OWNED_OVERLAY"
141
+ [[ -z "${FA_USER_HOOKS_STASH:-}" ]] || rm -f "$FA_USER_HOOKS_STASH"
142
+ }
143
+ trap cleanup_install_temps EXIT
144
+
102
145
  for managed_dir in \
103
146
  .flow-agents \
104
147
  agent-cards \
@@ -115,52 +158,65 @@ for managed_dir in \
115
158
  do
116
159
  if [[ -d "$ROOT_DIR/dist/codex/$managed_dir" ]]; then
117
160
  assert_safe_dest_path "$managed_dir"
118
- mkdir -p "$DEST/$managed_dir"
119
- rsync -a --delete "$ROOT_DIR/dist/codex/$managed_dir/" "$DEST/$managed_dir/"
161
+ mkdir -p "$FA_OWNED_OVERLAY/$managed_dir"
162
+ rsync -a "$ROOT_DIR/dist/codex/$managed_dir/" "$FA_OWNED_OVERLAY/$managed_dir/"
120
163
  fi
121
164
  done
122
165
 
123
166
  # Skills are user-extensible in a real Codex home. Merge both bundle skill
124
167
  # layers into the flattened destination without deleting user-owned skills.
125
168
  if [[ -d "$ROOT_DIR/dist/codex/skills" ]]; then
126
- assert_safe_dest_path "skills"
127
- mkdir -p "$DEST/skills"
128
- rsync -a "$ROOT_DIR/dist/codex/skills/" "$DEST/skills/"
169
+ mkdir -p "$FA_OWNED_OVERLAY/skills"
170
+ rsync -a "$ROOT_DIR/dist/codex/skills/" "$FA_OWNED_OVERLAY/skills/"
129
171
  fi
130
172
 
131
173
  if [[ -d "$ROOT_DIR/dist/codex/.codex/skills" ]]; then
132
- assert_safe_dest_path "skills"
133
- mkdir -p "$DEST/skills"
134
- rsync -a "$ROOT_DIR/dist/codex/.codex/skills/" "$DEST/skills/"
174
+ mkdir -p "$FA_OWNED_OVERLAY/skills"
175
+ rsync -a "$ROOT_DIR/dist/codex/.codex/skills/" "$FA_OWNED_OVERLAY/skills/"
135
176
  fi
136
177
 
137
178
  if [[ -d "$ROOT_DIR/dist/codex/kits" ]]; then
138
- assert_safe_dest_path "kits"
139
- mkdir -p "$DEST/kits"
140
- rsync -a --delete --exclude 'local/' "$ROOT_DIR/dist/codex/kits/" "$DEST/kits/"
179
+ mkdir -p "$FA_OWNED_OVERLAY/kits"
180
+ rsync -a --exclude 'local/' "$ROOT_DIR/dist/codex/kits/" "$FA_OWNED_OVERLAY/kits/"
141
181
  fi
142
182
 
143
183
  # Agents are user-extensible in a real Codex home. Merge generated agents
144
184
  # without deleting user-owned agents.
145
185
  if [[ -d "$ROOT_DIR/dist/codex/.codex/agents" ]]; then
146
- assert_safe_dest_path "agents"
147
- mkdir -p "$DEST/agents"
148
- rsync -a "$ROOT_DIR/dist/codex/.codex/agents/" "$DEST/agents/"
186
+ mkdir -p "$FA_OWNED_OVERLAY/agents"
187
+ rsync -a "$ROOT_DIR/dist/codex/.codex/agents/" "$FA_OWNED_OVERLAY/agents/"
149
188
  fi
150
189
 
151
190
  for bundle_file in README.md console.telemetry.json install.sh; do
152
191
  if [[ -f "$ROOT_DIR/dist/codex/$bundle_file" ]]; then
153
- assert_safe_dest_path "$bundle_file"
154
- cp "$ROOT_DIR/dist/codex/$bundle_file" "$DEST/$bundle_file"
192
+ cp "$ROOT_DIR/dist/codex/$bundle_file" "$FA_OWNED_OVERLAY/$bundle_file"
155
193
  fi
156
194
  done
157
195
 
196
+ assert_safe_dest_path ".flow-agents/codex-install-manifest.json"
197
+ node "$ROOT_DIR/scripts/install-owned-files.js" \
198
+ "$FA_OWNED_OVERLAY" "$DEST" ".flow-agents/codex-install-manifest.json"
199
+
200
+ atomic_copy() {
201
+ local source="$1"
202
+ local rel="$2"
203
+ assert_safe_dest_path "$rel"
204
+ local target="$DEST/$rel"
205
+ if [[ -e "$target" && ! -f "$target" ]]; then
206
+ echo "install-codex-home.sh: refusing to replace non-file: $target" >&2
207
+ exit 1
208
+ fi
209
+ local temp
210
+ temp="$(mktemp "$(dirname "$target")/.flow-agents-install.XXXXXX")"
211
+ cp "$source" "$temp"
212
+ mv -f "$temp" "$target"
213
+ }
214
+
158
215
  # Root Codex config/profiles and AGENTS.md may be user-owned in ~/.codex.
159
216
  # Seed Flow Agents defaults only when absent.
160
217
  for seed_file in AGENTS.md; do
161
218
  if [[ -f "$ROOT_DIR/dist/codex/$seed_file" && ! -e "$DEST/$seed_file" ]]; then
162
- assert_safe_dest_path "$seed_file"
163
- cp "$ROOT_DIR/dist/codex/$seed_file" "$DEST/$seed_file"
219
+ atomic_copy "$ROOT_DIR/dist/codex/$seed_file" "$seed_file"
164
220
  fi
165
221
  done
166
222
  generated_profile_files=()
@@ -173,8 +229,7 @@ for seed_source in "$ROOT_DIR/dist/codex/.codex/config.toml" "$ROOT_DIR"/dist/co
173
229
  profile_names+=("${seed_file%.config.toml}")
174
230
  fi
175
231
  if [[ ! -e "$DEST/$seed_file" ]] || grep -q 'Generated from packaging/manifest.json' "$DEST/$seed_file"; then
176
- assert_safe_dest_path "$seed_file"
177
- cp "$seed_source" "$DEST/$seed_file"
232
+ atomic_copy "$seed_source" "$seed_file"
178
233
  fi
179
234
  done
180
235
  for profile_path in "$DEST"/*.config.toml; do
@@ -195,9 +250,8 @@ done
195
250
  rmdir "$DEST/.codex" 2>/dev/null || true
196
251
 
197
252
  for auth_file in auth.json version.json installation_id models_cache.json; do
198
- if [[ "$REAL_CODEX_HOME" != "$DEST" && -f "$REAL_CODEX_HOME/$auth_file" ]]; then
199
- assert_safe_dest_path "$auth_file"
200
- cp "$REAL_CODEX_HOME/$auth_file" "$DEST/$auth_file"
253
+ if [[ "$REAL_CODEX_HOME_REAL" != "$DEST_REAL" && -f "$REAL_CODEX_HOME_REAL/$auth_file" ]]; then
254
+ atomic_copy "$REAL_CODEX_HOME_REAL/$auth_file" "$auth_file"
201
255
  fi
202
256
  done
203
257
 
@@ -212,26 +266,22 @@ chmod 700 "$DEST" 2>/dev/null || true
212
266
  FA_VERSION="$(node -p "require('$ROOT_DIR/package.json').version" 2>/dev/null || echo unknown)"
213
267
  FA_MANAGED_HOOKS="$ROOT_DIR/dist/codex/.codex/hooks.json"
214
268
  if command -v node >/dev/null 2>&1 && [[ -f "$FA_MANAGED_HOOKS" ]]; then
215
- if [[ -n "$FA_USER_HOOKS_STASH" && -f "$FA_USER_HOOKS_STASH" ]]; then
216
- # Merge user's prior hooks (stash) with the current FA managed hooks.
217
- node "$ROOT_DIR/scripts/install-merge.js" \
218
- --config "$FA_USER_HOOKS_STASH" \
219
- --managed-hooks "$FA_MANAGED_HOOKS" \
220
- --version "$FA_VERSION" \
221
- --install-record "$DEST/.flow-agents/install.json" \
222
- --runtime "codex"
223
- # Move the merged result into the destination.
224
- cp "$FA_USER_HOOKS_STASH" "$DEST/hooks.json"
225
- rm -f "$FA_USER_HOOKS_STASH"
226
- else
227
- # No prior user hooks: just write the version stamp (FA hooks are already correct from rsync).
228
- node "$ROOT_DIR/scripts/install-merge.js" \
229
- --config "$DEST/hooks.json" \
230
- --managed-hooks "$FA_MANAGED_HOOKS" \
231
- --version "$FA_VERSION" \
232
- --install-record "$DEST/.flow-agents/install.json" \
233
- --runtime "codex"
269
+ FA_HOOKS_WORK="${FA_USER_HOOKS_STASH:-}"
270
+ if [[ -z "$FA_HOOKS_WORK" ]]; then
271
+ FA_HOOKS_WORK="$(mktemp /tmp/fa-hooks-work.XXXXXX.json)"
272
+ printf '{"hooks":{}}\n' > "$FA_HOOKS_WORK"
234
273
  fi
274
+ FA_INSTALL_RECORD_WORK="$(mktemp /tmp/fa-install-record.XXXXXX.json)"
275
+ node "$ROOT_DIR/scripts/install-merge.js" \
276
+ --config "$FA_HOOKS_WORK" \
277
+ --managed-hooks "$FA_MANAGED_HOOKS" \
278
+ --version "$FA_VERSION" \
279
+ --install-record "$FA_INSTALL_RECORD_WORK" \
280
+ --runtime "codex"
281
+ atomic_copy "$FA_HOOKS_WORK" "hooks.json"
282
+ atomic_copy "$FA_INSTALL_RECORD_WORK" ".flow-agents/install.json"
283
+ rm -f "$FA_HOOKS_WORK" "$FA_INSTALL_RECORD_WORK"
284
+ FA_USER_HOOKS_STASH=""
235
285
  fi
236
286
 
237
287
  if [[ ${#CONSOLE_CONFIG_ARGS[@]} -gt 0 || -n "${FLOW_AGENTS_TELEMETRY_SINK:-}" || -n "${FLOW_AGENTS_TELEMETRY_SINKS:-}" || -n "${FLOW_AGENTS_CONSOLE_URL:-}" || -n "${CONSOLE_TELEMETRY_URL:-}" || -n "${CONSOLE_URL:-}" || -n "${FLOW_AGENTS_CONSOLE_TOKEN_FILE:-}" || -n "${CONSOLE_TELEMETRY_TOKEN_FILE:-}" ]]; then
@@ -23,10 +23,13 @@
23
23
  * flow-agents marker (the COLLISION_MARKER strings from init.ts).
24
24
  * (c) APPEND the current managed hook groups from the bundle.
25
25
  * (d) Preserve ALL other keys (permissions, statusLine, user hooks, auth).
26
+ * Managed non-hook values are added only when absent or when replacing
27
+ * an existing Flow Agents-owned value. Conflicting user-owned values win
28
+ * and are reported to stderr.
26
29
  * (e) Atomic write (write tmp + rename).
27
30
  * (f) Write/update .flow-agents/install.json version stamp.
28
31
  *
29
- * Export: mergeSettings(existing, managed) — pure, testable.
32
+ * Export: mergeSettings(existing, managed, options) — pure, testable.
30
33
  * The managed ownership region is identified purely by statusMessage markers
31
34
  * (cross-runtime, no top-level key needed in settings.json).
32
35
  */
@@ -47,6 +50,33 @@ const FA_MARKERS = [
47
50
  "Capturing Flow Agents command evidence",
48
51
  ];
49
52
 
53
+ const FA_OWNERSHIP_MARKERS = [
54
+ ...FA_MARKERS,
55
+ "flow-agents",
56
+ "Flow Agents",
57
+ ];
58
+
59
+ function stableJson(value) {
60
+ if (value === undefined) return "undefined";
61
+ return JSON.stringify(value);
62
+ }
63
+
64
+ function valuesEqual(a, b) {
65
+ return stableJson(a) === stableJson(b);
66
+ }
67
+
68
+ function valueContainsManagedMarker(value) {
69
+ return FA_OWNERSHIP_MARKERS.some((marker) => stableJson(value).includes(marker));
70
+ }
71
+
72
+ function conflict(path, existingValue, managedValue) {
73
+ return { path, existingValue, managedValue };
74
+ }
75
+
76
+ function emitConflict(onConflict, item) {
77
+ if (typeof onConflict === "function") onConflict(item);
78
+ }
79
+
50
80
  /**
51
81
  * Returns true if a hook-group entry is owned by flow-agents.
52
82
  * A hook-group in Claude Code settings looks like:
@@ -95,16 +125,28 @@ function mergeArrayUnion(a, b) {
95
125
  * @param {unknown} existingPerms @param {unknown} managedPerms
96
126
  * @returns {Record<string, unknown>}
97
127
  */
98
- function mergePermissions(existingPerms, managedPerms) {
128
+ function mergePermissions(existingPerms, managedPerms, options = {}) {
99
129
  const e = existingPerms && typeof existingPerms === "object" ? existingPerms : {};
100
130
  const m = managedPerms && typeof managedPerms === "object" ? managedPerms : {};
101
- const out = Object.assign({}, e, m);
131
+ const out = Object.assign({}, e);
102
132
  for (const listKey of ["allow", "deny", "ask"]) {
103
133
  if (Array.isArray(e[listKey]) || Array.isArray(m[listKey])) {
104
134
  out[listKey] = mergeArrayUnion(e[listKey], m[listKey]);
105
135
  }
106
136
  }
107
- if (e.defaultMode !== undefined) out.defaultMode = e.defaultMode;
137
+ for (const [key, value] of Object.entries(m)) {
138
+ if (["allow", "deny", "ask"].includes(key)) continue;
139
+ if (!(key in e)) {
140
+ out[key] = value;
141
+ continue;
142
+ }
143
+ if (valuesEqual(e[key], value) || valueContainsManagedMarker(e[key])) {
144
+ out[key] = value;
145
+ continue;
146
+ }
147
+ out[key] = e[key];
148
+ emitConflict(options.onConflict, conflict(`permissions.${key}`, e[key], value));
149
+ }
108
150
  return out;
109
151
  }
110
152
 
@@ -117,14 +159,17 @@ function mergePermissions(existingPerms, managedPerms) {
117
159
  *
118
160
  * Returns a new object with:
119
161
  * - All keys from `existing` preserved (permissions, statusLine, auth, etc.)
120
- * - All keys from `managed` merged in (flow-agents owned keys like statusLine, hooks)
162
+ * - Managed keys added when absent, or updated only when the existing value
163
+ * is already Flow Agents-owned.
164
+ * - User-owned managed-key conflicts preserved from `existing` and surfaced
165
+ * through `options.onConflict`.
121
166
  * - For the `hooks` key: user-owned hook groups (non-FA) survive; FA groups are
122
167
  * replaced with the current managed set from `managed`.
123
168
  *
124
169
  * Strategy:
125
170
  * 1. Start with a shallow copy of `existing` (preserves all user keys).
126
- * 2. Overlay all scalar/non-hooks keys from `managed` (statusLine, permissions
127
- * from the bundle, skipDangerousModePermissionPrompt, etc.).
171
+ * 2. Add or update Flow Agents-owned non-hooks keys from `managed`; preserve
172
+ * user-owned conflicting values and report conflicts.
128
173
  * 3. For `hooks`: iterate each event key from both existing and managed;
129
174
  * keep user (non-FA) groups from existing, append the current FA groups
130
175
  * from managed.
@@ -133,19 +178,26 @@ function mergePermissions(existingPerms, managedPerms) {
133
178
  * @param {Record<string, unknown>} managed
134
179
  * @returns {Record<string, unknown>}
135
180
  */
136
- function mergeSettings(existing, managed) {
181
+ function mergeSettings(existing, managed, options = {}) {
137
182
  // 1. Start with all existing keys (preserves user-owned data).
138
183
  const result = Object.assign({}, existing);
139
184
 
140
- // 2. Overlay non-hooks keys from managed. `permissions` is DEEP-merged so the
141
- // user's allow/deny/ask lists + defaultMode survive flow-agents only adds
142
- // its required entries (#117: never clobber user customizations).
185
+ // 2. Add non-hooks keys from managed without overwriting user-owned values.
186
+ // If a key is absent, add the managed default. If the existing value is
187
+ // already Flow Agents-owned (for example the generated statusLine command),
188
+ // replace it with the current managed value. Otherwise preserve the user's
189
+ // value and surface a conflict so the user can decide.
143
190
  for (const [key, value] of Object.entries(managed)) {
144
191
  if (key === "hooks") continue;
145
192
  if (key === "permissions") {
146
- result.permissions = mergePermissions(existing.permissions, value);
147
- } else {
193
+ result.permissions = mergePermissions(existing.permissions, value, options);
194
+ } else if (!(key in existing)) {
148
195
  result[key] = value;
196
+ } else if (valuesEqual(existing[key], value) || valueContainsManagedMarker(existing[key])) {
197
+ result[key] = value;
198
+ } else {
199
+ result[key] = existing[key];
200
+ emitConflict(options.onConflict, conflict(key, existing[key], value));
149
201
  }
150
202
  }
151
203
 
@@ -267,7 +319,13 @@ function runMerge({ configPath, managedHooksPath, version, installRecordPath, ru
267
319
  }
268
320
 
269
321
  // (b) + (c) + (d) Merge.
270
- const merged = mergeSettings(existing, managed);
322
+ const conflicts = [];
323
+ const merged = mergeSettings(existing, managed, { onConflict: (item) => conflicts.push(item) });
324
+ for (const item of conflicts) {
325
+ process.stderr.write(
326
+ `install-merge: conflict: preserving existing setting '${item.path}' and not applying Flow Agents managed value\n`
327
+ );
328
+ }
271
329
 
272
330
  // (e) Atomic write.
273
331
  atomicWriteJson(configPath, merged);