@kontourai/flow-agents 2.3.0 → 2.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 (250) hide show
  1. package/.github/CODEOWNERS +8 -0
  2. package/.github/workflows/ci.yml +32 -0
  3. package/AGENTS.md +4 -4
  4. package/CHANGELOG.md +39 -0
  5. package/CONTEXT.md +14 -0
  6. package/README.md +9 -3
  7. package/agents/dev.json +1 -1
  8. package/agents/tool-code-reviewer.json +1 -1
  9. package/agents/tool-planner.json +3 -3
  10. package/agents/tool-verifier.json +3 -3
  11. package/build/src/cli/console-learning-projection.js +3 -2
  12. package/build/src/cli/init.js +104 -21
  13. package/build/src/cli/kit.js +37 -6
  14. package/build/src/cli/pull-work-provider.js +1 -1
  15. package/build/src/cli/usage-feedback.js +3 -3
  16. package/build/src/cli/validate-hook-influence.js +1 -0
  17. package/build/src/cli/validate-workflow-artifacts.js +22 -6
  18. package/build/src/cli/workflow-artifact-cleanup-audit.js +36 -2
  19. package/build/src/cli/workflow-sidecar.d.ts +14 -7
  20. package/build/src/cli/workflow-sidecar.js +792 -69
  21. package/build/src/flow-kit/validate.d.ts +23 -0
  22. package/build/src/flow-kit/validate.js +67 -1
  23. package/build/src/index.d.ts +1 -1
  24. package/build/src/index.js +1 -1
  25. package/build/src/lib/flow-resolver.d.ts +6 -4
  26. package/build/src/lib/flow-resolver.js +125 -58
  27. package/build/src/lib/local-artifact-root.d.ts +14 -2
  28. package/build/src/lib/local-artifact-root.js +22 -5
  29. package/build/src/lib/workflow-learning-projection.js +2 -2
  30. package/build/src/runtime-adapters.d.ts +12 -0
  31. package/build/src/runtime-adapters.js +56 -16
  32. package/build/src/tools/build-universal-bundles.js +49 -16
  33. package/build/src/tools/generate-context-map.js +10 -8
  34. package/build/src/tools/validate-source-tree.js +23 -1
  35. package/console.telemetry.json +22 -0
  36. package/context/contracts/artifact-contract.md +38 -7
  37. package/context/contracts/builder-kit-workflow-state-contract.md +3 -2
  38. package/context/contracts/decision-registry-contract.md +263 -0
  39. package/context/contracts/delivery-contract.md +1 -1
  40. package/context/contracts/planning-contract.md +2 -1
  41. package/context/contracts/review-contract.md +5 -3
  42. package/context/contracts/verification-contract.md +9 -1
  43. package/context/contracts/work-item-contract.md +1 -1
  44. package/context/deferred/parallelization.md +1 -1
  45. package/context/gate-awareness.md +4 -4
  46. package/context/scripts/git-status.sh +2 -2
  47. package/context/scripts/hooks/config-protection.js +502 -2
  48. package/context/scripts/hooks/lib/config-protection-remedies.js +69 -0
  49. package/context/scripts/hooks/run-hook.js +14 -1
  50. package/context/scripts/hooks/stop-goal-fit.js +1140 -99
  51. package/context/scripts/hooks/workflow-steering.js +212 -6
  52. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  53. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +35 -0
  54. package/docs/adr/0019-kit-dependency-ownership.md +62 -0
  55. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +290 -0
  56. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +221 -0
  57. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +283 -0
  58. package/docs/agent-system-guidebook.md +3 -3
  59. package/docs/agent-usage-feedback-loop.md +6 -6
  60. package/docs/context-map.md +9 -7
  61. package/docs/decisions/decision-records.md +57 -0
  62. package/docs/decisions/index.md +14 -0
  63. package/docs/decisions/promotion-gate.md +52 -0
  64. package/docs/developer-architecture.md +1 -1
  65. package/docs/fixture-ownership.md +3 -0
  66. package/docs/flow-kit-repository-contract.md +5 -5
  67. package/docs/getting-started.md +12 -2
  68. package/docs/integrations/harness-install.md +2 -1
  69. package/docs/kit-authoring-guide.md +64 -5
  70. package/docs/knowledge-kit.md +5 -1
  71. package/docs/learnings/2026-07-improvement-program.md +203 -0
  72. package/docs/migrations.md +6 -5
  73. package/docs/north-star.md +2 -2
  74. package/docs/operating-layers.md +4 -3
  75. package/docs/repository-structure.md +5 -5
  76. package/docs/skills-map.md +8 -8
  77. package/docs/spec/runtime-hook-surface.md +19 -15
  78. package/docs/spikes/graph-provider-2026-07.md +155 -0
  79. package/docs/standards-register.md +7 -7
  80. package/docs/trust-anchor-adoption.md +105 -4
  81. package/docs/work-item-adapters.md +1 -1
  82. package/docs/workflow-artifact-lifecycle.md +72 -9
  83. package/docs/workflow-eval-strategy.md +3 -3
  84. package/docs/workflow-shared-contracts.md +1 -1
  85. package/docs/workflow-usage-guide.md +254 -21
  86. package/evals/acceptance/DEMO-false-completion.md +20 -11
  87. package/evals/acceptance/prove-capture-teeth-declared.sh +15 -15
  88. package/evals/acceptance/prove-capture-teeth.sh +11 -11
  89. package/evals/acceptance/test_kiro_harness.sh +1 -1
  90. package/evals/ci/antigaming-suite.sh +5 -0
  91. package/evals/ci/run-baseline.sh +65 -1
  92. package/evals/fixtures/flow-kit-repository/invalid-bad-dependency/flows/review.flow.json +26 -0
  93. package/evals/fixtures/flow-kit-repository/invalid-bad-dependency/kit.json +13 -0
  94. package/evals/fixtures/flow-kit-repository/valid-with-dependency/flows/review.flow.json +26 -0
  95. package/evals/fixtures/flow-kit-repository/valid-with-dependency/kit.json +13 -0
  96. package/evals/fixtures/hook-influence/cases.json +32 -0
  97. package/evals/fixtures/pull-work-provider/github-issues.json +6 -6
  98. package/evals/fixtures/trust-reconcile-exploits/fabricated-attestation.json +59 -0
  99. package/evals/fixtures/trust-reconcile-exploits/no-label-bypass.json +36 -0
  100. package/evals/fixtures/trust-reconcile-exploits/skip-assumed-bypass.json +59 -0
  101. package/evals/fixtures/trust-reconcile-exploits/status-misassertion.json +48 -0
  102. package/evals/fixtures/trust-reconcile-exploits/waived-command-check.json +44 -0
  103. package/evals/fixtures/trust-reconcile-mixed-bundle/mixed-bundle.json +159 -0
  104. package/evals/fixtures/trust-reconcile-ws3/ws3-bundle.json +920 -0
  105. package/evals/integration/test_actor_identity.sh +254 -0
  106. package/evals/integration/test_builder_step_producers.sh +33 -23
  107. package/evals/integration/test_bundle_install.sh +22 -16
  108. package/evals/integration/test_bundle_lifecycle.sh +11 -11
  109. package/evals/integration/test_captured_fail_reconciliation.sh +50 -50
  110. package/evals/integration/test_checkpoint_signing.sh +43 -0
  111. package/evals/integration/test_claim_lookup.sh +4 -4
  112. package/evals/integration/test_codex_hook_resolution.sh +114 -0
  113. package/evals/integration/test_command_log_concurrency.sh +8 -8
  114. package/evals/integration/test_command_log_fork_classification.sh +3 -3
  115. package/evals/integration/test_command_log_integrity.sh +16 -16
  116. package/evals/integration/test_console_learning_projection.sh +1 -1
  117. package/evals/integration/test_dual_emit_flow_step.sh +145 -0
  118. package/evals/integration/test_enforcer_expects_driven.sh +12 -12
  119. package/evals/integration/test_evidence_capture_hook.sh +14 -13
  120. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  121. package/evals/integration/test_flow_agents_statusline.sh +2 -2
  122. package/evals/integration/test_flow_kit_install_git.sh +52 -0
  123. package/evals/integration/test_flow_kit_repository.sh +2 -0
  124. package/evals/integration/test_flowdef_session_activation.sh +6 -6
  125. package/evals/integration/test_flowdef_union_floor_regression.sh +429 -0
  126. package/evals/integration/test_gate_bypass_chain.sh +52 -40
  127. package/evals/integration/test_gate_lockdown.sh +97 -73
  128. package/evals/integration/test_gate_review_inquiry_records.sh +3 -3
  129. package/evals/integration/test_goal_fit_escape_hatch.sh +10 -10
  130. package/evals/integration/test_goal_fit_ghost_session.sh +104 -0
  131. package/evals/integration/test_goal_fit_hook.sh +32 -32
  132. package/evals/integration/test_goal_fit_rederive.sh +9 -9
  133. package/evals/integration/test_hook_category_behaviors.sh +103 -0
  134. package/evals/integration/test_hook_influence_cases.sh +1 -0
  135. package/evals/integration/test_install_merge.sh +262 -1
  136. package/evals/integration/test_kit_identity_trust.sh +6 -6
  137. package/evals/integration/test_liveness_conflict_injection.sh +587 -0
  138. package/evals/integration/test_liveness_heartbeat.sh +677 -0
  139. package/evals/integration/test_liveness_verdict.sh +394 -0
  140. package/evals/integration/test_local_flow_kit_install.sh +15 -0
  141. package/evals/integration/test_phase_map_and_gate_claim.sh +63 -11
  142. package/evals/integration/test_promote_gate.sh +178 -0
  143. package/evals/integration/test_publish_delivery.sh +2 -2
  144. package/evals/integration/test_pull_work_liveness_preflight.sh +386 -0
  145. package/evals/integration/test_pull_work_provider.sh +2 -2
  146. package/evals/integration/test_reconcile_soundness.sh +4 -4
  147. package/evals/integration/test_resolvefirststep_security.sh +22 -0
  148. package/evals/integration/test_runtime_adapter_activation.sh +87 -18
  149. package/evals/integration/test_session_resume_roundtrip.sh +147 -0
  150. package/evals/integration/test_sidecar_field_preservation.sh +249 -0
  151. package/evals/integration/test_trust_reconcile.sh +2 -2
  152. package/evals/integration/test_trust_reconcile_manifest.sh +79 -0
  153. package/evals/integration/test_trust_reconcile_mixed_bundle.sh +96 -0
  154. package/evals/integration/test_trust_reconcile_negatives.sh +151 -0
  155. package/evals/integration/test_usage_feedback_global.sh +5 -5
  156. package/evals/integration/test_validate_artifacts_portability.sh +60 -0
  157. package/evals/integration/test_verify_cli.sh +2 -2
  158. package/evals/integration/test_veritas_governance_kit.sh +117 -0
  159. package/evals/integration/test_workflow_artifact_cleanup_audit.sh +26 -1
  160. package/evals/integration/test_workflow_artifacts.sh +33 -0
  161. package/evals/integration/test_workflow_sidecar_writer.sh +723 -74
  162. package/evals/integration/test_workflow_steering_hook.sh +175 -5
  163. package/evals/lib/codex-provider.sh +1 -1
  164. package/evals/lib/node.sh +19 -1
  165. package/evals/run.sh +22 -0
  166. package/evals/static/test_decisions.sh +150 -0
  167. package/evals/static/test_flowdef_codeowners_coverage.sh +56 -0
  168. package/evals/static/test_package.sh +19 -10
  169. package/evals/static/test_universal_bundles.sh +48 -3
  170. package/evals/static/test_workflow_skills.sh +41 -8
  171. package/kits/builder/flows/build.flow.json +3 -74
  172. package/kits/builder/flows/publish-learn.flow.json +90 -0
  173. package/kits/builder/kit.json +11 -0
  174. package/kits/builder/skills/builder-shape/SKILL.md +1 -1
  175. package/kits/builder/skills/deliver/SKILL.md +13 -11
  176. package/kits/builder/skills/design-probe/SKILL.md +4 -4
  177. package/kits/builder/skills/evidence-gate/SKILL.md +26 -1
  178. package/kits/builder/skills/execute-plan/SKILL.md +2 -2
  179. package/kits/builder/skills/fix-bug/SKILL.md +2 -0
  180. package/kits/builder/skills/gate-review/SKILL.md +6 -6
  181. package/kits/builder/skills/idea-to-backlog/SKILL.md +1 -1
  182. package/kits/builder/skills/learning-review/SKILL.md +7 -7
  183. package/kits/builder/skills/pickup-probe/SKILL.md +5 -4
  184. package/kits/builder/skills/plan-work/SKILL.md +27 -5
  185. package/kits/builder/skills/pull-work/SKILL.md +80 -5
  186. package/kits/builder/skills/release-readiness/SKILL.md +4 -4
  187. package/kits/builder/skills/review-work/SKILL.md +24 -1
  188. package/kits/builder/skills/tdd-workflow/SKILL.md +2 -0
  189. package/kits/builder/skills/verify-work/SKILL.md +8 -1
  190. package/kits/catalog.json +6 -0
  191. package/kits/veritas-governance/adapter/readiness-to-trust-bundle.mjs +178 -0
  192. package/kits/veritas-governance/docs/README.md +75 -0
  193. package/kits/veritas-governance/fixtures/readiness/not-ready.readiness-report.json +4645 -0
  194. package/kits/veritas-governance/fixtures/readiness/ready.readiness-report.json +4403 -0
  195. package/kits/veritas-governance/flows/readiness-check.flow.json +35 -0
  196. package/kits/veritas-governance/kit.json +16 -0
  197. package/package.json +8 -4
  198. package/packaging/README.md +2 -2
  199. package/packaging/conformance/fixtures/config-protection--allow-read-state-json.json +20 -0
  200. package/packaging/conformance/fixtures/config-protection--block-write-state-json.json +20 -0
  201. package/packaging/conformance/fixtures/stop-goal-fit--block-bundle-disputed-claim.json +1 -1
  202. package/packaging/conformance/fixtures/stop-goal-fit--block-capture-contradicts-claimed-pass.json +1 -1
  203. package/packaging/manifest.json +9 -10
  204. package/schemas/decision-record.schema.json +109 -0
  205. package/schemas/workflow-evidence.schema.json +1 -1
  206. package/schemas/workflow-state.schema.json +5 -0
  207. package/scripts/README.md +8 -3
  208. package/scripts/check-content-boundary.cjs +1 -1
  209. package/scripts/check-decisions.cjs +356 -0
  210. package/scripts/ci/derive-claim-status.mjs +74 -0
  211. package/scripts/ci/trust-reconcile.js +436 -79
  212. package/scripts/git-status.sh +2 -2
  213. package/scripts/hooks/claude-telemetry-hook.js +37 -3
  214. package/scripts/hooks/codex-telemetry-hook.js +36 -2
  215. package/scripts/hooks/config-protection.js +102 -30
  216. package/scripts/hooks/evidence-capture.js +3 -3
  217. package/scripts/hooks/lib/actor-identity.js +368 -0
  218. package/scripts/hooks/lib/config-protection-remedies.js +69 -0
  219. package/scripts/hooks/lib/liveness-heartbeat.js +361 -0
  220. package/scripts/hooks/lib/liveness-policy.js +127 -0
  221. package/scripts/hooks/lib/liveness-read.js +105 -12
  222. package/scripts/hooks/lib/liveness-write.js +46 -0
  223. package/scripts/hooks/lib/local-artifact-paths.js +12 -6
  224. package/scripts/hooks/opencode-telemetry-hook.js +27 -1
  225. package/scripts/hooks/pi-telemetry-hook.js +27 -1
  226. package/scripts/hooks/stop-goal-fit.js +157 -26
  227. package/scripts/hooks/workflow-steering.js +77 -3
  228. package/scripts/install-codex-home.sh +138 -38
  229. package/scripts/statusline/flow-agents-statusline.js +2 -2
  230. package/src/cli/console-learning-projection.ts +3 -2
  231. package/src/cli/init.ts +101 -21
  232. package/src/cli/kit.ts +37 -6
  233. package/src/cli/public-api.test.mjs +58 -6
  234. package/src/cli/pull-work-provider.ts +1 -1
  235. package/src/cli/trust-bundle-policy-order.test.mjs +87 -0
  236. package/src/cli/usage-feedback.ts +3 -3
  237. package/src/cli/validate-hook-influence.ts +1 -0
  238. package/src/cli/validate-workflow-artifacts.ts +22 -6
  239. package/src/cli/veritas-readiness-adapter.test.mjs +267 -0
  240. package/src/cli/workflow-artifact-cleanup-audit.ts +35 -2
  241. package/src/cli/workflow-sidecar.ts +795 -71
  242. package/src/flow-kit/validate.ts +74 -1
  243. package/src/index.ts +6 -2
  244. package/src/lib/flow-resolver.ts +123 -55
  245. package/src/lib/local-artifact-root.ts +24 -5
  246. package/src/lib/workflow-learning-projection.ts +2 -2
  247. package/src/runtime-adapters.ts +61 -15
  248. package/src/tools/build-universal-bundles.ts +49 -16
  249. package/src/tools/generate-context-map.ts +10 -8
  250. package/src/tools/validate-source-tree.ts +23 -1
@@ -0,0 +1,368 @@
1
+ 'use strict';
2
+ /**
3
+ * actor-identity.js — shared pure-CJS runtime-agnostic actor identity resolver
4
+ *
5
+ * Zero external dependencies (only Node core: fs, os, crypto, child_process).
6
+ * Consumed by:
7
+ * - scripts/hooks/workflow-steering.js (CJS, direct require)
8
+ * - build/src/cli/workflow-sidecar.js (ESM compiled, via createRequire)
9
+ *
10
+ * Purpose (issue #287): retire the shared literal `"local"` liveness-actor
11
+ * default. A fresh Bash-tool subshell cannot inherit env exported by a
12
+ * sibling hook subprocess, so this module recomputes the same actor on
13
+ * every invocation from a priority chain, rather than relying on any
14
+ * hook-writes-env or last-writer-wins persisted file:
15
+ *
16
+ * 1. `FLOW_AGENTS_ACTOR` env override, explicit, always wins (unless it is
17
+ * the literal `"local"`, case-insensitive, or it strips to empty under
18
+ * the allowed `[A-Za-z0-9_.-]` charset — neither value can round trip
19
+ * back in via the override seam; either rejection emits one stderr
20
+ * warning line, never silently substituting the shared `"unknown"`
21
+ * sentinel). A deliberate literal `"unknown"` override still passes
22
+ * through (it sanitizes to itself). The accepted override value is
23
+ * passed through `sanitizeSegment` (64-char cap; strips `:`) before use
24
+ * — it is never returned verbatim.
25
+ * 2. A runtime-native session-id env var already ambient in the current
26
+ * process's own environment (confirmed for Claude Code:
27
+ * `CLAUDECODE`/`CLAUDE_CODE_SESSION_ID`; Codex/opencode/pi candidate
28
+ * var names below are UNVERIFIED in this planning pass — accepted gap,
29
+ * see plan artifact — layer 3 is the correctness backstop for them).
30
+ * 3. A process-ancestry fallback: `process.ppid` plus that parent
31
+ * process's exact start timestamp (an absolute timestamp, not an
32
+ * elapsed-time subtraction, to avoid clock-drift flicker across
33
+ * repeated invocations seconds/minutes apart within one session),
34
+ * hashed into a short opaque token. Works identically for a Bash-tool
35
+ * shell or a hook subprocess, since both are children of the same
36
+ * long-lived runtime process for that session.
37
+ *
38
+ * Accepted gap: sandboxed/containerized environments without a working
39
+ * `ps`/`/proc` degrade the ancestry fallback to a PID-only seed (no
40
+ * start-time component), which is collision-prone under PID reuse on a
41
+ * long-uptime host. This is documented, not silently absorbed as "solved".
42
+ *
43
+ * Exports:
44
+ * detectRuntime(env) → "claude-code" | "codex" | "opencode" | "pi" | "unknown"
45
+ * runtimeSessionId(env) → first non-empty runtime-native session id, else ""
46
+ * ancestorActorSeed() → short opaque token from parent PID + start time, else ""
47
+ * sanitizeSegment(value) → value restricted to [A-Za-z0-9_.-], capped 64 chars
48
+ * serializeActor(actor) → actor struct serialized to a single grouping-key-safe string
49
+ * resolveActor(env) → { actor: string, source: string }
50
+ * isUnresolvedActor(actor) → boolean (true when actor is empty or the retired literal
51
+ * "local", case-insensitive — single-sourced predicate shared by
52
+ * the lifecycle auto-emit path, the direct CLI liveness path, and
53
+ * (#288) the tool-activity heartbeat path, so all three can never
54
+ * disagree on what counts as "no usable actor")
55
+ */
56
+
57
+ const fs = require('fs');
58
+ const os = require('os');
59
+ const crypto = require('crypto');
60
+ const { execFileSync } = require('child_process');
61
+
62
+ /** Candidate env var names for each runtime's native session id. */
63
+ const RUNTIME_SESSION_ID_VARS = {
64
+ 'claude-code': 'CLAUDE_CODE_SESSION_ID',
65
+ // Codex/opencode/pi candidate names are UNVERIFIED (no confirmed spike this
66
+ // planning pass) — accepted gap. Detection failure never blocks resolution;
67
+ // the process-ancestry fallback (layer 3) covers these runtimes either way.
68
+ codex: 'CODEX_SESSION_ID',
69
+ opencode: 'OPENCODE_SESSION_ID',
70
+ pi: 'PI_SESSION_ID',
71
+ };
72
+
73
+ /**
74
+ * Detect which coding-agent runtime the current process is running under.
75
+ * Detection failure must never block actor resolution — always falls back
76
+ * to "unknown" rather than throwing.
77
+ *
78
+ * @param {NodeJS.ProcessEnv} [env] Environment to inspect (default process.env)
79
+ * @returns {"claude-code"|"codex"|"opencode"|"pi"|"unknown"}
80
+ */
81
+ function detectRuntime(env = process.env) {
82
+ env = env || {};
83
+ if (env.CLAUDECODE === '1' || String(env.CLAUDE_CODE_SESSION_ID || '').trim()) {
84
+ return 'claude-code';
85
+ }
86
+ if (String(env.CODEX_SESSION_ID || '').trim()) return 'codex';
87
+ if (String(env.OPENCODE_SESSION_ID || '').trim()) return 'opencode';
88
+ if (String(env.PI_SESSION_ID || '').trim()) return 'pi';
89
+ return 'unknown';
90
+ }
91
+
92
+ /**
93
+ * Return the first non-empty runtime-native session id candidate present in
94
+ * the given environment, checked in a fixed order across all known
95
+ * runtimes (not just the detected one, so a misdetected/ambiguous env still
96
+ * resolves a usable id).
97
+ *
98
+ * @param {NodeJS.ProcessEnv} [env] Environment to inspect (default process.env)
99
+ * @returns {string} Non-empty session id, or "" if none present
100
+ */
101
+ function runtimeSessionId(env = process.env) {
102
+ env = env || {};
103
+ for (const varName of Object.values(RUNTIME_SESSION_ID_VARS)) {
104
+ const candidate = String(env[varName] || '').trim();
105
+ if (candidate) return candidate;
106
+ }
107
+ return '';
108
+ }
109
+
110
+ /**
111
+ * Read the parent process's absolute start timestamp on BSD/macOS via
112
+ * `ps -o lstart= -p <ppid>` (parsed with Date.parse — an absolute wall-clock
113
+ * timestamp, not an elapsed-time subtraction).
114
+ *
115
+ * @param {number} ppid
116
+ * @returns {string} Epoch-ms string, or "" if unobtainable
117
+ */
118
+ function getBsdAncestorStartTimeMs(ppid) {
119
+ try {
120
+ const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(ppid)], {
121
+ encoding: 'utf8',
122
+ stdio: ['ignore', 'pipe', 'ignore'],
123
+ timeout: 5000,
124
+ });
125
+ const trimmed = String(out || '').trim();
126
+ if (!trimmed) return '';
127
+ const parsedMs = Date.parse(trimmed);
128
+ return Number.isNaN(parsedMs) ? '' : String(parsedMs);
129
+ } catch {
130
+ return '';
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Read the parent process's absolute start timestamp on Linux via
136
+ * `/proc/<ppid>/stat` field 22 (starttime, in clock ticks since boot)
137
+ * converted through `/proc/stat`'s `btime` (boot time, seconds since epoch)
138
+ * plus the clock-tick rate (via `getconf CLK_TCK`, falling back to the
139
+ * standard USER_HZ default of 100 if unavailable). This yields an absolute
140
+ * epoch timestamp, not an elapsed-time subtraction.
141
+ *
142
+ * @param {number} ppid
143
+ * @returns {string} Epoch-ms string, or "" if unobtainable
144
+ */
145
+ function getLinuxAncestorStartTimeMs(ppid) {
146
+ try {
147
+ const statRaw = fs.readFileSync(`/proc/${ppid}/stat`, 'utf8');
148
+ // The comm field (2nd field) is parenthesized and may itself contain
149
+ // spaces/parens, so split on the *last* ")" rather than whitespace.
150
+ const closeParen = statRaw.lastIndexOf(')');
151
+ if (closeParen === -1) return '';
152
+ const rest = statRaw.slice(closeParen + 2).trim().split(/\s+/);
153
+ // Fields after ")" start at field 3 (state); field 22 (starttime) is
154
+ // therefore index 22 - 3 = 19 in this zero-indexed remainder array.
155
+ const startTicks = Number(rest[19]);
156
+ if (!Number.isFinite(startTicks)) return '';
157
+
158
+ const procStat = fs.readFileSync('/proc/stat', 'utf8');
159
+ const btimeMatch = procStat.match(/^btime\s+(\d+)/m);
160
+ if (!btimeMatch) return '';
161
+ const btimeSeconds = Number(btimeMatch[1]);
162
+ if (!Number.isFinite(btimeSeconds)) return '';
163
+
164
+ let clockTicksPerSec = 100; // USER_HZ default; accepted approximation if getconf fails.
165
+ try {
166
+ const getconfOut = execFileSync('getconf', ['CLK_TCK'], {
167
+ encoding: 'utf8',
168
+ stdio: ['ignore', 'pipe', 'ignore'],
169
+ timeout: 5000,
170
+ }).trim();
171
+ const parsedTicks = Number(getconfOut);
172
+ if (Number.isFinite(parsedTicks) && parsedTicks > 0) clockTicksPerSec = parsedTicks;
173
+ } catch {
174
+ /* fall back to the USER_HZ default above */
175
+ }
176
+
177
+ const startSeconds = btimeSeconds + startTicks / clockTicksPerSec;
178
+ return String(Math.round(startSeconds * 1000));
179
+ } catch {
180
+ return '';
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Compute a runtime-agnostic process-ancestry seed: the current process's
186
+ * parent PID plus that parent's exact (absolute) start timestamp, hashed
187
+ * into a short opaque token. Stable across repeated invocations within the
188
+ * same session (same parent PID, same start time), distinct across
189
+ * concurrent sessions on one host (different parent process per session).
190
+ *
191
+ * Degrades to a PID-only seed (documented accepted gap — collision-prone
192
+ * under PID reuse on a long-uptime host) when the start timestamp cannot be
193
+ * obtained (no working `ps`/`/proc`, e.g. some sandboxes). Returns "" only
194
+ * in the near-impossible case that `process.ppid` itself is unavailable.
195
+ *
196
+ * @returns {string} Short opaque hex token, or ""
197
+ */
198
+ function ancestorActorSeed() {
199
+ try {
200
+ const ppid = process.ppid;
201
+ if (!ppid || ppid <= 0) return '';
202
+
203
+ const startTimeMs =
204
+ process.platform === 'linux'
205
+ ? getLinuxAncestorStartTimeMs(ppid)
206
+ : getBsdAncestorStartTimeMs(ppid);
207
+
208
+ const seedInput = startTimeMs ? `${ppid}:${startTimeMs}` : `pid-only:${ppid}`;
209
+ return crypto.createHash('sha1').update(seedInput).digest('hex').slice(0, 12);
210
+ } catch {
211
+ return '';
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Restrict a value to a grouping-key-safe segment: strip everything outside
217
+ * `[A-Za-z0-9_.-]`, cap at 64 chars, and fall back to `"unknown"` if empty
218
+ * after stripping. Guarantees the result never contains `:` (so it can
219
+ * never collide with the `::` grouping delimiter used elsewhere) and is
220
+ * never an empty string (so joined segments never produce doubled `:`).
221
+ *
222
+ * @param {*} value
223
+ * @returns {string}
224
+ */
225
+ function sanitizeSegment(value) {
226
+ const cleaned = String(value == null ? '' : value)
227
+ .replace(/[^A-Za-z0-9_.-]/g, '')
228
+ .slice(0, 64);
229
+ return cleaned || 'unknown';
230
+ }
231
+
232
+ /**
233
+ * Serialize a runtime-agnostic actor struct into a single string safe for
234
+ * the existing `${subjectId}::${actor}` grouping key: each field is passed
235
+ * through sanitizeSegment (so no raw `:` can appear inside any segment),
236
+ * then joined with a single `:` delimiter (never doubled).
237
+ *
238
+ * @param {{runtime?: string, session_id?: string, host?: string, human?: string}} actor
239
+ * @returns {string}
240
+ */
241
+ function serializeActor(actor) {
242
+ actor = actor || {};
243
+ const parts = [
244
+ sanitizeSegment(actor.runtime),
245
+ sanitizeSegment(actor.session_id),
246
+ sanitizeSegment(actor.host),
247
+ ];
248
+ if (actor.human != null && String(actor.human).trim() !== '') {
249
+ parts.push(sanitizeSegment(actor.human));
250
+ }
251
+ return parts.join(':');
252
+ }
253
+
254
+ /**
255
+ * Resolve the current process's actor identity via the priority chain:
256
+ * 1. `env.FLOW_AGENTS_ACTOR` (trimmed, non-empty, not literal "local"
257
+ * case-insensitive, and not stripping to empty under the allowed
258
+ * `[A-Za-z0-9_.-]` charset) — wins outright, but is passed through
259
+ * `sanitizeSegment` before being returned (64-char cap; strips `:`) so
260
+ * it is always grouping-key-safe and display-safe. Callers must NOT
261
+ * assume this value is returned verbatim — it never is. A deliberate
262
+ * literal `"unknown"` override is honored as-is (it sanitizes to
263
+ * itself) — only a value that strips to empty is rejected.
264
+ * 2. Runtime-native session id (via runtimeSessionId), serialized with
265
+ * detectRuntime() and os.hostname().
266
+ * 3. Process-ancestry fallback (via ancestorActorSeed), serialized the
267
+ * same way.
268
+ *
269
+ * `actor` is `""` only if every layer failed (near-impossible in practice,
270
+ * since layer 3 has no external dependency).
271
+ *
272
+ * When `FLOW_AGENTS_ACTOR` is set but rejected — either because it is the
273
+ * literal `"local"` (case-insensitive) or because it strips to empty under
274
+ * the allowed charset (e.g. `":::"`) — a single diagnostic line is written
275
+ * to `process.stderr` (never stdout, never thrown) so the substitution is
276
+ * never silent, and resolution falls through to the runtime/ancestry
277
+ * derivation below rather than adopting the shared `"unknown"` sentinel:
278
+ * - literal "local": `[actor-identity] ignoring FLOW_AGENTS_ACTOR=local
279
+ * (reserved legacy value); using derived actor`.
280
+ * - strips to empty: `[actor-identity] ignoring FLOW_AGENTS_ACTOR override
281
+ * (strips to empty under allowed charset [A-Za-z0-9_.-]); using derived
282
+ * actor`.
283
+ *
284
+ * Test-only escape hatch: requires BOTH
285
+ * `env.FLOW_AGENTS_ACTOR_TEST_FORCE_UNRESOLVED === "1"` AND
286
+ * `env.NODE_ENV === "test"` — short-circuits to `{ actor: "", source:
287
+ * "test-forced-unresolved" }` before any real detection runs. This lets
288
+ * tests prove the fail-loud path deterministically without sabotaging
289
+ * `ps`/`/proc`, while the `NODE_ENV === "test"` requirement prevents the
290
+ * hatch from being tripped by an accidental/malicious env var outside a
291
+ * test harness.
292
+ *
293
+ * @param {NodeJS.ProcessEnv} [env] Environment to resolve from (default process.env)
294
+ * @returns {{actor: string, source: string}}
295
+ */
296
+ function resolveActor(env = process.env) {
297
+ env = env || {};
298
+
299
+ if (env.FLOW_AGENTS_ACTOR_TEST_FORCE_UNRESOLVED === '1' && env.NODE_ENV === 'test') {
300
+ return { actor: '', source: 'test-forced-unresolved' };
301
+ }
302
+
303
+ const explicit = String(env.FLOW_AGENTS_ACTOR || '').trim();
304
+ if (explicit) {
305
+ if (explicit.toLowerCase() === 'local') {
306
+ // Rejected literal "local" override: never silent — one stderr warning line, never stdout,
307
+ // never thrown (hooks parse stdout; this diagnostic must not break that contract).
308
+ try {
309
+ process.stderr.write(
310
+ '[actor-identity] ignoring FLOW_AGENTS_ACTOR=local (reserved legacy value); using derived actor\n'
311
+ );
312
+ } catch { /* best-effort diagnostic only */ }
313
+ } else if (!/[A-Za-z0-9_.-]/.test(explicit)) {
314
+ // F7 (#287 fix iteration 2): the override strips to empty under sanitizeSegment's allowed
315
+ // charset — falling through to sanitizeSegment here would silently adopt its "unknown"
316
+ // fallback sentinel as if it were a deliberate value. Reject instead, with the same
317
+ // never-silent / never-stdout / never-thrown contract as the "local" rejection above. A
318
+ // literal "unknown" override is unaffected by this branch (it contains allowed chars, so it
319
+ // sanitizes to itself and returns via the branch below).
320
+ try {
321
+ process.stderr.write(
322
+ '[actor-identity] ignoring FLOW_AGENTS_ACTOR override (strips to empty under allowed charset [A-Za-z0-9_.-]); using derived actor\n'
323
+ );
324
+ } catch { /* best-effort diagnostic only */ }
325
+ } else {
326
+ return { actor: sanitizeSegment(explicit), source: 'explicit-override' };
327
+ }
328
+ }
329
+
330
+ const runtime = detectRuntime(env);
331
+ const sessionId = runtimeSessionId(env);
332
+ if (sessionId) {
333
+ const actor = serializeActor({ runtime, session_id: sessionId, host: os.hostname() });
334
+ return { actor, source: `runtime-session-id:${runtime}` };
335
+ }
336
+
337
+ const seed = ancestorActorSeed();
338
+ if (seed) {
339
+ const actor = serializeActor({ runtime, session_id: `anc-${seed}`, host: os.hostname() });
340
+ return { actor, source: 'process-ancestry' };
341
+ }
342
+
343
+ return { actor: '', source: 'unresolved' };
344
+ }
345
+
346
+ /**
347
+ * True when an actor is empty or the retired literal "local" (case-insensitive) — the one
348
+ * shared definition of "unresolved" (#287 fix iteration 1, F6; single-sourced here per #288's
349
+ * Wave 1 Task 1.1 so the lifecycle auto-emit path, the direct CLI liveness path, and the
350
+ * tool-activity heartbeat path all consume the same predicate rather than each forking their
351
+ * own copy).
352
+ *
353
+ * @param {string} actor
354
+ * @returns {boolean}
355
+ */
356
+ function isUnresolvedActor(actor) {
357
+ return !actor || String(actor).toLowerCase() === 'local';
358
+ }
359
+
360
+ module.exports = {
361
+ detectRuntime,
362
+ runtimeSessionId,
363
+ ancestorActorSeed,
364
+ sanitizeSegment,
365
+ serializeActor,
366
+ resolveActor,
367
+ isUnresolvedActor,
368
+ };
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+ // ---------------------------------------------------------------------------
3
+ // config-protection-remedies.js — the sanctioned-remedy table for
4
+ // scripts/hooks/config-protection.js.
5
+ //
6
+ // WS8 (AC15): extracted verbatim from config-protection.js so the remedy table has an
7
+ // independently testable, independently reviewable surface and the main hook file is
8
+ // smaller. Behavior-preserving: no message text or matching data changed.
9
+ //
10
+ // AC7 (config-protection): for sidecar/gate kill-switch paths the block message MUST name
11
+ // the sanctioned `npm run workflow:sidecar -- <command>` writer (or a human maintainer for
12
+ // host-owned files) and MUST NEVER advise disabling the config-protection hook.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const SHELL_PROFILE_REMEDY =
16
+ 'There is no sanctioned automated writer for shell profiles; ask a human maintainer to edit it directly. Never disable this hook to make the write.';
17
+
18
+ const SANCTIONED_REMEDIES = {
19
+ '.claude/settings.json':
20
+ 'There is no sanctioned automated writer for this file. Ask a human maintainer to edit it directly. Never disable this hook to make the write.',
21
+ '.claude/settings.local.json':
22
+ 'There is no sanctioned automated writer for this file. Ask a human maintainer to edit it directly. Never disable this hook to make the write.',
23
+ '.kontourai/flow-agents/current.json':
24
+ 'Use `npm run workflow:sidecar -- ensure-session` (or `advance-state`), which writes this file for you. Never disable this hook to make the write.',
25
+ '.kontourai/flow-agents/.goal-fit-block-streak.json':
26
+ 'This file is only mutated internally by the goal-fit Stop hook; there is no sanctioned agent writer. Never disable this hook to make the write.',
27
+ '.kontourai/flow-agents/<slug>/state.json':
28
+ 'Use `npm run workflow:sidecar -- advance-state <artifact-dir> --status <status> --phase <phase> --summary ... --next-action ...`. Never disable this hook to make the write.',
29
+ '.kontourai/flow-agents/<slug>/trust.bundle':
30
+ 'Use `npm run workflow:sidecar -- record-gate-claim` or `seal-checkpoint`, not a direct write. Never disable this hook to make the write.',
31
+ 'delivery/trust.bundle':
32
+ 'This is written automatically by the delivery publish step (`npm run workflow:sidecar -- publish-delivery` / `advance-state --status delivered`). Never disable this hook to make the write.',
33
+ 'delivery/trust.checkpoint.json':
34
+ 'This is written automatically by the delivery publish step (`npm run workflow:sidecar -- publish-delivery` / `advance-state --status delivered`). Never disable this hook to make the write.',
35
+ };
36
+
37
+ /** Sanctioned remedy for a protected path name (from checkProtectedPathPattern). */
38
+ function remedyFor(name) {
39
+ return SANCTIONED_REMEDIES[name] || SHELL_PROFILE_REMEDY;
40
+ }
41
+
42
+ /**
43
+ * Ordered remedy lookup candidates for raw command-string (substring) matching.
44
+ * Each entry names a SANCTIONED_REMEDIES key and the literal needle(s) that,
45
+ * when found anywhere in the command text, identify that protected path.
46
+ *
47
+ * Needed because Pass 1 (in config-protection.js) tokenizes on whitespace and requires a
48
+ * token to END at the protected basename. Real interpreter-write commands embed the
49
+ * path inside a quoted string followed by punctuation (an inline interpreter
50
+ * eval flag calling fs.writeFileSync with a quoted path argument), so no
51
+ * token ends cleanly at the basename and Pass 1 never matches.
52
+ *
53
+ * Order matters: more specific paths (delivery/*) are listed before the
54
+ * generic slug-scoped basenames they would otherwise collide with (both
55
+ * the per-slug trust bundle and the delivery trust bundle share the
56
+ * basename 'trust.bundle') -- first match wins, deterministically.
57
+ */
58
+ const REMEDY_COMMAND_CANDIDATES = [
59
+ { name: 'delivery/trust.checkpoint.json', needles: ['delivery/trust.checkpoint.json', 'trust.checkpoint.json'] },
60
+ { name: 'delivery/trust.bundle', needles: ['delivery/trust.bundle'] },
61
+ { name: '.kontourai/flow-agents/<slug>/trust.bundle', needles: ['trust.bundle'] },
62
+ { name: '.kontourai/flow-agents/<slug>/state.json', needles: ['state.json'] },
63
+ { name: '.kontourai/flow-agents/.goal-fit-block-streak.json', needles: ['.goal-fit-block-streak.json'] },
64
+ { name: '.kontourai/flow-agents/current.json', needles: ['current.json'] },
65
+ { name: '.claude/settings.local.json', needles: ['settings.local.json'] },
66
+ { name: '.claude/settings.json', needles: ['.claude/settings.json'] },
67
+ ];
68
+
69
+ module.exports = { SHELL_PROFILE_REMEDY, SANCTIONED_REMEDIES, remedyFor, REMEDY_COMMAND_CANDIDATES };