@kontourai/flow-agents 1.4.0 → 2.0.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 (184) hide show
  1. package/.github/CODEOWNERS +29 -0
  2. package/.github/actions/trust-verify/action.yml +145 -0
  3. package/.github/workflows/ci.yml +11 -4
  4. package/.github/workflows/kit-gates-demo.yml +2 -2
  5. package/.github/workflows/publish-npm.yml +10 -2
  6. package/.github/workflows/release-please.yml +1 -1
  7. package/.github/workflows/runtime-compat.yml +1 -1
  8. package/.github/workflows/trust-reconcile.yml +113 -0
  9. package/AGENTS.md +13 -0
  10. package/CHANGELOG.md +103 -0
  11. package/CONTRIBUTING.md +4 -4
  12. package/README.md +1 -0
  13. package/agents/tool-planner.json +1 -1
  14. package/build/src/cli/init.js +242 -20
  15. package/build/src/cli/validate-workflow-artifacts.js +19 -2
  16. package/build/src/cli/verify.d.ts +1 -0
  17. package/build/src/cli/verify.js +90 -0
  18. package/build/src/cli/workflow-sidecar.d.ts +316 -8
  19. package/build/src/cli/workflow-sidecar.js +1996 -91
  20. package/build/src/cli.js +2 -3
  21. package/build/src/lib/flow-resolver.d.ts +111 -0
  22. package/build/src/lib/flow-resolver.js +308 -0
  23. package/build/src/tools/build-universal-bundles.js +34 -22
  24. package/build/src/tools/generate-context-map.js +3 -16
  25. package/build/src/tools/validate-source-tree.d.ts +1 -1
  26. package/build/src/tools/validate-source-tree.js +42 -162
  27. package/context/contracts/artifact-contract.md +10 -0
  28. package/context/contracts/delivery-contract.md +1 -0
  29. package/context/contracts/review-contract.md +1 -0
  30. package/context/contracts/verification-contract.md +2 -0
  31. package/context/gate-awareness.md +39 -0
  32. package/context/scripts/hooks/stop-goal-fit.js +632 -70
  33. package/docs/adr/0001-flow-agents-consumes-flow.md +1 -1
  34. package/docs/adr/0002-flow-kits-as-extension-unit.md +1 -1
  35. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  36. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  37. package/docs/adr/0007-skill-audit.md +1 -1
  38. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +95 -0
  39. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +139 -0
  40. package/docs/adr/0011-mcp-posture.md +100 -0
  41. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +119 -0
  42. package/docs/adr/0013-context-lifecycle.md +151 -0
  43. package/docs/adr/0014-core-vs-domain-kit-boundary.md +143 -0
  44. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +120 -0
  45. package/docs/adr/0016-three-hard-boundary-model.md +71 -0
  46. package/docs/adr/0017-anti-gaming-trust-security-model.md +155 -0
  47. package/docs/agent-system-guidebook.md +5 -12
  48. package/docs/context-map.md +4 -10
  49. package/docs/index.md +3 -2
  50. package/docs/integrations/framework-adapter.md +19 -6
  51. package/docs/integrations/index.md +2 -2
  52. package/docs/north-star.md +4 -4
  53. package/docs/operating-layers.md +3 -3
  54. package/docs/plans/adr-0010-phase2-gate-recompute.md +55 -0
  55. package/docs/repository-structure.md +2 -2
  56. package/docs/skills-map.md +1 -0
  57. package/docs/spec/runtime-hook-surface.md +62 -9
  58. package/docs/standards-register.md +3 -3
  59. package/docs/survey-utterance-check.md +1 -1
  60. package/docs/trust-anchor-adoption.md +197 -0
  61. package/docs/verifiable-trust.md +95 -0
  62. package/docs/veritas-integration.md +2 -2
  63. package/docs/workflow-usage-guide.md +69 -0
  64. package/evals/acceptance/DEMO-false-completion.md +144 -0
  65. package/evals/acceptance/demo-cast.sh +92 -0
  66. package/evals/acceptance/demo-false-completion.sh +72 -0
  67. package/evals/acceptance/demo-real-evidence.sh +104 -0
  68. package/evals/acceptance/demo.tape +29 -0
  69. package/evals/acceptance/prove-capture-teeth-declared.sh +335 -0
  70. package/evals/acceptance/prove-capture-teeth.sh +114 -0
  71. package/evals/acceptance/prove-teeth.sh +105 -0
  72. package/evals/ci/antigaming-suite.sh +55 -0
  73. package/evals/ci/run-baseline.sh +2 -0
  74. package/evals/fixtures/flow-kit-repository/invalid-missing-extension-asset/flows/review.flow.json +26 -0
  75. package/evals/fixtures/flow-kit-repository/invalid-missing-extension-asset/kit.json +20 -0
  76. package/evals/fixtures/flow-kit-repository/valid-unknown-extension/flows/review.flow.json +26 -0
  77. package/evals/fixtures/flow-kit-repository/valid-unknown-extension/kit.json +18 -0
  78. package/evals/integration/test_builder_step_producers.sh +379 -0
  79. package/evals/integration/test_bundle_install.sh +35 -71
  80. package/evals/integration/test_bundle_lifecycle.sh +39 -2
  81. package/evals/integration/test_captured_fail_reconciliation.sh +820 -0
  82. package/evals/integration/test_checkpoint_signing.sh +489 -0
  83. package/evals/integration/test_claim_lookup.sh +352 -0
  84. package/evals/integration/test_command_log_fork_classification.sh +134 -0
  85. package/evals/integration/test_command_log_integrity.sh +275 -0
  86. package/evals/integration/test_context_map.sh +0 -2
  87. package/evals/integration/test_dual_emit_flow_step.sh +278 -0
  88. package/evals/integration/test_enforcer_expects_driven.sh +281 -0
  89. package/evals/integration/test_evidence_capture_hook.sh +185 -0
  90. package/evals/integration/test_flow_kit_repository.sh +2 -0
  91. package/evals/integration/test_flowdef_session_activation.sh +273 -0
  92. package/evals/integration/test_flowdef_session_history_preservation.sh +250 -0
  93. package/evals/integration/test_gate_bypass_chain.sh +448 -0
  94. package/evals/integration/test_gate_lockdown.sh +1137 -0
  95. package/evals/integration/test_gate_review_inquiry_records.sh +399 -0
  96. package/evals/integration/test_goal_fit_escape_hatch.sh +73 -0
  97. package/evals/integration/test_goal_fit_hook.sh +69 -4
  98. package/evals/integration/test_goal_fit_rederive.sh +263 -0
  99. package/evals/integration/test_install_merge.sh +1176 -0
  100. package/evals/integration/test_kit_identity_trust.sh +393 -0
  101. package/evals/integration/test_mint_attestation.sh +373 -0
  102. package/evals/integration/test_phase_map_and_gate_claim.sh +365 -0
  103. package/evals/integration/test_publish_delivery.sh +269 -0
  104. package/evals/integration/test_reconcile_soundness.sh +528 -0
  105. package/evals/integration/test_resolvefirststep_security.sh +208 -0
  106. package/evals/integration/test_session_resume_roundtrip.sh +286 -0
  107. package/evals/integration/test_trust_checkpoint.sh +325 -0
  108. package/evals/integration/test_trust_reconcile.sh +293 -0
  109. package/evals/integration/test_verify_cli.sh +208 -0
  110. package/evals/integration/test_workflow_sidecar_writer.sh +549 -34
  111. package/evals/lib/node.sh +0 -6
  112. package/evals/run.sh +47 -0
  113. package/evals/static/test_workflow_skills.sh +6 -13
  114. package/install.sh +0 -7
  115. package/integrations/strands-ts/README.md +25 -15
  116. package/integrations/veritas/flow-agents.adapter.json +1 -2
  117. package/kits/builder/flows/build.flow.json +59 -12
  118. package/kits/builder/kit.json +85 -15
  119. package/kits/builder/skills/continue-work/SKILL.md +116 -0
  120. package/kits/builder/skills/deliver/SKILL.md +36 -6
  121. package/kits/builder/skills/design-probe/SKILL.md +28 -0
  122. package/kits/builder/skills/execute-plan/SKILL.md +9 -1
  123. package/kits/builder/skills/gate-review/SKILL.md +234 -0
  124. package/kits/builder/skills/learning-review/SKILL.md +30 -0
  125. package/kits/builder/skills/pickup-probe/SKILL.md +29 -0
  126. package/kits/builder/skills/plan-work/SKILL.md +13 -1
  127. package/kits/builder/skills/pull-work/SKILL.md +19 -0
  128. package/kits/knowledge/adapters/default-store/index.js +38 -0
  129. package/kits/knowledge/adapters/flow-runner/index.js +1620 -0
  130. package/kits/knowledge/adapters/obsidian-store/index.js +36 -6
  131. package/kits/knowledge/docs/store-contract.md +314 -0
  132. package/kits/knowledge/evals/audit-freshness/suite.test.js +368 -0
  133. package/kits/knowledge/evals/canonicalize-category/suite.test.js +383 -0
  134. package/kits/knowledge/evals/contract-suite/suite.test.js +111 -0
  135. package/kits/knowledge/evals/detect-contradictions/suite.test.js +324 -0
  136. package/kits/knowledge/evals/entities/suite.test.js +40 -0
  137. package/kits/knowledge/evals/glossary-sync/suite.test.js +416 -0
  138. package/kits/knowledge/evals/hygiene-review/suite.test.js +396 -0
  139. package/kits/knowledge/evals/retirement/suite.test.js +145 -0
  140. package/kits/knowledge/flows/audit-freshness.flow.json +44 -0
  141. package/kits/knowledge/flows/canonicalize-category.flow.json +44 -0
  142. package/kits/knowledge/flows/detect-contradictions.flow.json +44 -0
  143. package/kits/knowledge/flows/glossary-sync.flow.json +61 -0
  144. package/kits/knowledge/flows/hygiene-review.flow.json +43 -0
  145. package/kits/knowledge/kit.json +51 -1
  146. package/package.json +6 -6
  147. package/packaging/conformance/README.md +10 -2
  148. package/packaging/conformance/fixtures/evidence-capture--allow-records-command.json +29 -0
  149. package/packaging/conformance/fixtures/stop-goal-fit--block-bundle-disputed-claim.json +29 -0
  150. package/packaging/conformance/fixtures/stop-goal-fit--block-capture-contradicts-claimed-pass.json +30 -0
  151. package/packaging/conformance/fixtures/stop-goal-fit--block-mode.json +23 -0
  152. package/packaging/conformance/fixtures/stop-goal-fit--off-mode.json +24 -0
  153. package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +5 -2
  154. package/packaging/conformance/fixtures/stop-goal-fit--warn-no-bundle.json +23 -0
  155. package/packaging/conformance/fixtures/workflow-steering--reground-active-prompt.json +30 -0
  156. package/packaging/conformance/fixtures/workflow-steering--reground-session-start.json +30 -0
  157. package/packaging/conformance/run-conformance.js +1 -1
  158. package/scripts/README.md +2 -1
  159. package/scripts/build-universal-bundles.js +0 -1
  160. package/scripts/ci/mint-attestation.js +221 -0
  161. package/scripts/ci/trust-reconcile.js +545 -0
  162. package/scripts/hooks/config-protection.js +423 -1
  163. package/scripts/hooks/evidence-capture.js +348 -0
  164. package/scripts/hooks/lib/liveness-read.js +113 -0
  165. package/scripts/hooks/run-hook.js +6 -1
  166. package/scripts/hooks/stop-goal-fit.js +1524 -79
  167. package/scripts/hooks/workflow-steering.js +135 -5
  168. package/scripts/install-codex-home.sh +39 -0
  169. package/scripts/install-merge.js +330 -0
  170. package/scripts/repair-command-log.js +115 -0
  171. package/src/cli/init.ts +218 -20
  172. package/src/cli/validate-workflow-artifacts.ts +18 -2
  173. package/src/cli/verify.ts +100 -0
  174. package/src/cli/workflow-sidecar.ts +2127 -84
  175. package/src/cli.ts +2 -3
  176. package/src/lib/flow-resolver.ts +369 -0
  177. package/src/tools/build-universal-bundles.ts +34 -21
  178. package/src/tools/generate-context-map.ts +3 -17
  179. package/src/tools/validate-source-tree.ts +44 -104
  180. package/build/src/tools/filter-installed-packs.d.ts +0 -2
  181. package/build/src/tools/filter-installed-packs.js +0 -135
  182. package/packaging/packs.json +0 -49
  183. package/scripts/filter-installed-packs.js +0 -2
  184. package/src/tools/filter-installed-packs.ts +0 -132
@@ -3,7 +3,11 @@
3
3
  * Workflow Steering Hook
4
4
  *
5
5
  * Injects phase-transition reminders after use_subagent calls complete and
6
- * ambient workflow-state reminders at the start of the next user turn.
6
+ * re-grounds the active workflow state at the start of every user turn and on
7
+ * SessionStart. SessionStart fires after context compaction and on resume, so
8
+ * re-injecting the goal/phase/next-step there is what makes an in-flight goal
9
+ * survive context loss instead of relying on the model voluntarily re-reading
10
+ * the sidecar.
7
11
  *
8
12
  * Non-blocking — always exits 0.
9
13
  */
@@ -12,6 +16,7 @@
12
16
 
13
17
  const fs = require('fs');
14
18
  const path = require('path');
19
+ const { readLivenessEvents, freshHolders } = require('./lib/liveness-read');
15
20
 
16
21
  const STEERING = {
17
22
  'tool-planner': [
@@ -198,6 +203,118 @@ function contextMapSteering(root) {
198
203
  ].join(' ');
199
204
  }
200
205
 
206
+ /**
207
+ * Compose the RESUME block for SessionStart.
208
+ *
209
+ * Reads trust.bundle, handoff.json, and the liveness stream beside state.json;
210
+ * all reads are fail-open (errors → skip that section, never throw).
211
+ *
212
+ * Returns a multi-line string starting with "RESUME: <slug> status:<s> phase:<p>"
213
+ * or '' if the current state has status 'done', 'archived', or 'accepted'.
214
+ *
215
+ * @param {string} root Repository root
216
+ * @param {{ file: string, payload: object }} current Latest active state entry
217
+ * @returns {string}
218
+ */
219
+ function resumeSteering(root, current) {
220
+ try {
221
+ const state = current.payload;
222
+ const workflowDir = path.dirname(current.file);
223
+ const slug = state.task_slug || path.basename(workflowDir);
224
+ const next = state.next_action || {};
225
+
226
+ if (next.status === 'done' || state.status === 'archived' || state.status === 'accepted') return '';
227
+
228
+ const lines = [];
229
+
230
+ // Header line
231
+ lines.push(`RESUME: ${slug} status:${safeStateText(state.status, 60)} phase:${safeStateText(state.phase, 60)}`);
232
+
233
+ // Full next action (240-char display path, not the 80-char normalization)
234
+ const nextSummary = next.summary ? safeStateText(next.summary, 240) : 'none';
235
+ lines.push(`Next action: ${nextSummary}`);
236
+
237
+ // Plan artifact path
238
+ let planPath = 'not found';
239
+ try {
240
+ const artifactPaths = Array.isArray(state.artifact_paths) ? state.artifact_paths : [];
241
+ const planEntry = artifactPaths.find(p => typeof p === 'string' && p.endsWith('--plan-work.md'));
242
+ if (planEntry) {
243
+ planPath = planEntry;
244
+ } else {
245
+ const candidate = path.join(workflowDir, `${slug}--plan-work.md`);
246
+ if (fs.existsSync(candidate)) planPath = candidate;
247
+ }
248
+ } catch { /* skip */ }
249
+ lines.push(`Plan: ${planPath}`);
250
+
251
+ // Handoff: next_steps[0] and blockers
252
+ let nextStep = 'none';
253
+ let blockers = 'none';
254
+ try {
255
+ const handoff = readJson(path.join(workflowDir, 'handoff.json'));
256
+ if (handoff) {
257
+ const steps = Array.isArray(handoff.next_steps) ? handoff.next_steps : [];
258
+ if (steps.length > 0) nextStep = safeStateText(String(steps[0]), 240);
259
+ const bArr = Array.isArray(handoff.blockers) ? handoff.blockers : [];
260
+ if (bArr.length > 0) blockers = bArr.map(b => safeStateText(String(b), 120)).join(', ');
261
+ }
262
+ } catch { /* skip */ }
263
+ lines.push(`Next step: ${nextStep}`);
264
+ lines.push(`Blockers: ${blockers}`);
265
+
266
+ // Trust bundle
267
+ try {
268
+ const bundle = readJson(path.join(workflowDir, 'trust.bundle'));
269
+ if (bundle) {
270
+ const claims = Array.isArray(bundle.claims) ? bundle.claims : [];
271
+ let verified = 0;
272
+ let disputed = 0;
273
+ const unresolved = [];
274
+ for (const claim of claims) {
275
+ if (!claim || typeof claim !== 'object') continue;
276
+ const status = String(claim.status || '');
277
+ if (status === 'verified') {
278
+ verified++;
279
+ } else if (status === 'disputed' || status === 'unknown') {
280
+ disputed++;
281
+ unresolved.push(claim);
282
+ }
283
+ }
284
+ const total = claims.length;
285
+ lines.push(`Trust: ${verified} verified / ${disputed} disputed / ${total} total`);
286
+ for (const claim of unresolved) {
287
+ const id = safeStateText(String(claim.id || ''), 120);
288
+ const st = safeStateText(String(claim.status || ''), 30);
289
+ lines.push(` - ${id} (${st}) → npm run workflow:sidecar -- claim ${id} ${workflowDir}`);
290
+ }
291
+ } else {
292
+ lines.push('Trust: no trust data available');
293
+ }
294
+ } catch { /* skip */ }
295
+
296
+ // Liveness advisory
297
+ try {
298
+ const livenessFile = path.join(root, '.flow-agents', 'liveness', 'events.jsonl');
299
+ const events = readLivenessEvents(livenessFile);
300
+ if (events.length > 0) {
301
+ const selfActor = (process.env.FLOW_AGENTS_ACTOR || '').trim() || 'local';
302
+ const holders = freshHolders(events, slug, selfActor, Date.now());
303
+ for (const h of holders) {
304
+ lines.push(`[LIVENESS WARNING: another agent appears live on this work: actor ${h.actor}, last seen ${h.lastAt}]`);
305
+ }
306
+ }
307
+ } catch { /* skip */ }
308
+
309
+ // Pull-work route hint
310
+ lines.push('To continue: resume this work. Or run pull-work to assess WIP and start new/parallel work.');
311
+
312
+ return lines.join('\n');
313
+ } catch {
314
+ return '';
315
+ }
316
+ }
317
+
201
318
  function run(rawInput) {
202
319
  try {
203
320
  const input = JSON.parse(rawInput);
@@ -217,9 +334,22 @@ function run(rawInput) {
217
334
  shouldAppendWorkflowContext = hints.length > 0;
218
335
  }
219
336
 
220
- if (event === 'UserPromptSubmit' && current && stateNeedsAmbientSteering(current.payload)) {
221
- hints.push('WORKFLOW STATE ATTENTION: current sidecars show unresolved workflow state at turn start.');
222
- shouldAppendWorkflowContext = true;
337
+ if ((event === 'UserPromptSubmit' || event === 'SessionStart') && current) {
338
+ const stateHint = stateSteering(root);
339
+ if (stateHint) {
340
+ hints.push(stateNeedsAmbientSteering(current.payload)
341
+ ? 'WORKFLOW STATE ATTENTION: current sidecars show unresolved workflow state at turn start.'
342
+ : 'WORKFLOW STATE: an active task is in progress — re-ground the recorded goal and resume the next step before doing anything else.');
343
+ hints.push(stateHint);
344
+ const contextHint = contextMapSteering(root);
345
+ if (contextHint) hints.push(contextHint);
346
+ }
347
+ }
348
+
349
+ // SessionStart only: append the RESUME block for richer situational awareness
350
+ if (event === 'SessionStart' && current) {
351
+ const resumeBlock = resumeSteering(root, current);
352
+ if (resumeBlock) hints.push(resumeBlock);
223
353
  }
224
354
 
225
355
  if (shouldAppendWorkflowContext) {
@@ -247,4 +377,4 @@ if (require.main === module) {
247
377
  });
248
378
  }
249
379
 
250
- module.exports = { run, stateSteering, critiqueSteering, contextMapSteering, latestWorkflowState, findRepoRoot, safeStateText, stateNeedsAmbientSteering };
380
+ module.exports = { run, stateSteering, critiqueSteering, contextMapSteering, latestWorkflowState, findRepoRoot, safeStateText, stateNeedsAmbientSteering, resumeSteering };
@@ -60,6 +60,14 @@ fi
60
60
 
61
61
  mkdir -p "$DEST"
62
62
 
63
+ # Stash the user's existing hooks.json (if any) BEFORE cleaning, so the merge
64
+ # step below can preserve user hooks across re-installs.
65
+ FA_USER_HOOKS_STASH=""
66
+ if [[ -f "$DEST/hooks.json" ]]; then
67
+ FA_USER_HOOKS_STASH="$(mktemp /tmp/fa-user-hooks.XXXXXX.json)"
68
+ cp "$DEST/hooks.json" "$FA_USER_HOOKS_STASH"
69
+ fi
70
+
63
71
  # This is an isolated generated Codex home. Clean generated bundle content before
64
72
  # overlaying so renamed/deleted source files do not survive across installs.
65
73
  rm -rf \
@@ -98,6 +106,37 @@ done
98
106
 
99
107
  chmod 700 "$DEST" 2>/dev/null || true
100
108
  [[ -f "$DEST/auth.json" ]] && chmod 600 "$DEST/auth.json" 2>/dev/null || true
109
+
110
+ # Merge FA hooks into the flattened hooks.json, preserving any user hooks already present.
111
+ # The managed-hooks source is the bundle's .codex/hooks.json (pre-flatten, always present in dist/codex/).
112
+ # The rsync above wrote the FA hooks.json directly to $DEST/hooks.json.
113
+ # If the user had a hooks.json before this install, use the stash as the "existing" config so
114
+ # user-owned hook groups survive. Otherwise, $DEST/hooks.json is already correct (FA only).
115
+ FA_VERSION="$(node -p "require('$ROOT_DIR/package.json').version" 2>/dev/null || echo unknown)"
116
+ FA_MANAGED_HOOKS="$ROOT_DIR/dist/codex/.codex/hooks.json"
117
+ if command -v node >/dev/null 2>&1 && [[ -f "$FA_MANAGED_HOOKS" ]]; then
118
+ if [[ -n "$FA_USER_HOOKS_STASH" && -f "$FA_USER_HOOKS_STASH" ]]; then
119
+ # Merge user's prior hooks (stash) with the current FA managed hooks.
120
+ node "$ROOT_DIR/scripts/install-merge.js" \
121
+ --config "$FA_USER_HOOKS_STASH" \
122
+ --managed-hooks "$FA_MANAGED_HOOKS" \
123
+ --version "$FA_VERSION" \
124
+ --install-record "$DEST/.flow-agents/install.json" \
125
+ --runtime "codex" || true
126
+ # Move the merged result into the destination.
127
+ cp "$FA_USER_HOOKS_STASH" "$DEST/hooks.json"
128
+ rm -f "$FA_USER_HOOKS_STASH"
129
+ else
130
+ # No prior user hooks: just write the version stamp (FA hooks are already correct from rsync).
131
+ node "$ROOT_DIR/scripts/install-merge.js" \
132
+ --config "$DEST/hooks.json" \
133
+ --managed-hooks "$FA_MANAGED_HOOKS" \
134
+ --version "$FA_VERSION" \
135
+ --install-record "$DEST/.flow-agents/install.json" \
136
+ --runtime "codex" || true
137
+ fi
138
+ fi
139
+
101
140
  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
102
141
  bash "$DEST/scripts/telemetry/install-console-config.sh" "$DEST/scripts/telemetry/telemetry.conf" "${CONSOLE_CONFIG_ARGS[@]}"
103
142
  fi
@@ -0,0 +1,330 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * install-merge.js — Merge-aware installer for flow-agents config files.
4
+ *
5
+ * Usage (CLI — full merge):
6
+ * node scripts/install-merge.js \
7
+ * --config <path-to-settings.json-or-hooks.json> \
8
+ * --managed-hooks <path-to-flow-agents-hooks-snippet.json> \
9
+ * --version <version-string> \
10
+ * --install-record <path-to-install.json> \
11
+ * --runtime <claude-code|codex|opencode|pi|kiro|base>
12
+ *
13
+ * Usage (CLI — stamp only, no config merge):
14
+ * node scripts/install-merge.js \
15
+ * --stamp-only \
16
+ * --version <version-string> \
17
+ * --install-record <path-to-install.json> \
18
+ * --runtime <pi|kiro|base|opencode>
19
+ *
20
+ * Design (per install-merge-aware--plan-work.md):
21
+ * (a) Read dest JSON (or {} if absent).
22
+ * (b) STRIP any hook entry whose inner hooks' statusMessage matches a
23
+ * flow-agents marker (the COLLISION_MARKER strings from init.ts).
24
+ * (c) APPEND the current managed hook groups from the bundle.
25
+ * (d) Preserve ALL other keys (permissions, statusLine, user hooks, auth).
26
+ * (e) Atomic write (write tmp + rename).
27
+ * (f) Write/update .flow-agents/install.json version stamp.
28
+ *
29
+ * Export: mergeSettings(existing, managed) — pure, testable.
30
+ * The managed ownership region is identified purely by statusMessage markers
31
+ * (cross-runtime, no top-level key needed in settings.json).
32
+ */
33
+
34
+ "use strict";
35
+
36
+ const fs = require("node:fs");
37
+ const path = require("node:path");
38
+ const os = require("node:os");
39
+
40
+ // ─── Marker strings ───────────────────────────────────────────────────────────
41
+ // These three statusMessage strings identify every flow-agents managed hook.
42
+ // They are the same strings used by COLLISION_MARKER in src/cli/init.ts and
43
+ // by exportClaudeSettings() / exportCodexHooks() in build-universal-bundles.ts.
44
+ const FA_MARKERS = [
45
+ "Recording Flow Agents telemetry",
46
+ "Running Flow Agents hook policy",
47
+ "Capturing Flow Agents command evidence",
48
+ ];
49
+
50
+ /**
51
+ * Returns true if a hook-group entry is owned by flow-agents.
52
+ * A hook-group in Claude Code settings looks like:
53
+ * { hooks: [ { type, command, timeout, statusMessage } ] }
54
+ * A hook-group in Codex hooks.json looks like:
55
+ * { hooks: [ { type, command, timeout, statusMessage } ] }
56
+ *
57
+ * We identify a managed group by checking whether ANY inner hook's
58
+ * statusMessage contains one of the FA_MARKERS strings.
59
+ *
60
+ * @param {Record<string, unknown>} hookGroup
61
+ * @returns {boolean}
62
+ */
63
+ function isManagedHookGroup(hookGroup) {
64
+ if (typeof hookGroup !== "object" || hookGroup === null) return false;
65
+ const innerHooks = Array.isArray(hookGroup.hooks) ? hookGroup.hooks : [];
66
+ return innerHooks.some((innerHook) => {
67
+ if (typeof innerHook !== "object" || innerHook === null) return false;
68
+ const sm = typeof innerHook.statusMessage === "string" ? innerHook.statusMessage : "";
69
+ return FA_MARKERS.some((marker) => sm.includes(marker));
70
+ });
71
+ }
72
+
73
+ /**
74
+ * mergeArrayUnion — union two arrays preserving order, de-duped (string or JSON key).
75
+ * @param {unknown} a @param {unknown} b @returns {unknown[]}
76
+ */
77
+ function mergeArrayUnion(a, b) {
78
+ const out = [];
79
+ const seen = new Set();
80
+ for (const item of [...(Array.isArray(a) ? a : []), ...(Array.isArray(b) ? b : [])]) {
81
+ const key = typeof item === "string" ? item : JSON.stringify(item);
82
+ if (seen.has(key)) continue;
83
+ seen.add(key);
84
+ out.push(item);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /**
90
+ * mergePermissions — deep-merge the `permissions` block so flow-agents only ADDS
91
+ * its required entries and never clobbers the user's customizations (#117):
92
+ * - union the user's + managed `allow`/`deny`/`ask` rule lists (de-duped),
93
+ * - preserve the user's `defaultMode` when set (only adopt managed's if unset),
94
+ * - keep user-only sub-keys; overlay other managed scalar sub-keys.
95
+ * @param {unknown} existingPerms @param {unknown} managedPerms
96
+ * @returns {Record<string, unknown>}
97
+ */
98
+ function mergePermissions(existingPerms, managedPerms) {
99
+ const e = existingPerms && typeof existingPerms === "object" ? existingPerms : {};
100
+ const m = managedPerms && typeof managedPerms === "object" ? managedPerms : {};
101
+ const out = Object.assign({}, e, m);
102
+ for (const listKey of ["allow", "deny", "ask"]) {
103
+ if (Array.isArray(e[listKey]) || Array.isArray(m[listKey])) {
104
+ out[listKey] = mergeArrayUnion(e[listKey], m[listKey]);
105
+ }
106
+ }
107
+ if (e.defaultMode !== undefined) out.defaultMode = e.defaultMode;
108
+ return out;
109
+ }
110
+
111
+ /**
112
+ * mergeSettings — pure merge function (no I/O).
113
+ *
114
+ * Given:
115
+ * existing — the current dest settings object (e.g. from settings.json)
116
+ * managed — the flow-agents generated settings (e.g. from bundle .claude/settings.json)
117
+ *
118
+ * Returns a new object with:
119
+ * - All keys from `existing` preserved (permissions, statusLine, auth, etc.)
120
+ * - All keys from `managed` merged in (flow-agents owned keys like statusLine, hooks)
121
+ * - For the `hooks` key: user-owned hook groups (non-FA) survive; FA groups are
122
+ * replaced with the current managed set from `managed`.
123
+ *
124
+ * Strategy:
125
+ * 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.).
128
+ * 3. For `hooks`: iterate each event key from both existing and managed;
129
+ * keep user (non-FA) groups from existing, append the current FA groups
130
+ * from managed.
131
+ *
132
+ * @param {Record<string, unknown>} existing
133
+ * @param {Record<string, unknown>} managed
134
+ * @returns {Record<string, unknown>}
135
+ */
136
+ function mergeSettings(existing, managed) {
137
+ // 1. Start with all existing keys (preserves user-owned data).
138
+ const result = Object.assign({}, existing);
139
+
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).
143
+ for (const [key, value] of Object.entries(managed)) {
144
+ if (key === "hooks") continue;
145
+ if (key === "permissions") {
146
+ result.permissions = mergePermissions(existing.permissions, value);
147
+ } else {
148
+ result[key] = value;
149
+ }
150
+ }
151
+
152
+ // 3. Merge hooks: strip FA groups from existing, append current FA groups.
153
+ const existingHooks =
154
+ typeof existing.hooks === "object" && existing.hooks !== null
155
+ ? (existing.hooks)
156
+ : {};
157
+ const managedHooks =
158
+ typeof managed.hooks === "object" && managed.hooks !== null
159
+ ? (managed.hooks)
160
+ : {};
161
+
162
+ // Collect all event keys from both sides.
163
+ const allEventKeys = new Set([
164
+ ...Object.keys(existingHooks),
165
+ ...Object.keys(managedHooks),
166
+ ]);
167
+
168
+ const mergedHooks = {};
169
+ for (const eventKey of allEventKeys) {
170
+ const existingGroups = Array.isArray(existingHooks[eventKey])
171
+ ? existingHooks[eventKey]
172
+ : [];
173
+ const managedGroups = Array.isArray(managedHooks[eventKey])
174
+ ? managedHooks[eventKey]
175
+ : [];
176
+
177
+ // Keep user-owned (non-FA) groups from existing.
178
+ const userGroups = existingGroups.filter(
179
+ (group) => !isManagedHookGroup(group)
180
+ );
181
+
182
+ // Append the new FA groups from managed (may be empty if event not in managed).
183
+ mergedHooks[eventKey] = [...userGroups, ...managedGroups];
184
+
185
+ // Drop empty event keys (only user groups existed and were zero — unlikely
186
+ // but defensive: remove keys with empty arrays to keep JSON clean).
187
+ if (mergedHooks[eventKey].length === 0) {
188
+ delete mergedHooks[eventKey];
189
+ }
190
+ }
191
+
192
+ // Only write the hooks key if at least one side actually had a hooks key,
193
+ // OR if the merged result is non-empty. This prevents injecting a spurious
194
+ // empty "hooks": {} into configs that have no hooks (e.g. opencode.json).
195
+ const eitherHadHooks = "hooks" in existing || "hooks" in managed;
196
+ if (eitherHadHooks || Object.keys(mergedHooks).length > 0) {
197
+ result.hooks = mergedHooks;
198
+ }
199
+ return result;
200
+ }
201
+
202
+ /**
203
+ * atomicWriteJson — write JSON to a file atomically (tmp + rename).
204
+ *
205
+ * @param {string} filePath
206
+ * @param {unknown} data
207
+ */
208
+ function atomicWriteJson(filePath, data) {
209
+ const dir = path.dirname(filePath);
210
+ fs.mkdirSync(dir, { recursive: true });
211
+ const tmp = `${filePath}.tmp.${process.pid}`;
212
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf8");
213
+ fs.renameSync(tmp, filePath);
214
+ }
215
+
216
+ /**
217
+ * writeInstallRecord — write/update .flow-agents/install.json.
218
+ *
219
+ * @param {string} installRecordPath
220
+ * @param {string} version
221
+ * @param {string} runtime
222
+ */
223
+ function writeInstallRecord(installRecordPath, version, runtime) {
224
+ const record = {
225
+ version,
226
+ installedAt: new Date().toISOString(),
227
+ runtime,
228
+ };
229
+ atomicWriteJson(installRecordPath, record);
230
+ }
231
+
232
+ /**
233
+ * runMerge — perform the full merge (read, merge, write, stamp).
234
+ *
235
+ * @param {{
236
+ * configPath: string,
237
+ * managedHooksPath: string,
238
+ * version: string,
239
+ * installRecordPath: string,
240
+ * runtime: string,
241
+ * }} opts
242
+ */
243
+ function runMerge({ configPath, managedHooksPath, version, installRecordPath, runtime }) {
244
+ // (a) Read dest JSON (or {} if absent).
245
+ let existing = {};
246
+ if (fs.existsSync(configPath)) {
247
+ try {
248
+ existing = JSON.parse(fs.readFileSync(configPath, "utf8"));
249
+ } catch (err) {
250
+ process.stderr.write(
251
+ `install-merge: warning: could not parse existing ${configPath} (${err.message}); treating as empty\n`
252
+ );
253
+ existing = {};
254
+ }
255
+ }
256
+
257
+ // Read the managed (bundle-generated) config.
258
+ let managed = {};
259
+ try {
260
+ managed = JSON.parse(fs.readFileSync(managedHooksPath, "utf8"));
261
+ } catch (err) {
262
+ process.stderr.write(
263
+ `install-merge: error: could not read managed config ${managedHooksPath}: ${err.message}\n`
264
+ );
265
+ process.exitCode = 1;
266
+ return;
267
+ }
268
+
269
+ // (b) + (c) + (d) Merge.
270
+ const merged = mergeSettings(existing, managed);
271
+
272
+ // (e) Atomic write.
273
+ atomicWriteJson(configPath, merged);
274
+
275
+ // (f) Write version stamp.
276
+ writeInstallRecord(installRecordPath, version, runtime);
277
+ }
278
+
279
+ // ─── CLI wrapper ──────────────────────────────────────────────────────────────
280
+ if (require.main === module) {
281
+ const args = process.argv.slice(2);
282
+ const flags = {};
283
+ for (let i = 0; i < args.length; i++) {
284
+ if (args[i] === "--stamp-only") {
285
+ flags["stamp-only"] = "1";
286
+ } else if (args[i].startsWith("--") && i + 1 < args.length) {
287
+ flags[args[i].slice(2)] = args[i + 1];
288
+ i++;
289
+ }
290
+ }
291
+
292
+ const stampOnly = flags["stamp-only"] === "1";
293
+ const configPath = flags["config"];
294
+ const managedHooksPath = flags["managed-hooks"];
295
+ const version = flags["version"] || "unknown";
296
+ const installRecordPath = flags["install-record"];
297
+ const runtime = flags["runtime"] || "claude-code";
298
+
299
+ if (stampOnly) {
300
+ // Stamp-only mode: write install.json without merging any config file.
301
+ // Used by runtimes (pi, kiro, base) that do not have a shared config to merge.
302
+ if (!installRecordPath) {
303
+ process.stderr.write(
304
+ "usage: node install-merge.js --stamp-only --version <ver> --install-record <path> --runtime <runtime>\n"
305
+ );
306
+ process.exitCode = 2;
307
+ } else {
308
+ try {
309
+ writeInstallRecord(installRecordPath, version, runtime);
310
+ } catch (err) {
311
+ process.stderr.write(`install-merge: error: ${err.message}\n`);
312
+ process.exitCode = 1;
313
+ }
314
+ }
315
+ } else if (!configPath || !managedHooksPath || !installRecordPath) {
316
+ process.stderr.write(
317
+ "usage: node install-merge.js --config <path> --managed-hooks <path> --version <ver> --install-record <path> --runtime <runtime>\n"
318
+ );
319
+ process.exitCode = 2;
320
+ } else {
321
+ try {
322
+ runMerge({ configPath, managedHooksPath, version, installRecordPath, runtime });
323
+ } catch (err) {
324
+ process.stderr.write(`install-merge: error: ${err.message}\n`);
325
+ process.exitCode = 1;
326
+ }
327
+ }
328
+ }
329
+
330
+ module.exports = { mergeSettings, isManagedHookGroup, FA_MARKERS };
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * repair-command-log.js — deterministic re-linearization of a concurrent-fork
5
+ * command-log.jsonl.
6
+ *
7
+ * A "fork" happens when two PostToolUse captures append off the same parent tip
8
+ * (parallel writers before the writer lock, flow-agents#232). The records are
9
+ * all genuine and self-consistent; only their linear order is ambiguous. This
10
+ * tool produces THE canonical order — sort chained entries by (capturedAt, then
11
+ * hash) — and re-chains them, so any party re-running it gets the identical
12
+ * result. It is therefore a verifiable repair, not a judgement call.
13
+ *
14
+ * SAFETY: it refuses to run unless verifyCommandLogChain() reports "forked".
15
+ * - "broken" (real tamper: edited content, reorder, deletion) → REFUSE. The
16
+ * repair must never be usable to launder tampering.
17
+ * - "ok" / "legacy" → nothing to do.
18
+ * No record content is altered — only the _chain wrappers and line order. The
19
+ * original is backed up, and an in-chain `chain-repair` marker records that the
20
+ * re-linearization happened (so the repair is itself auditable).
21
+ *
22
+ * Usage: node scripts/repair-command-log.js <artifact-dir> [--reason "..."]
23
+ */
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const crypto = require('crypto');
27
+
28
+ const gate = require(path.join(__dirname, 'hooks', 'stop-goal-fit.js'));
29
+ const GENESIS = gate.CHAIN_GENESIS_VERIFY;
30
+
31
+ function canon(rec) {
32
+ const keys = Object.keys(rec).filter((k) => k !== '_chain').sort();
33
+ const obj = {};
34
+ for (const k of keys) obj[k] = rec[k];
35
+ return JSON.stringify(obj);
36
+ }
37
+ function hashLink(prev, rec) {
38
+ return crypto.createHash('sha256').update(prev + canon(rec), 'utf8').digest('hex');
39
+ }
40
+
41
+ function main() {
42
+ const dir = process.argv[2];
43
+ if (!dir) { console.error('usage: repair-command-log.js <artifact-dir> [--reason "..."]'); process.exit(2); }
44
+ const reasonIdx = process.argv.indexOf('--reason');
45
+ const reason = reasonIdx !== -1 ? (process.argv[reasonIdx + 1] || '') : 'deterministic concurrent-fork re-linearization';
46
+
47
+ const verdict = gate.verifyCommandLogChain(dir);
48
+ if (verdict.status === 'ok' || verdict.status === 'legacy') {
49
+ console.log(`nothing to repair: chain status is "${verdict.status}"`);
50
+ return;
51
+ }
52
+ if (verdict.status !== 'forked') {
53
+ console.error(`REFUSING to repair: chain status is "${verdict.status}" (entry ${verdict.brokenAt}). ` +
54
+ 'This tool only re-linearizes benign concurrent forks; it will not touch a tampered chain.');
55
+ process.exit(1);
56
+ }
57
+
58
+ const file = path.join(dir, 'command-log.jsonl');
59
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter((l) => l.trim());
60
+
61
+ // Preserve legacy prefix verbatim; collect the chained records (content only).
62
+ const legacyPrefix = [];
63
+ const records = [];
64
+ let started = false;
65
+ for (const line of lines) {
66
+ let e;
67
+ try { e = JSON.parse(line); } catch { if (!started) { legacyPrefix.push(line); } continue; }
68
+ const isChained = e._chain && typeof e._chain.hash === 'string';
69
+ if (!started && !isChained) { legacyPrefix.push(line); continue; }
70
+ started = true;
71
+ const rec = { ...e };
72
+ delete rec._chain;
73
+ records.push(rec);
74
+ }
75
+
76
+ // Canonical deterministic order: capturedAt asc, then a stable content hash.
77
+ records.sort((a, b) => {
78
+ const ta = String(a.capturedAt || ''), tb = String(b.capturedAt || '');
79
+ if (ta !== tb) return ta < tb ? -1 : 1;
80
+ const ha = crypto.createHash('sha256').update(canon(a)).digest('hex');
81
+ const hb = crypto.createHash('sha256').update(canon(b)).digest('hex');
82
+ return ha < hb ? -1 : ha > hb ? 1 : 0;
83
+ });
84
+
85
+ // Re-chain from genesis.
86
+ const out = [...legacyPrefix];
87
+ let prev = GENESIS;
88
+ let seq = 0;
89
+ for (const rec of records) {
90
+ const h = hashLink(prev, rec);
91
+ out.push(JSON.stringify({ ...rec, _chain: { seq, prevHash: prev, hash: h } }));
92
+ prev = h; seq += 1;
93
+ }
94
+ // Append an in-chain repair marker so the re-linearization is itself auditable.
95
+ const marker = {
96
+ command: '(chain-repair marker)',
97
+ observedResult: `re-linearized ${records.length} entries from concurrent fork`,
98
+ exitCode: 0,
99
+ capturedAt: new Date().toISOString(),
100
+ source: 'chain-repair',
101
+ repair: { reason, entries: records.length, forkAt: verdict.forkAt },
102
+ };
103
+ const mh = hashLink(prev, marker);
104
+ out.push(JSON.stringify({ ...marker, _chain: { seq, prevHash: prev, hash: mh } }));
105
+
106
+ fs.copyFileSync(file, file + '.prebackup-repair');
107
+ fs.writeFileSync(file, out.join('\n') + '\n');
108
+
109
+ const after = gate.verifyCommandLogChain(dir);
110
+ console.log(`repaired: re-linearized ${records.length} entries (legacy prefix: ${legacyPrefix.length}); ` +
111
+ `chain status now "${after.status}". backup: command-log.jsonl.prebackup-repair`);
112
+ if (after.status !== 'ok') { console.error('repair did not produce a clean chain'); process.exit(1); }
113
+ }
114
+
115
+ main();