@kontourai/flow-agents 3.0.0 → 3.1.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 (131) hide show
  1. package/.github/workflows/ci.yml +8 -0
  2. package/CHANGELOG.md +15 -0
  3. package/CONTEXT.md +67 -1
  4. package/README.md +4 -0
  5. package/build/src/cli/assignment-provider.d.ts +143 -0
  6. package/build/src/cli/assignment-provider.js +106 -49
  7. package/build/src/cli/workflow-sidecar.d.ts +2 -2
  8. package/build/src/cli/workflow-sidecar.js +392 -35
  9. package/build/src/lib/flow-resolver.d.ts +12 -6
  10. package/build/src/lib/flow-resolver.js +30 -14
  11. package/build/src/tools/validate-source-tree.js +2 -1
  12. package/context/contracts/assignment-provider-contract.md +9 -0
  13. package/context/contracts/probe-docs-write-contract.md +187 -0
  14. package/context/scripts/hooks/config-protection.js +14 -1
  15. package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
  16. package/context/scripts/hooks/stop-goal-fit.js +4 -2
  17. package/context/scripts/hooks/workflow-steering.js +42 -0
  18. package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
  19. package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
  20. package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
  21. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  22. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  23. package/docs/adr/0006-typescript-first-source-policy.md +2 -0
  24. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  25. package/docs/adr/0007-skill-audit.md +2 -0
  26. package/docs/adr/0008-kit-operation-boundary.md +2 -0
  27. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
  28. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
  29. package/docs/adr/0011-mcp-posture.md +2 -0
  30. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
  31. package/docs/adr/0013-context-lifecycle.md +2 -0
  32. package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
  33. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
  34. package/docs/adr/0016-three-hard-boundary-model.md +2 -0
  35. package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
  36. package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
  37. package/docs/adr/0019-kit-dependency-ownership.md +2 -0
  38. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
  39. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
  40. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +2 -0
  41. package/docs/adr/README.md +49 -0
  42. package/docs/adr/index.md +34 -0
  43. package/docs/decisions/agent-coordination.md +20 -0
  44. package/docs/decisions/anti-gaming-trust-security.md +20 -0
  45. package/docs/decisions/context-lifecycle.md +18 -0
  46. package/docs/decisions/core-domain-kit-boundary.md +18 -0
  47. package/docs/decisions/flow-flow-agents-boundary.md +18 -0
  48. package/docs/decisions/flow-kit.md +20 -0
  49. package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
  50. package/docs/decisions/graph-knowledge-provider.md +63 -0
  51. package/docs/decisions/hook-core-kit-boundary.md +18 -0
  52. package/docs/decisions/index.md +19 -0
  53. package/docs/decisions/kit-dependency-ownership.md +18 -0
  54. package/docs/decisions/kit-operation-boundary.md +18 -0
  55. package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
  56. package/docs/decisions/kontour-resource-contract.md +18 -0
  57. package/docs/decisions/mcp-posture.md +18 -0
  58. package/docs/decisions/three-hard-boundary-model.md +18 -0
  59. package/docs/decisions/trust-reconcile.md +20 -0
  60. package/docs/decisions/typescript-source-policy.md +48 -0
  61. package/docs/decisions/workflow-enforcement.md +18 -0
  62. package/docs/decisions/workflow-trust-state.md +20 -0
  63. package/docs/fixture-ownership.md +1 -1
  64. package/docs/workflow-usage-guide.md +1 -1
  65. package/evals/ci/run-baseline.sh +4 -0
  66. package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
  67. package/evals/integration/test_current_json_per_actor.sh +516 -0
  68. package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
  69. package/evals/integration/test_gate_lockdown.sh +10 -0
  70. package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
  71. package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
  72. package/evals/run.sh +4 -0
  73. package/evals/static/test_knowledge_providers.sh +13 -4
  74. package/evals/static/test_workflow_skills.sh +15 -2
  75. package/kits/builder/skills/deliver/SKILL.md +17 -0
  76. package/kits/builder/skills/design-probe/SKILL.md +37 -0
  77. package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
  78. package/kits/knowledge/adapters/default-store/index.js +92 -4
  79. package/kits/knowledge/adapters/obsidian-store/index.js +69 -4
  80. package/kits/knowledge/adapters/shared/codec.js +141 -0
  81. package/kits/knowledge/docs/README.md +121 -2
  82. package/kits/knowledge/docs/store-contract.md +112 -4
  83. package/kits/knowledge/evals/contract-suite/suite.test.js +176 -0
  84. package/kits/knowledge/flows/promote.flow.json +84 -0
  85. package/kits/knowledge/kit.json +15 -0
  86. package/kits/knowledge/promote/distill.js +96 -0
  87. package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
  88. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
  89. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
  90. package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
  91. package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
  92. package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
  93. package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
  94. package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
  95. package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
  96. package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
  97. package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
  98. package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
  99. package/kits/knowledge/promote/fixtures/session/state.json +19 -0
  100. package/kits/knowledge/promote/health.js +137 -0
  101. package/kits/knowledge/promote/index.js +176 -0
  102. package/kits/knowledge/promote/ingest.js +103 -0
  103. package/kits/knowledge/promote/lib.js +132 -0
  104. package/kits/knowledge/promote/link.js +84 -0
  105. package/kits/knowledge/promote/promote.test.js +174 -0
  106. package/kits/knowledge/providers/conformance/suite.test.js +18 -0
  107. package/kits/knowledge/providers/index.js +1 -0
  108. package/kits/knowledge/providers/neo4j/connection.js +121 -0
  109. package/kits/knowledge/providers/neo4j/cypher.js +190 -0
  110. package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
  111. package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
  112. package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
  113. package/kits/knowledge/providers/neo4j/index.js +280 -0
  114. package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
  115. package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
  116. package/kits/knowledge/providers/neo4j/sync.js +235 -0
  117. package/package.json +4 -2
  118. package/scripts/README.md +1 -0
  119. package/scripts/freeze-adrs.mjs +364 -0
  120. package/scripts/hooks/config-protection.js +14 -1
  121. package/scripts/hooks/evidence-capture.js +4 -1
  122. package/scripts/hooks/lib/config-protection-remedies.js +9 -0
  123. package/scripts/hooks/lib/current-pointer.js +123 -0
  124. package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
  125. package/scripts/hooks/stop-goal-fit.js +4 -2
  126. package/scripts/hooks/workflow-steering.js +42 -0
  127. package/scripts/statusline/flow-agents-statusline.js +3 -1
  128. package/src/cli/assignment-provider.ts +137 -55
  129. package/src/cli/workflow-sidecar.ts +430 -33
  130. package/src/lib/flow-resolver.ts +35 -14
  131. package/src/tools/validate-source-tree.ts +2 -1
@@ -49,6 +49,8 @@
49
49
  const fs = require('fs');
50
50
  const path = require('path');
51
51
  const { flowAgentsArtifactRootsForRead } = require('./lib/local-artifact-paths');
52
+ const { resolveActor } = require('./lib/actor-identity.js');
53
+ const { readCurrentPointer } = require('./lib/current-pointer.js');
52
54
  const crypto = require('crypto');
53
55
 
54
56
  const MAX_STDIN = 1024 * 1024;
@@ -198,8 +200,9 @@ function latestStateDir(flowAgentsDir) {
198
200
  * to the newest-mtime state.json directory.
199
201
  */
200
202
  function resolveArtifactDir(root) {
203
+ const actorKey = resolveActor(process.env).actor;
201
204
  for (const flowAgentsDir of flowAgentsArtifactRootsForRead(root)) {
202
- const current = readJsonFile(path.join(flowAgentsDir, 'current.json'));
205
+ const { payload: current } = readCurrentPointer(flowAgentsDir, actorKey);
203
206
  if (current) {
204
207
  const slug = current.artifact_dir || current.active_slug;
205
208
  if (typeof slug === 'string' && slug.trim()) {
@@ -22,6 +22,8 @@ const SANCTIONED_REMEDIES = {
22
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
23
  '.kontourai/flow-agents/current.json':
24
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/current/<actor>.json':
26
+ '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
27
  '.kontourai/flow-agents/.goal-fit-block-streak.json':
26
28
  '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
29
  '.kontourai/flow-agents/<slug>/state.json':
@@ -61,6 +63,13 @@ const REMEDY_COMMAND_CANDIDATES = [
61
63
  { name: '.kontourai/flow-agents/<slug>/trust.bundle', needles: ['trust.bundle'] },
62
64
  { name: '.kontourai/flow-agents/<slug>/state.json', needles: ['state.json'] },
63
65
  { name: '.kontourai/flow-agents/.goal-fit-block-streak.json', needles: ['.goal-fit-block-streak.json'] },
66
+ // #291: the per-actor projection (current/<actor>.json) is checked BEFORE the legacy
67
+ // current.json needle below — its needle is a directory-scoped substring
68
+ // ('flow-agents/current/') that never overlaps with the bare 'current.json' basename, so
69
+ // ordering between these two entries has no effect on real inputs, but keeping the more
70
+ // specific per-actor entry first mirrors this table's existing "specific before generic"
71
+ // convention.
72
+ { name: '.kontourai/flow-agents/current/<actor>.json', needles: ['flow-agents/current/', '.flow-agents/current/'] },
64
73
  { name: '.kontourai/flow-agents/current.json', needles: ['current.json'] },
65
74
  { name: '.claude/settings.local.json', needles: ['settings.local.json'] },
66
75
  { name: '.claude/settings.json', needles: ['.claude/settings.json'] },
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+ /**
3
+ * current-pointer.js — shared pure-CJS per-actor "current" pointer reader/writer (#291)
4
+ *
5
+ * Zero external dependencies (only Node core: fs, path). Consumed by:
6
+ * - build/src/cli/workflow-sidecar.js (ESM compiled, via createRequire — Wave 2 Task 2.1)
7
+ * - build/src/lib/flow-resolver.js (ESM compiled, via createRequire — Wave 2 Task 2.2)
8
+ * - scripts/hooks/stop-goal-fit.js, evidence-capture.js, lib/liveness-heartbeat.js,
9
+ * scripts/statusline/flow-agents-statusline.js (CJS, direct require — Wave 2 Task 2.3)
10
+ * - scripts/hooks/workflow-steering.js (CJS, direct require — Wave 2 Task 2.4)
11
+ *
12
+ * Purpose (issue #291): today every one of the consumers above hand-rolls its own
13
+ * `fs.readFileSync(path.join(flowAgentsDir, "current.json"))`, so once `ensure-session` starts
14
+ * projecting a per-actor `current/<actor>.json` pointer (Wave 2 Task 2.1's `writeCurrent()` dual
15
+ * write), every reader must gain the SAME actor-aware preference or session A's own "what am I
16
+ * working on" view keeps getting silently overwritten by session B's more-recent legacy write.
17
+ * This module is the single choke point for that preference rule — every reader listed above
18
+ * calls `readCurrentPointer()` instead of hand-rolling its own fallback, so the compat-shim rule
19
+ * (per-actor first, legacy-global fallback) can never drift between call sites.
20
+ *
21
+ * `sanitizeSegment` is required from `./actor-identity.js` (already reviewed: restricts to
22
+ * `[A-Za-z0-9_.-]`, caps 64 chars) — this file deliberately does NOT re-implement a second
23
+ * sanitizer; it is the same charset restriction `assignment-provider.ts`'s `assignmentFilePath()`
24
+ * already applies to its own per-subject filenames, so the naming convention is consistent across
25
+ * both per-key-file stores in this repo (`assignment/<subject>.json` and now
26
+ * `current/<actor>.json`).
27
+ *
28
+ * Exports:
29
+ * perActorCurrentFile(flowAgentsDir, actorKey) → string (path, unsanitized existence not
30
+ * implied — caller must still fs.existsSync)
31
+ * readCurrentPointer(flowAgentsDir, actorKey) → { payload: object|null,
32
+ * source: "per-actor"|"legacy"|"none",
33
+ * file: string|null }
34
+ * writePerActorCurrent(flowAgentsDir, actorKey, payload) → void
35
+ */
36
+
37
+ const fs = require('fs');
38
+ const path = require('path');
39
+ const { sanitizeSegment, isUnresolvedActor } = require('./actor-identity.js');
40
+
41
+ /**
42
+ * Best-effort tolerant JSON read: missing file or corrupt/unparseable content are BOTH treated
43
+ * as "absent" (returns null), never thrown — this is an advisory read used to decide which
44
+ * pointer file to prefer, not a durable-write persistence path. Mirrors the `readJsonFile`
45
+ * tolerance convention already used by stop-goal-fit.js/evidence-capture.js for this exact file
46
+ * (`current.json`) today.
47
+ *
48
+ * @param {string} file
49
+ * @returns {object|null}
50
+ */
51
+ function readJsonFileTolerant(file) {
52
+ try {
53
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Path to the per-actor current pointer file for a given actor key, sanitized via the shared
61
+ * `sanitizeSegment` (reused, not re-implemented) so the filename is path-traversal-safe exactly
62
+ * like `assignment-provider.ts`'s `assignmentFilePath()`.
63
+ *
64
+ * @param {string} flowAgentsDir
65
+ * @param {string} actorKey
66
+ * @returns {string}
67
+ */
68
+ function perActorCurrentFile(flowAgentsDir, actorKey) {
69
+ return path.join(flowAgentsDir, 'current', `${sanitizeSegment(actorKey)}.json`);
70
+ }
71
+
72
+ /**
73
+ * The ENTIRE compat-shim fallback rule (#291): when `actorKey` is a non-empty, resolved (not
74
+ * `isUnresolvedActor`) string and its per-actor file exists and parses, prefer it
75
+ * (`source: "per-actor"`). Otherwise — no actorKey, an unresolved actor, or no per-actor file yet
76
+ * — fall back to the legacy global `<flowAgentsDir>/current.json`; if THAT exists and parses,
77
+ * return it (`source: "legacy"`). Otherwise return `{ payload: null, source: "none", file: null }`.
78
+ *
79
+ * A missing/corrupt per-actor file is tolerated as absent (falls through to the legacy branch,
80
+ * never throws) — same best-effort tolerance the existing `readJsonFile`-style readers already
81
+ * apply to `current.json` today. This fallback must be EXACTLY equivalent to today's plain
82
+ * `current.json` read for every caller that has no actorKey (or an unresolved one) — that is
83
+ * what makes this a compat shim rather than a behavior change.
84
+ *
85
+ * @param {string} flowAgentsDir
86
+ * @param {string} [actorKey]
87
+ * @returns {{ payload: object|null, source: "per-actor"|"legacy"|"none", file: string|null }}
88
+ */
89
+ function readCurrentPointer(flowAgentsDir, actorKey) {
90
+ const key = actorKey == null ? '' : String(actorKey);
91
+ if (key && !isUnresolvedActor(key)) {
92
+ const perActorFile = perActorCurrentFile(flowAgentsDir, key);
93
+ const perActorPayload = readJsonFileTolerant(perActorFile);
94
+ if (perActorPayload !== null) return { payload: perActorPayload, source: 'per-actor', file: perActorFile };
95
+ }
96
+
97
+ const legacyFile = path.join(flowAgentsDir, 'current.json');
98
+ const legacyPayload = readJsonFileTolerant(legacyFile);
99
+ if (legacyPayload !== null) return { payload: legacyPayload, source: 'legacy', file: legacyFile };
100
+
101
+ return { payload: null, source: 'none', file: null };
102
+ }
103
+
104
+ /**
105
+ * Durable write-side counterpart to `readCurrentPointer`'s per-actor branch — used only by
106
+ * `workflow-sidecar.ts`'s `writeCurrent()` (Wave 2 Task 2.1), which calls this ALONGSIDE
107
+ * (never instead of) its existing unconditional legacy `current.json` write, so the legacy
108
+ * compat-shim fallback always has a value to read for any caller that never resolves an actor.
109
+ * `fs.mkdirSync` the `current/` subdir with `{ recursive: true }` before writing — this is the
110
+ * ONLY writer of that subdir, so no other caller needs to pre-create it.
111
+ *
112
+ * @param {string} flowAgentsDir
113
+ * @param {string} actorKey
114
+ * @param {object} payload
115
+ * @returns {void}
116
+ */
117
+ function writePerActorCurrent(flowAgentsDir, actorKey, payload) {
118
+ const file = perActorCurrentFile(flowAgentsDir, actorKey);
119
+ fs.mkdirSync(path.dirname(file), { recursive: true });
120
+ fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`);
121
+ }
122
+
123
+ module.exports = { perActorCurrentFile, readCurrentPointer, writePerActorCurrent };
@@ -18,10 +18,24 @@
18
18
  * the actor-scoped stream has no shared-write race.
19
19
  *
20
20
  * Hot-path ordering + bounded read (F3, #288 fix iteration 1, sec-MED +
21
- * cr-MED): steps run enabled? -> current.json active_slug present? ->
22
- * resolveActor() -> bounded tail read, in that order, so a repo with liveness
23
- * disabled or no active session never pays the `resolveActor()` process-
24
- * ancestry `ps` spawn cost.
21
+ * cr-MED; restored by F4, fix-plan iteration 1): steps run enabled? ->
22
+ * mightHaveActiveSession() peek -> resolveActor() -> current.json
23
+ * active_slug present? -> bounded tail read, in that order, so a repo with
24
+ * liveness disabled OR no active session never pays the `resolveActor()`
25
+ * process-ancestry `ps` spawn cost. (#291 initially moved resolveActor()
26
+ * ahead of the active_slug check unconditionally, since the current-pointer
27
+ * read became actor-aware and needs the resolved actor to choose per-actor
28
+ * vs. legacy `current.json` — this silently dropped the "no active session"
29
+ * half of the short-circuit for every liveness-enabled call, not just the
30
+ * "disabled" half. F4 restores it: `mightHaveActiveSession()` is a plain,
31
+ * actor-free `fs.existsSync` peek of the legacy `current.json` file and the
32
+ * `current/` per-actor directory — when NEITHER exists, no actor could
33
+ * possibly have an active session (per-actor files only ever live under
34
+ * `current/`), so resolveActor() is skipped entirely, exactly restoring the
35
+ * pre-#291 short-circuit for the common "enabled, no session yet" case. Only
36
+ * once a current-pointer file might exist does resolveActor() run, still
37
+ * exactly once, reused for both the actor-scoped current-pointer read and the
38
+ * actor-unresolved gate below.)
25
39
  *
26
40
  * Orphan-heartbeat invariant (F8(ii), #288 fix iteration 2): a heartbeat must
27
41
  * never be treated as its own evidence of a claim. The reviewer-reproduced
@@ -74,12 +88,12 @@
74
88
 
75
89
  const fs = require('fs');
76
90
  const path = require('path');
77
-
78
91
  const { isLivenessEnabled, resolveHeartbeatThrottleSeconds } = require('./liveness-policy');
79
92
  const { livenessStreamFile, appendLivenessEvent } = require('./liveness-write');
80
93
  const { readLivenessEvents, readLivenessEventsTail, freshHolders } = require('./liveness-read');
81
94
  const { resolveActor, isUnresolvedActor, sanitizeSegment } = require('./actor-identity');
82
95
  const { flowAgentsArtifactRoot } = require('./local-artifact-paths');
96
+ const { readCurrentPointer } = require('./current-pointer');
83
97
 
84
98
  /**
85
99
  * Resolve a caller-supplied `now` (Date, ISO string, or omitted) to epoch ms.
@@ -101,35 +115,50 @@ function resolveNowMs(now) {
101
115
  }
102
116
 
103
117
  /**
104
- * Read `current.json`'s `active_slug` from the given artifact root, sanitized through the same
105
- * charset+cap restriction as actor-identity.js's `sanitizeSegment` (F5, #288 fix iteration 1,
106
- * sec-LOW: `current.json` is a local file that could be hand-edited or otherwise hostile
107
- * `active_slug` must never be trusted verbatim before it is used as a JSONL grouping key /
108
- * emitted `subjectId` or compared against event data). Tolerates a missing file or malformed
109
- * JSON (returns "").
118
+ * Read the active_slug from the actor-scoped "current" pointer (#291: per-actor
119
+ * `current/<actor>.json` preferred over the legacy global `current.json`, via
120
+ * `readCurrentPointer` the single choke point for that preference rule), sanitized through the
121
+ * same charset+cap restriction as actor-identity.js's `sanitizeSegment` (F5, #288 fix iteration 1,
122
+ * sec-LOW: this is a local file that could be hand-edited or otherwise hostile — `active_slug`
123
+ * must never be trusted verbatim before it is used as a JSONL grouping key / emitted `subjectId`
124
+ * or compared against event data). Tolerates a missing file or malformed JSON (returns "") exactly
125
+ * like the plain-`fs.readFileSync` read this replaces; when `actorKey` is empty/unresolved,
126
+ * `readCurrentPointer` falls straight to the legacy global file, so behavior for that case is
127
+ * unchanged from before #291.
110
128
  *
111
129
  * @param {string} root
130
+ * @param {string} [actorKey]
112
131
  * @returns {string}
113
132
  */
114
- function readActiveSlug(root) {
115
- let raw = '';
116
- try {
117
- raw = fs.readFileSync(path.join(root, 'current.json'), 'utf8');
118
- } catch {
119
- return '';
120
- }
121
- try {
122
- const parsed = JSON.parse(raw);
123
- const activeSlug = (parsed && parsed.active_slug) || '';
124
- if (!activeSlug) return '';
125
- // sanitizeSegment falls back to the literal "unknown" for an all-stripped input; that
126
- // fallback is not a legitimate slug, so treat it as "no active slug" here rather than
127
- // matching against a subject that was never really claimed.
128
- const sanitized = sanitizeSegment(activeSlug);
129
- return sanitized === 'unknown' ? '' : sanitized;
130
- } catch {
131
- return '';
132
- }
133
+ /**
134
+ * F4 fix (fix-plan iteration 1, MED): a cheap, actor-free existence peek restoring the module's
135
+ * documented "no active session never pays resolveActor()" invariant. `readCurrentPointer`'s
136
+ * actor-aware preference genuinely needs a resolved actor to choose per-actor vs. legacy — but
137
+ * that choice is only ever relevant when SOME current-pointer file might exist at all. When
138
+ * neither the legacy global `<root>/current.json` file NOR the `<root>/current/` per-actor
139
+ * directory exists, there is provably no active session for any actor (per-actor files are only
140
+ * ever written into that directory — see current-pointer.js's writePerActorCurrent), so this
141
+ * function returns false without calling resolveActor() at all, exactly restoring the pre-#291
142
+ * short-circuit for the common "liveness enabled, no session yet" case. Two plain fs.existsSync
143
+ * calls — no ps/proc ancestry cost, no JSON parse.
144
+ *
145
+ * @param {string} root
146
+ * @returns {boolean}
147
+ */
148
+ function mightHaveActiveSession(root) {
149
+ return fs.existsSync(path.join(root, 'current.json')) || fs.existsSync(path.join(root, 'current'));
150
+ }
151
+
152
+ function readActiveSlug(root, actorKey) {
153
+ const { payload: current } = readCurrentPointer(root, actorKey);
154
+ if (!current) return '';
155
+ const activeSlug = (current && current.active_slug) || '';
156
+ if (!activeSlug) return '';
157
+ // sanitizeSegment falls back to the literal "unknown" for an all-stripped input; that
158
+ // fallback is not a legitimate slug, so treat it as "no active slug" here rather than
159
+ // matching against a subject that was never really claimed.
160
+ const sanitized = sanitizeSegment(activeSlug);
161
+ return sanitized === 'unknown' ? '' : sanitized;
133
162
  }
134
163
 
135
164
  /**
@@ -221,9 +250,10 @@ function attachConflictField(result, conflict) {
221
250
  * object, never throws.
222
251
  *
223
252
  * See the module header for the full F8(ii) throttle/emit split. Summary:
224
- * - enabled? -> slug present? -> resolveActor() -> bounded tail read, in
225
- * that order (F3), so a disabled repo or one with no active session
226
- * never pays the resolveActor() ancestry `ps` spawn cost.
253
+ * - enabled? -> mightHaveActiveSession() peek -> resolveActor() -> slug
254
+ * present? -> bounded tail read, in that order (F3, restored by F4), so
255
+ * a disabled repo or one with no active session never pays the
256
+ * resolveActor() ancestry `ps` spawn cost.
227
257
  * - THROTTLE decision reads only the bounded tail (never a full read):
228
258
  * a release found there refuses; a fresh event there throttles.
229
259
  * - EMIT decision requires an actual `claim` event for the pair: taken
@@ -248,12 +278,22 @@ function maybeEmitHeartbeat(opts = {}) {
248
278
  }
249
279
 
250
280
  const root = flowAgentsArtifactRoot(cwd);
251
- const slug = readActiveSlug(root);
252
- if (!slug) {
281
+ // F4 fix (fix-plan iteration 1, MED): restore the "no active session never pays
282
+ // resolveActor()" invariant. mightHaveActiveSession() is a plain fs.existsSync peek of the
283
+ // LEGACY current.json / current/ dir — no actor needed — so a repo with liveness enabled but
284
+ // no session at all (the common case this invariant exists for) short-circuits here, exactly
285
+ // like pre-#291. Only once a current-pointer file MIGHT exist do we pay for resolveActor(),
286
+ // needed to decide per-actor vs. legacy preference via readActiveSlug/readCurrentPointer.
287
+ if (!mightHaveActiveSession(root)) {
253
288
  return { emitted: false, reason: 'no-current' };
254
289
  }
255
290
 
256
291
  const { actor } = resolveActor(env);
292
+ const slug = readActiveSlug(root, actor);
293
+ if (!slug) {
294
+ return { emitted: false, reason: 'no-current' };
295
+ }
296
+
257
297
  if (isUnresolvedActor(actor)) {
258
298
  return { emitted: false, reason: 'actor-unresolved' };
259
299
  }
@@ -42,6 +42,8 @@ const {
42
42
  flowAgentsArtifactRoot,
43
43
  flowAgentsArtifactRootsForRead,
44
44
  } = require('./lib/local-artifact-paths');
45
+ const { resolveActor } = require('./lib/actor-identity.js');
46
+ const { readCurrentPointer } = require('./lib/current-pointer.js');
45
47
 
46
48
  const MAX_STDIN = 1024 * 1024;
47
49
  const ACTIVE_STATUSES = new Set([
@@ -1531,7 +1533,7 @@ async function bundleEnforcement(artifactDir, activeFlowStep) {
1531
1533
  * to scanning all of .kontourai/flow-agents (newest-mtime).
1532
1534
  */
1533
1535
  function preferredArtifactDir(flowAgentsDir) {
1534
- const current = readJsonFile(path.join(flowAgentsDir, 'current.json'));
1536
+ const { payload: current } = readCurrentPointer(flowAgentsDir, resolveActor(process.env).actor);
1535
1537
  if (!current) return null;
1536
1538
  const slug = current.artifact_dir || current.active_slug;
1537
1539
  if (typeof slug !== 'string' || !slug.trim()) return null;
@@ -1549,7 +1551,7 @@ function hasSidecarPresence(artifactDir) {
1549
1551
  // return that slug so analyze() can log the staleness rather than silently falling back to
1550
1552
  // a global mtime scan that could resurface an abandoned/never-real session as active.
1551
1553
  function staleCurrentSlug(flowAgentsDir) {
1552
- const current = readJsonFile(path.join(flowAgentsDir, 'current.json'));
1554
+ const { payload: current } = readCurrentPointer(flowAgentsDir, resolveActor(process.env).actor);
1553
1555
  if (!current) return null;
1554
1556
  const slug = current.artifact_dir || current.active_slug;
1555
1557
  if (typeof slug !== 'string' || !slug.trim()) return null;
@@ -19,6 +19,7 @@ const path = require('path');
19
19
  const { readLivenessEvents, freshHolders } = require('./lib/liveness-read');
20
20
  const { resolveActor } = require('./lib/actor-identity');
21
21
  const { flowAgentsArtifactRootsForRead } = require('./lib/local-artifact-paths');
22
+ const { readCurrentPointer } = require('./lib/current-pointer');
22
23
 
23
24
  const STEERING = {
24
25
  'tool-planner': [
@@ -102,7 +103,48 @@ function readJson(file) {
102
103
  }
103
104
  }
104
105
 
106
+ /**
107
+ * #291 (Conflict #2, Wave 2 Task 2.4): actor-scoped preference consulted BEFORE the global
108
+ * newest-mtime scan below. For each read-root, resolve the "current" pointer via
109
+ * `readCurrentPointer` (per-actor `current/<actor>.json` preferred, legacy global
110
+ * `current.json` fallback — the same single choke point every other Wave 2 consumer uses) and,
111
+ * if it names a session dir whose `state.json` is still in an ACTIVE_STATE_STATUSES status,
112
+ * return that state immediately — so actor A's own ambient steering hint always prefers A's own
113
+ * current task over a globally-newer-mtime `state.json` some other actor (B) happened to touch
114
+ * more recently. Returns null (never throws) when no root yields an actor-scoped resolution,
115
+ * so the caller falls through to the unchanged global scan below — this is the compat-shim
116
+ * guarantee: when `actorKey` is empty/unresolved and/or no per-actor file exists yet, this
117
+ * degrades to the SAME legacy-`current.json`-or-nothing lookup the compat shim already performs
118
+ * for every other Wave 2 reader, so a single-session/CI/legacy-only fixture cannot fail to
119
+ * resolve here in a way it wouldn't already have resolved via the global scan.
120
+ *
121
+ * @param {string} root
122
+ * @param {string} actorKey
123
+ * @returns {{file: string, payload: object, mtimeMs: number}|null}
124
+ */
125
+ function actorScopedWorkflowState(root, actorKey) {
126
+ for (const flowAgentsDir of flowAgentsArtifactRootsForRead(root)) {
127
+ const { payload: current } = readCurrentPointer(flowAgentsDir, actorKey);
128
+ if (!current) continue;
129
+ const slug = current.artifact_dir || current.active_slug;
130
+ if (typeof slug !== 'string' || !slug.trim()) continue;
131
+ const safe = slug.replace(/\.\.+/g, '').replace(/^[/\\]+/, '');
132
+ const dir = path.join(flowAgentsDir, safe);
133
+ if (!dir.startsWith(flowAgentsDir + path.sep) || !fs.existsSync(dir)) continue;
134
+ const file = path.join(dir, 'state.json');
135
+ let stat;
136
+ try { stat = fs.statSync(file); } catch { continue; }
137
+ const payload = readJson(file);
138
+ if (!payload || !ACTIVE_STATE_STATUSES.has(payload.status)) continue;
139
+ return { file, payload, mtimeMs: stat.mtimeMs };
140
+ }
141
+ return null;
142
+ }
143
+
105
144
  function latestWorkflowState(root) {
145
+ const preferred = actorScopedWorkflowState(root, resolveActor(process.env).actor);
146
+ if (preferred) return preferred;
147
+
106
148
  const states = flowAgentsArtifactRootsForRead(root)
107
149
  .flatMap(artifactRoot => walkStateFiles(artifactRoot))
108
150
  .map(file => {
@@ -10,6 +10,8 @@
10
10
 
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
+ const { resolveActor } = require('../hooks/lib/actor-identity.js');
14
+ const { readCurrentPointer } = require('../hooks/lib/current-pointer.js');
13
15
 
14
16
  const MAX_SUMMARY = 72;
15
17
 
@@ -76,7 +78,7 @@ function stateFiles(workflowRoot) {
76
78
  }
77
79
 
78
80
  function chooseWorkflow(workflowRoot) {
79
- const current = readJson(path.join(workflowRoot, 'current.json'));
81
+ const { payload: current } = readCurrentPointer(workflowRoot, resolveActor(process.env).actor);
80
82
  if (current?.active_slug || current?.artifact_dir) {
81
83
  const slug = current.active_slug || current.artifact_dir;
82
84
  const dir = path.join(workflowRoot, slug);