@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
@@ -2,14 +2,105 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { execFileSync } = require('child_process');
5
6
 
6
7
  const KONTOURAI_DIR = '.kontourai';
7
8
  const FLOW_AGENTS_RUNTIME_SUBDIR = 'flow-agents';
8
9
  const FLOW_AGENTS_RUNTIME_DIR = `${KONTOURAI_DIR}/${FLOW_AGENTS_RUNTIME_SUBDIR}`;
9
10
  const DURABLE_FLOW_AGENTS_DIR = '.flow-agents';
10
11
 
12
+ /**
13
+ * #357: resolve the SHARED, git-common-dir-anchored repo root for `cwd`. CJS twin of
14
+ * src/lib/local-artifact-root.ts's resolveSharedRepoRoot — see that file's doc comment for
15
+ * the full rationale (git-common-dir resolves to the SAME primary-checkout .git directory
16
+ * from any linked worktree, so path.dirname() of its absolute form is the one shared repo
17
+ * root every worktree of a repo should read/write .kontourai/flow-agents under). Returns
18
+ * null (never throws) when git is unavailable, cwd is not inside a git working tree, or the
19
+ * command fails — callers MUST fail open to the existing cwd-based behavior in that case.
20
+ *
21
+ * #413 iteration-2 Fix 3: strips the ambient GIT_DIR/GIT_COMMON_DIR/GIT_WORK_TREE/
22
+ * GIT_CEILING_DIRECTORIES env vars before shelling out — see the TS twin's doc comment for
23
+ * the full rationale (an ambient GIT_DIR etc. could otherwise silently redirect resolution to
24
+ * a different repo than the one cwd is actually inside).
25
+ */
26
+ function resolveSharedRepoRoot(cwd) {
27
+ try {
28
+ const env = { ...process.env };
29
+ delete env.GIT_DIR;
30
+ delete env.GIT_COMMON_DIR;
31
+ delete env.GIT_WORK_TREE;
32
+ delete env.GIT_CEILING_DIRECTORIES;
33
+ const out = execFileSync('git', ['rev-parse', '--git-common-dir'], {
34
+ cwd,
35
+ env,
36
+ encoding: 'utf8',
37
+ stdio: ['ignore', 'pipe', 'ignore'],
38
+ }).trim();
39
+ if (!out) return null;
40
+ const absoluteCommonDir = path.resolve(cwd, out);
41
+ return path.dirname(absoluteCommonDir);
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * #413 iteration-2 Fix 1: cheap discriminator for the loud-vs-silent fail-open decision below.
49
+ * CJS twin of the TS lib's isInsideGitWorkingTree — see that file's doc comment for the full
50
+ * rationale. Walks up from `cwd` looking for a `.git` entry (file or directory) without
51
+ * shelling out to git, so it stays meaningful even when git itself is broken/absent.
52
+ */
53
+ function isInsideGitWorkingTree(cwd) {
54
+ try {
55
+ let dir = path.resolve(cwd);
56
+ const root = path.parse(dir).root;
57
+ for (let depth = 0; depth < 40; depth++) {
58
+ if (fs.existsSync(path.join(dir, '.git'))) return true;
59
+ if (dir === root) break;
60
+ const parent = path.dirname(dir);
61
+ if (parent === dir) break;
62
+ dir = parent;
63
+ }
64
+ return false;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * #413 iteration-2 Fix 1 (HIGH, security): emit a LOUD stderr warning before falling open, but
72
+ * ONLY when `cwd` is genuinely inside a git working tree AND resolveSharedRepoRoot still
73
+ * returned null. See the TS twin's doc comment for the full rationale — the benign case (no
74
+ * git repo at all) must stay perfectly silent; the loud case is #357's exact silent-fail-open
75
+ * bug (an isolated, invisible cwd-local store), and a warning (not a hard refuse, since this
76
+ * sits on read paths too) gives an operator the signal to pass --artifact-root explicitly.
77
+ */
78
+ function warnIfFailingOpenInsideGitTree(cwd, fallbackPath) {
79
+ if (!isInsideGitWorkingTree(cwd)) return;
80
+ process.stderr.write(
81
+ `[artifact-root] WARNING: inside a git working tree but could not resolve the shared repo root ` +
82
+ `(git rev-parse --git-common-dir failed or returned nothing from ${cwd}); falling back to a ` +
83
+ `cwd-local store at ${fallbackPath} — coordination claims may be invisible to other ` +
84
+ `worktrees/actors. Pass --artifact-root explicitly to fix.\n`
85
+ );
86
+ }
87
+
88
+ /**
89
+ * #413 iteration-2 Fix 2 (documentation only, no behavior change): CJS twin of the TS lib's own
90
+ * doc comment on flowAgentsArtifactRoot — see that file for the full rationale. A SUBDIRECTORY
91
+ * cwd of a plain checkout intentionally resolves to the shared <repo-root>/.kontourai/flow-agents
92
+ * store (via the git-common-dir walk-up), not a subdir-anchored path.resolve(cwd, ...) — the
93
+ * correct one-store-per-repo semantics, not a regression.
94
+ */
11
95
  function flowAgentsArtifactRoot(cwd = process.cwd()) {
12
- return path.resolve(cwd, FLOW_AGENTS_RUNTIME_DIR);
96
+ const sharedRepoRoot = resolveSharedRepoRoot(cwd);
97
+ if (sharedRepoRoot) return path.resolve(sharedRepoRoot, FLOW_AGENTS_RUNTIME_DIR);
98
+ // Fail-open: not a git repo, git unavailable, or command failed — reproduce today's plain
99
+ // cwd-based behavior unchanged (AC6 backward-compat guarantee). #413 iteration-2 Fix 1: loud,
100
+ // not silent, when cwd IS inside a git working tree (see warnIfFailingOpenInsideGitTree).
101
+ const fallback = path.resolve(cwd, FLOW_AGENTS_RUNTIME_DIR);
102
+ warnIfFailingOpenInsideGitTree(cwd, fallback);
103
+ return fallback;
13
104
  }
14
105
 
15
106
  function durableFlowAgentsRoot(cwd = process.cwd()) {
@@ -35,4 +126,9 @@ module.exports = {
35
126
  flowAgentsArtifactRoot,
36
127
  flowAgentsArtifactRootsForRead,
37
128
  defaultArtifactRootForRead,
129
+ resolveSharedRepoRoot,
130
+ // #413 iteration-2 Fix 1: exported so findRepoRoot in stop-goal-fit.js/workflow-steering.js
131
+ // can apply the SAME loud-when-in-git-tree warning this lib's own flowAgentsArtifactRoot
132
+ // uses, rather than a second, independently-drifting copy of the discriminator.
133
+ warnIfFailingOpenInsideGitTree,
38
134
  };
@@ -0,0 +1,177 @@
1
+ 'use strict';
2
+ /**
3
+ * runnable-command.js — shared pure-CJS runnable-command-text heuristic (#412)
4
+ *
5
+ * Zero external dependencies (only string ops). Consumed by:
6
+ * - scripts/hooks/stop-goal-fit.js (CJS, direct require)
7
+ * - build/src/cli/workflow-sidecar.js (ESM compiled, via createRequire)
8
+ *
9
+ * Extracted verbatim (no logic change) from stop-goal-fit.js's former inline
10
+ * `isRunnableCommandText`, so the heuristic has exactly one source of truth
11
+ * instead of drifting between the hook (Stop-time backstop) and the sidecar
12
+ * (record-time rejection).
13
+ *
14
+ * Exports:
15
+ * isRunnableCommandText(text) → boolean
16
+ * isAmbiguousAbsenceCommand(text) → boolean
17
+ */
18
+
19
+ // WS8 (AC10b): a kind:"command" evidence ref's excerpt/command must be a literally runnable
20
+ // shell command, not a prose description of a manual verification step. This heuristic
21
+ // rejects prose so the goal-fit backstop never spawns `bash -lc "<a sentence>"` and
22
+ // misreports the resulting shell error as a caught false-completion. Pairs with the rule
23
+ // stated in context/contracts/planning-contract.md (AC11).
24
+ //
25
+ // #362 (Wave 3 coherence fix): a leading `!` negation (optionally followed by whitespace) is
26
+ // stripped before evaluation, mirroring isAmbiguousAbsenceCommand's own negation handling
27
+ // (runnable-command.js's OTHER export, above/below) -- a negated runnable command (e.g.
28
+ // `! grep -r foo src/`, the exact self-asserting rewrite this module's ambiguous-absence
29
+ // guidance recommends) is itself still a literally runnable shell command, not prose. Without
30
+ // this, the ambiguous-absence advisory's own suggested remediation would be rejected by this
31
+ // function's fatal runnability guard -- a self-defeating loop. Only a BARE leading `!` is
32
+ // stripped (a single strip, not recursive); the remainder is evaluated by the existing checks
33
+ // unchanged, so a genuinely non-command sentence that happens to start with `!` (e.g.
34
+ // `! a prose sentence. It fails.`) still fails the sentence-like-prose check below and is
35
+ // rejected, fail-closed exactly as before.
36
+ function isRunnableCommandText(text) {
37
+ const s = typeof text === 'string' ? text.trim() : '';
38
+ if (!s) return false;
39
+ const unnegated = s.replace(/^!\s*/, '');
40
+ if (!unnegated) return false;
41
+
42
+ const prosePunctuation = /[.!?]\s+\S|[;:]\s+[A-Za-z]/;
43
+ const startsLikeSentence = /^[A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+|\s+[a-z]{2,})/.test(unnegated);
44
+ if (prosePunctuation.test(unnegated) && startsLikeSentence) return false;
45
+
46
+ const assignment = /^[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)/;
47
+ let rest = unnegated;
48
+ let sawAssignment = false;
49
+ // Strip leading KEY=value prefixes before evaluating the actual command token.
50
+ // A line containing only assignments is still a valid shell command.
51
+ // eslint-disable-next-line no-constant-condition
52
+ while (true) {
53
+ const m = rest.match(assignment);
54
+ if (!m) break;
55
+ sawAssignment = true;
56
+ rest = rest.slice(m[0].length).trimStart();
57
+ if (!rest) return true;
58
+ }
59
+
60
+ const first = rest.split(/\s+/)[0] || '';
61
+ if (!first) return sawAssignment;
62
+ // WS8 (AC10b, iteration 2): the first-token allowlist is broadened to the common
63
+ // real-world verify binaries a project's runnable evidence names (git, tsc, eslint,
64
+ // python/python3, docker, curl, jq, diff, grep, test) so honest command evidence is not
65
+ // misclassified as prose. Still fail-closed for genuinely non-command prose: a bare
66
+ // sentence whose first token is none of these (and carries no path prefix or shell
67
+ // metacharacter) is rejected rather than executed.
68
+ const knownBinary = /^(npm|npx|pnpm|yarn|node|bash|sh|zsh|make|just|task|cargo|go|pytest|tox|deno|bun|ruby|rake|mvn|gradle|dotnet|swift|flutter|dart|git|tsc|eslint|python|python3|docker|curl|jq|diff|grep|test|exit|cd|false|true|set|unset|export|echo|\[|return|:|source|\.)$/.test(first)
69
+ || first.startsWith('./') || first.startsWith('/') || first.includes('/');
70
+ if (knownBinary) {
71
+ const remainder = rest.slice(first.length).trim();
72
+ if (!remainder) return true;
73
+ if (isCommandShapedRemainder(remainder)) return true;
74
+ return !isPlainEnglishRemainder(remainder);
75
+ }
76
+
77
+ // Shell operators are only a command signal when the first token itself looks like a
78
+ // command word. Incidental punctuation in prose ("External CI (green)", "done: shipped")
79
+ // must not make the text runnable.
80
+ const commandLikeToken = /^[a-z_][a-z0-9._+-]*$/i.test(first) && first === first.toLowerCase();
81
+ const hasCommandPositionOperator = /(?:^|\s)(?:&&|\|\||[|;])(?:\s|$)|[$`]/.test(rest);
82
+ if (commandLikeToken && hasCommandPositionOperator && !/[;:]\s+[A-Za-z]{2,}/.test(rest)) return true;
83
+
84
+ return false;
85
+ }
86
+
87
+ function isCommandShapedRemainder(remainder) {
88
+ const tokens = remainder.split(/\s+/).filter(Boolean);
89
+ if (tokens.length === 0) return true;
90
+ if (/['"`|&;<>()${}]/.test(remainder)) return true;
91
+ return tokens.some((token) => {
92
+ return /^--?[A-Za-z0-9][A-Za-z0-9_-]*(?:=.*)?$/.test(token)
93
+ || /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)
94
+ || token.startsWith('./')
95
+ || token.includes('/')
96
+ || /\.[A-Za-z0-9]{1,12}(?::\d+)?$/.test(token);
97
+ });
98
+ }
99
+
100
+ function isPlainEnglishRemainder(remainder) {
101
+ const tokens = remainder.split(/\s+/).filter(Boolean);
102
+ if (tokens.length < 3) return false;
103
+ return tokens.every((token) => /^[A-Za-z]+[,.]?$/.test(token));
104
+ }
105
+
106
+ // #362: a BARE (non-negated, non-self-asserting) `grep`/`diff` invocation is exit-code
107
+ // AMBIGUOUS for its two most common uses — a "the thing is absent" check (author intends
108
+ // exit 1 = PASS) and a "the thing is present" check (author intends exit 1 = FAIL) look
109
+ // byte-identical to any downstream consumer that only sees the exit code. This heuristic
110
+ // recognizes that SHAPE (narrowly: grep/diff only, per the plan's explicit two-binary
111
+ // carve-out — see plan body "#362 root cause") so callers can classify a bare exit-1
112
+ // result as ambiguous/NOT_VERIFIED instead of guessing pass or fail.
113
+ //
114
+ // A command is NOT ambiguous (i.e. already self-asserting, exit code already encodes the
115
+ // author's intent) when ANY of:
116
+ // - it is negated with a leading `!` (`! grep ...` — negation flips 0/1 into the
117
+ // intended fail/pass, so exit 0 unambiguously means "pattern is present/absent" per
118
+ // the author's own assertion)
119
+ // - it is part of a `||`/`&&` boolean chain (`grep ... || true`, `grep ... && echo ok`)
120
+ // — the chain itself already asserts what a given branch outcome means
121
+ // - it is piped into a count-assertion (`grep -c ... | grep -qx 0`, `grep ... | wc -l`
122
+ // style) — the author is asserting on a MATCH COUNT, not on grep's own raw exit code
123
+ // - its first real command token (after stripping `bash -lc '...'` wrapping and any
124
+ // leading `KEY=value` env assignments) is not exactly `grep` or `diff`
125
+ //
126
+ // FROZEN, narrowly-scoped by design (plan item 4): exactly two binaries, one ambiguous
127
+ // exit code each (1). Do NOT grow this into a general per-binary exit-code table — that
128
+ // is explicitly rejected in the plan (unreliable for pipelines/subshells/compound
129
+ // commands). This function classifies EXIT-CODE ambiguity only; it has no opinion on
130
+ // (and does not touch) the recorded command's regex dialect/flags (BRE vs ERE, -E/-P/-G,
131
+ // etc.) — that is a SEPARATE invariant, enforced at replay time by
132
+ // `resolveTrustedCommand`/`runBackstop` (scripts/hooks/stop-goal-fit.js, search
133
+ // "DIALECT-PRESERVATION INVARIANT"): the recorded command string is replayed verbatim on
134
+ // every backstop re-run path so a `grep -E '(foo|bar)'`-style ERE construct is never
135
+ // silently reinterpreted as BRE.
136
+ function isAmbiguousAbsenceCommand(text) {
137
+ const s = typeof text === 'string' ? text.trim() : '';
138
+ if (!s) return false;
139
+
140
+ // Unwrap a `bash -lc '<command>'` / `sh -c "<command>"` style wrapper so the real
141
+ // command is inspected, not the shell launcher. Only a single level of unwrapping is
142
+ // attempted — this stays a narrow heuristic, not a shell parser.
143
+ let inner = s;
144
+ const wrapped = inner.match(/^(?:bash|sh|zsh)\s+-\S*c\s+(['"])([\s\S]*)\1\s*$/);
145
+ if (wrapped) inner = wrapped[2].trim();
146
+
147
+ if (!inner) return false;
148
+
149
+ // Negation anywhere at the start of the (unwrapped) command already asserts intent.
150
+ if (/^!\s*/.test(inner)) return false;
151
+
152
+ // A `||`/`&&` boolean chain already asserts what a given exit code means for the
153
+ // overall command (e.g. `grep ... || true`, `grep ... && echo found`).
154
+ if (/\|\|/.test(inner) || /&&/.test(inner)) return false;
155
+
156
+ // Strip leading `KEY=value` environment assignments (e.g. `FOO=bar grep ...`) to find
157
+ // the first REAL command token.
158
+ let rest = inner;
159
+ // eslint-disable-next-line no-constant-condition
160
+ while (true) {
161
+ const m = rest.match(/^[A-Za-z_][A-Za-z0-9_]*=\S*\s+(\S[\s\S]*)$/);
162
+ if (!m) break;
163
+ rest = m[1].trim();
164
+ }
165
+
166
+ // A pipe (any `|`, since `||` was already excluded above) into a count-assertion
167
+ // (`grep -c ... | grep -qx 0`, `... | wc -l`) already asserts intent on the MATCH
168
+ // COUNT, not on grep/diff's own raw exit code — so ANY pipe makes this "not a bare
169
+ // invocation" for this narrow heuristic: a bare absence-style grep/diff is by
170
+ // definition not piped anywhere.
171
+ if (rest.includes('|')) return false;
172
+
173
+ const first = rest.trim().split(/\s+/)[0] || '';
174
+ return first === 'grep' || first === 'diff';
175
+ }
176
+
177
+ module.exports = { isRunnableCommandText, isAmbiguousAbsenceCommand };
@@ -0,0 +1,350 @@
1
+ 'use strict';
2
+ /**
3
+ * skill-drift.js — shared pure-CJS manifest/drift-comparison library for installed Claude Code
4
+ * skill files (kontourai/flow-agents#439, slice 1).
5
+ *
6
+ * Zero external dependencies (only Node core: fs, path, crypto, os). Consumed by:
7
+ * - src/cli/init.ts (compiled ESM, via createRequire — writes the manifest at
8
+ * `init --global` sync time, Wave 2)
9
+ * - src/cli/skill-drift-check.ts (compiled ESM, via createRequire — standalone read-only
10
+ * CLI check, Wave 2)
11
+ * - scripts/hooks/workflow-steering.js (CJS, direct require — SessionStart advisory, Wave 2)
12
+ *
13
+ * Purpose (issue #439): this is the SINGLE choke point for classifying installed-skill drift so
14
+ * the manifest writer, the CLI check, and the SessionStart advisory can never disagree with one
15
+ * another about what "in sync" / "stale" / "user-modified" means for a given file. Every consumer
16
+ * calls `compareSkillDrift()` instead of hand-rolling its own comparison, mirroring the same
17
+ * "one shared CJS module, multiple TS/hook consumers" convention already established by
18
+ * `current-pointer.js` (#291) and `actor-identity.js` for this repo's TS/CJS boundary.
19
+ *
20
+ * Hashing convention: `hashFile()` returns `"sha256:<hex>"` — the SAME string prefix convention
21
+ * `src/cli/kit.ts`'s `contentHash`/`kitContentHash` already established for Flow Kit installs.
22
+ * This module does not invent a second hash format (ADR 0008/0010 consume-never-fork).
23
+ *
24
+ * File-walk convention: `walkFilesSorted()`/`relPathPosix()` re-implement the SAME
25
+ * sorted-recursive-`readdirSync` traversal and POSIX-joined relative-path normalization as
26
+ * `src/lib/fs.ts`'s `walkFiles`/`relPath` — byte-identical relative-path strings for the same
27
+ * directory tree. This file cannot `import` that TS module directly (it must stay pure CJS so
28
+ * `workflow-steering.js` can `require()` it directly without a compiled-ESM detour), so it is an
29
+ * intentional, minimal, SAME-SHAPE re-implementation across the CJS boundary, not a fork of
30
+ * behavior — this must not diverge from `walkFiles`/`relPath` at review time.
31
+ *
32
+ * Exports:
33
+ * hashFile(absPath) → "sha256:<hex>" string
34
+ * buildManifest({ skillsSourceDir, runtime }) → manifest object (see below)
35
+ * loadManifest(manifestPath) → parsed manifest object, or null if
36
+ * absent/unreadable (never throws)
37
+ * writeManifestAtomic(manifestPath, manifest) → void (tmp-write-then-rename, same
38
+ * idiom `src/cli/init.ts` uses for
39
+ * `install.json`)
40
+ * compareSkillDrift({ installedDir, kitSourceDir, manifest }) → drift report (see below)
41
+ * resolveClaudeGlobalSkillsDir(env = process.env) → string (the global claude-code dest
42
+ * dir, e.g. `~/.claude`, honoring
43
+ * FLOW_AGENTS_USER_CLAUDE_SETTINGS for
44
+ * test isolation — same resolution
45
+ * `src/cli/init.ts`'s `globalDest`
46
+ * already implements for "claude-code")
47
+ *
48
+ * Manifest shape:
49
+ * {
50
+ * schema_version: "1.0",
51
+ * runtime: string,
52
+ * generatedAt: string (ISO 8601),
53
+ * sourceDir: string,
54
+ * files: { "<skill>/<relpath>": "sha256:<hex>", ... }
55
+ * }
56
+ *
57
+ * Drift report shape (from compareSkillDrift):
58
+ * {
59
+ * checkedAt: string (ISO 8601),
60
+ * installedDir: string,
61
+ * kitSourceDir: string,
62
+ * manifestFound: boolean,
63
+ * files: [{ path, state, installedHash, kitHash, manifestHash }],
64
+ * summary: { total, inSync, kitUpdated, userModified, unbaselined, missingInstall, kitRemoved },
65
+ * hasDrift: boolean
66
+ * }
67
+ *
68
+ * Classification states (one of exactly six per file; names must stay identical across every
69
+ * consumer — the manifest writer, the CLI check, and the hook advisory never hand-roll their own
70
+ * classification strings):
71
+ * - "in_sync" installedHash === kitHash (installed bytes already match current kit
72
+ * source, regardless of manifest).
73
+ * - "kit_updated" installedHash !== kitHash but installedHash === manifestHash (installed
74
+ * copy matches the last recorded baseline; kit source has since changed).
75
+ * - "user_modified" installedHash !== manifestHash (and not in_sync) — installed bytes were
76
+ * changed locally since the last recorded install. This ALWAYS wins over
77
+ * "kit_updated" when both installedHash !== kitHash and
78
+ * installedHash !== manifestHash are true (never silently reclassified as
79
+ * a kit update).
80
+ * - "unbaselined" no manifest entry exists for this path (manifest missing entirely, or
81
+ * present but lacking this path's key) and installedHash !== kitHash —
82
+ * reported distinctly, never conflated with user_modified/kit_updated.
83
+ * - "missing_install" a kit-source file has no installed counterpart at all (skill never
84
+ * synced locally yet) — reported distinctly, though also resolved by the
85
+ * same refresh command as the other non-in_sync states.
86
+ * - "kit_removed" kitHash === null (the path no longer exists anywhere in the current kit
87
+ * source) but the installed file still matches the last recorded baseline
88
+ * exactly (installedHash === manifestHash). Distinct from "kit_updated":
89
+ * the additive `copyDirMerge`-based refresh can only add/update files, it
90
+ * can never delete, so it cannot "fix" this state — reported with guidance
91
+ * to review/remove the installed file manually. Sub-case precedence:
92
+ * kitHash === null AND installedHash !== manifestHash still classifies as
93
+ * "user_modified" (installed bytes differ from the recorded baseline wins
94
+ * over "kit_removed", same precedence rule as the kit_updated case above).
95
+ */
96
+
97
+ const fs = require('fs');
98
+ const path = require('path');
99
+ const crypto = require('crypto');
100
+ const os = require('os');
101
+
102
+ /**
103
+ * Sorted recursive file walk — mirrors `src/lib/fs.ts`'s `walkFiles` exactly (sorted
104
+ * `fs.readdirSync`, depth-first, files only). Returns absolute paths.
105
+ *
106
+ * Uses `readdirSync(dir, { withFileTypes: true })` Dirent type checks — NOT `fs.statSync`, which
107
+ * follows symlinks — so a symlink under the tree (e.g. pointing outside it, or dangling/looped)
108
+ * is neither followed nor hashed, and never throws (ELOOP / ENOENT on a dangling symlink). This
109
+ * is the exact same semantics `copyDirMerge` already uses for install copying
110
+ * (`src/cli/init.ts`): recurse into `entry.isDirectory()`, include `entry.isFile()`, silently
111
+ * skip everything else (symlinks, sockets, etc). Manifest/drift walking and install copying must
112
+ * never disagree about which files exist.
113
+ *
114
+ * @param {string} root
115
+ * @returns {string[]}
116
+ */
117
+ function walkFilesSorted(root) {
118
+ const out = [];
119
+ if (!fs.existsSync(root)) return out;
120
+ const entries = fs.readdirSync(root, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
121
+ for (const entry of entries) {
122
+ const file = path.join(root, entry.name);
123
+ if (entry.isDirectory()) out.push(...walkFilesSorted(file));
124
+ else if (entry.isFile()) out.push(file);
125
+ // else: symlink, socket, fifo, etc — skip (see doc comment above).
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /**
131
+ * POSIX-joined relative path — mirrors `src/lib/fs.ts`'s `relPath` exactly.
132
+ *
133
+ * @param {string} root
134
+ * @param {string} file
135
+ * @returns {string}
136
+ */
137
+ function relPathPosix(root, file) {
138
+ const rel = path.relative(root, file);
139
+ return rel && !rel.startsWith('..') && !path.isAbsolute(rel)
140
+ ? rel.split(path.sep).join('/')
141
+ : file.split(path.sep).join('/');
142
+ }
143
+
144
+ /**
145
+ * `sha256:<hex>` content hash of a single file — same prefix convention as
146
+ * `src/cli/kit.ts`'s `contentHash`/`kitContentHash`.
147
+ *
148
+ * @param {string} absPath
149
+ * @returns {string}
150
+ */
151
+ function hashFile(absPath) {
152
+ const hash = crypto.createHash('sha256');
153
+ hash.update(fs.readFileSync(absPath));
154
+ return `sha256:${hash.digest('hex')}`;
155
+ }
156
+
157
+ /**
158
+ * Builds a per-skill-file content-hash manifest by walking `skillsSourceDir` (e.g. a built
159
+ * `dist/claude-code/.claude/skills` bundle directory). Keys are POSIX relative paths under
160
+ * `skillsSourceDir` (e.g. `pull-work/SKILL.md`).
161
+ *
162
+ * @param {{ skillsSourceDir: string, runtime: string }} params
163
+ * @returns {object} manifest (see module doc comment for shape)
164
+ */
165
+ function buildManifest({ skillsSourceDir, runtime }) {
166
+ const files = {};
167
+ for (const file of walkFilesSorted(skillsSourceDir)) {
168
+ const rel = relPathPosix(skillsSourceDir, file);
169
+ files[rel] = hashFile(file);
170
+ }
171
+ return {
172
+ schema_version: '1.0',
173
+ runtime,
174
+ generatedAt: new Date().toISOString(),
175
+ sourceDir: skillsSourceDir,
176
+ files,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Best-effort tolerant manifest read: missing file or corrupt/unparseable content are BOTH
182
+ * treated as "absent" (returns null), never thrown — mirrors the `readCurrentPointer`-style
183
+ * tolerance already established for advisory reads in this repo.
184
+ *
185
+ * @param {string} manifestPath
186
+ * @returns {object|null}
187
+ */
188
+ function loadManifest(manifestPath) {
189
+ try {
190
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Atomic write: tmp-write-then-rename, the SAME idiom `src/cli/init.ts` already uses for
198
+ * `install.json` (`${path}.tmp.${pid}` then `fs.renameSync`).
199
+ *
200
+ * @param {string} manifestPath
201
+ * @param {object} manifest
202
+ * @returns {void}
203
+ */
204
+ function writeManifestAtomic(manifestPath, manifest) {
205
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
206
+ const tmp = `${manifestPath}.tmp.${process.pid}`;
207
+ fs.writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
208
+ fs.renameSync(tmp, manifestPath);
209
+ }
210
+
211
+ /**
212
+ * Hashes a single relative path under `dir`, if it exists there as a file. Returns null if
213
+ * absent (covers both "not installed yet" and "not present in this kit source snapshot").
214
+ *
215
+ * @param {string} dir
216
+ * @param {string} rel
217
+ * @returns {string|null}
218
+ */
219
+ function hashRelIfPresent(dir, rel) {
220
+ const abs = path.join(dir, rel);
221
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return null;
222
+ try {
223
+ return hashFile(abs);
224
+ } catch {
225
+ // Race between the existsSync/statSync check above and this read (file deleted or made
226
+ // unreadable in between — ENOENT/EACCES) — treat as "hash unavailable" (null), same as "not
227
+ // present", rather than throwing and crashing the whole drift check over one flaky file.
228
+ return null;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Compares installed skill files, the current kit/bundle source, and the last-recorded manifest
234
+ * baseline, classifying every relative path into exactly one of six states. See the module doc
235
+ * comment for the full state semantics and precedence rules.
236
+ *
237
+ * @param {{ installedDir: string, kitSourceDir: string, manifest: object|null }} params
238
+ * @returns {object} drift report (see module doc comment for shape)
239
+ */
240
+ function compareSkillDrift({ installedDir, kitSourceDir, manifest }) {
241
+ const manifestFiles = manifest && manifest.files && typeof manifest.files === 'object' ? manifest.files : null;
242
+
243
+ const relPaths = new Set();
244
+ for (const file of walkFilesSorted(kitSourceDir)) relPaths.add(relPathPosix(kitSourceDir, file));
245
+ for (const file of walkFilesSorted(installedDir)) relPaths.add(relPathPosix(installedDir, file));
246
+ if (manifestFiles) {
247
+ for (const rel of Object.keys(manifestFiles)) {
248
+ // Defense-in-depth: our own writer (`buildManifest`) only ever produces POSIX-relative
249
+ // keys derived from `walkFilesSorted` + `relPathPosix`, so a key that is absolute, contains
250
+ // a backslash, or has any '..' path segment can only come from a malformed or hostile
251
+ // manifest — skip it entirely (never join it onto installedDir/kitSourceDir, never report
252
+ // it) rather than trusting untrusted manifest content into a path.join.
253
+ if (path.isAbsolute(rel) || rel.includes('\\') || rel.split('/').includes('..')) continue;
254
+ relPaths.add(rel);
255
+ }
256
+ }
257
+
258
+ const summary = { total: 0, inSync: 0, kitUpdated: 0, userModified: 0, unbaselined: 0, missingInstall: 0, kitRemoved: 0 };
259
+ const files = [];
260
+
261
+ for (const rel of Array.from(relPaths).sort()) {
262
+ const installedHash = hashRelIfPresent(installedDir, rel);
263
+ const kitHash = hashRelIfPresent(kitSourceDir, rel);
264
+ const manifestHash = manifestFiles && Object.prototype.hasOwnProperty.call(manifestFiles, rel) ? manifestFiles[rel] : null;
265
+
266
+ // A path recorded only in a stale manifest, with no counterpart in the kit source NOR the
267
+ // installed dir any more, is nothing currently installable or driftable — skip it rather
268
+ // than inventing a seventh state for it.
269
+ if (installedHash === null && kitHash === null) continue;
270
+
271
+ // An installed-only path with no kit-source counterpart AND no manifest entry is outside the
272
+ // kit's jurisdiction entirely — e.g. another tool's skill directory living alongside
273
+ // kit-installed skills under the same shared `~/.claude/skills` dest. `copyDirMerge`'s doc
274
+ // contract (`src/cli/init.ts`) is explicit that the dest may hold unrelated content it must
275
+ // never touch; the drift classifier honors the same boundary by staying silent about paths it
276
+ // never owned, rather than reporting them "unbaselined". `unbaselined` stays reserved for
277
+ // kit-owned paths (kitHash !== null) that simply predate the manifest baseline; `kit_removed`
278
+ // is unaffected — a manifest entry there proves prior kit ownership, so it never reaches this
279
+ // skip (manifestHash === null is required here).
280
+ if (kitHash === null && manifestHash === null) continue;
281
+
282
+ let state;
283
+ if (kitHash !== null && installedHash === null) {
284
+ state = 'missing_install';
285
+ } else if (installedHash !== null && kitHash !== null && installedHash === kitHash) {
286
+ state = 'in_sync';
287
+ } else if (manifestHash === null) {
288
+ state = 'unbaselined';
289
+ } else if (installedHash !== manifestHash) {
290
+ // user_modified always wins over kit_updated/kit_removed when installed bytes differ from
291
+ // the recorded baseline — see module doc comment. This precedence also covers the
292
+ // kitHash === null sub-case (file removed from kit source AND locally modified): still
293
+ // reported as user_modified, never kit_removed.
294
+ state = 'user_modified';
295
+ } else if (kitHash === null) {
296
+ // Installed file matches the last recorded baseline exactly, but no longer exists anywhere
297
+ // in the current kit source (removed upstream). Distinct from kit_updated: the additive
298
+ // copyDirMerge-based refresh cannot delete, so it cannot "fix" this state.
299
+ state = 'kit_removed';
300
+ } else {
301
+ state = 'kit_updated';
302
+ }
303
+
304
+ files.push({ path: rel, state, installedHash, kitHash, manifestHash });
305
+ summary.total += 1;
306
+ if (state === 'in_sync') summary.inSync += 1;
307
+ else if (state === 'kit_updated') summary.kitUpdated += 1;
308
+ else if (state === 'user_modified') summary.userModified += 1;
309
+ else if (state === 'unbaselined') summary.unbaselined += 1;
310
+ else if (state === 'missing_install') summary.missingInstall += 1;
311
+ else if (state === 'kit_removed') summary.kitRemoved += 1;
312
+ }
313
+
314
+ return {
315
+ checkedAt: new Date().toISOString(),
316
+ installedDir,
317
+ kitSourceDir,
318
+ manifestFound: manifest !== null,
319
+ files,
320
+ summary,
321
+ hasDrift: files.some((entry) => entry.state !== 'in_sync'),
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Resolves the global claude-code destination directory (e.g. `~/.claude`), honoring
327
+ * `FLOW_AGENTS_USER_CLAUDE_SETTINGS` for test isolation — the SAME resolution
328
+ * `src/cli/init.ts`'s `globalDest("claude-code")` already implements, re-implemented here in
329
+ * pure CJS so `workflow-steering.js` can call it directly without a `createRequire`-of-a-
330
+ * differently-shaped-compiled-path detour. `src/cli/skill-drift-check.ts` instead calls the
331
+ * real exported `globalDest` from `init.ts` directly (already TS-to-TS, no duplication needed
332
+ * there).
333
+ *
334
+ * @param {NodeJS.ProcessEnv} env
335
+ * @returns {string}
336
+ */
337
+ function resolveClaudeGlobalSkillsDir(env = process.env) {
338
+ const override = env['FLOW_AGENTS_USER_CLAUDE_SETTINGS'];
339
+ if (override) return path.dirname(override);
340
+ return path.join(os.homedir(), '.claude');
341
+ }
342
+
343
+ module.exports = {
344
+ hashFile,
345
+ buildManifest,
346
+ loadManifest,
347
+ writeManifestAtomic,
348
+ compareSkillDrift,
349
+ resolveClaudeGlobalSkillsDir,
350
+ };