@kontourai/flow-agents 3.0.0 → 3.2.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 (158) hide show
  1. package/.github/workflows/ci.yml +12 -0
  2. package/CHANGELOG.md +32 -0
  3. package/CONTEXT.md +67 -1
  4. package/README.md +4 -0
  5. package/build/src/cli/assignment-provider.d.ts +188 -0
  6. package/build/src/cli/assignment-provider.js +193 -51
  7. package/build/src/cli/workflow-sidecar.d.ts +16 -6
  8. package/build/src/cli/workflow-sidecar.js +492 -45
  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 +10 -1
  13. package/context/contracts/execution-contract.md +78 -0
  14. package/context/contracts/probe-docs-write-contract.md +187 -0
  15. package/context/scripts/hooks/config-protection.js +24 -4
  16. package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
  17. package/context/scripts/hooks/stop-goal-fit.js +262 -5
  18. package/context/scripts/hooks/workflow-steering.js +42 -0
  19. package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
  20. package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
  21. package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
  22. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  23. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  24. package/docs/adr/0006-typescript-first-source-policy.md +2 -0
  25. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  26. package/docs/adr/0007-skill-audit.md +2 -0
  27. package/docs/adr/0008-kit-operation-boundary.md +2 -0
  28. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
  29. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
  30. package/docs/adr/0011-mcp-posture.md +2 -0
  31. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
  32. package/docs/adr/0013-context-lifecycle.md +2 -0
  33. package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
  34. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
  35. package/docs/adr/0016-three-hard-boundary-model.md +2 -0
  36. package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
  37. package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
  38. package/docs/adr/0019-kit-dependency-ownership.md +2 -0
  39. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
  40. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
  41. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +113 -0
  42. package/docs/adr/README.md +49 -0
  43. package/docs/adr/index.md +34 -0
  44. package/docs/decisions/agent-coordination.md +20 -0
  45. package/docs/decisions/anti-gaming-trust-security.md +20 -0
  46. package/docs/decisions/context-lifecycle.md +18 -0
  47. package/docs/decisions/core-domain-kit-boundary.md +18 -0
  48. package/docs/decisions/flow-flow-agents-boundary.md +18 -0
  49. package/docs/decisions/flow-kit.md +20 -0
  50. package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
  51. package/docs/decisions/graph-knowledge-provider.md +63 -0
  52. package/docs/decisions/hook-core-kit-boundary.md +18 -0
  53. package/docs/decisions/index.md +19 -0
  54. package/docs/decisions/kit-dependency-ownership.md +18 -0
  55. package/docs/decisions/kit-operation-boundary.md +18 -0
  56. package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
  57. package/docs/decisions/kontour-resource-contract.md +18 -0
  58. package/docs/decisions/mcp-posture.md +18 -0
  59. package/docs/decisions/three-hard-boundary-model.md +18 -0
  60. package/docs/decisions/trust-reconcile.md +20 -0
  61. package/docs/decisions/typescript-source-policy.md +48 -0
  62. package/docs/decisions/workflow-enforcement.md +18 -0
  63. package/docs/decisions/workflow-trust-state.md +20 -0
  64. package/docs/fixture-ownership.md +1 -1
  65. package/docs/workflow-usage-guide.md +1 -1
  66. package/evals/ci/run-baseline.sh +6 -0
  67. package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
  68. package/evals/integration/test_checkpoint_signing.sh +4 -3
  69. package/evals/integration/test_current_json_per_actor.sh +516 -0
  70. package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
  71. package/evals/integration/test_gate_lockdown.sh +46 -0
  72. package/evals/integration/test_model_routing_escalation.sh +145 -0
  73. package/evals/integration/test_publish_delivery.sh +14 -6
  74. package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
  75. package/evals/integration/test_stop_hook_release.sh +552 -0
  76. package/evals/integration/test_trust_reconcile_negatives.sh +170 -0
  77. package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
  78. package/evals/run.sh +10 -0
  79. package/evals/static/test_knowledge_providers.sh +13 -4
  80. package/evals/static/test_model_routing_hints.sh +107 -0
  81. package/evals/static/test_workflow_skills.sh +15 -2
  82. package/kits/builder/skills/builder-shape/SKILL.md +10 -0
  83. package/kits/builder/skills/deliver/SKILL.md +69 -11
  84. package/kits/builder/skills/design-probe/SKILL.md +47 -0
  85. package/kits/builder/skills/execute-plan/SKILL.md +13 -0
  86. package/kits/builder/skills/fix-bug/SKILL.md +17 -0
  87. package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
  88. package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
  89. package/kits/builder/skills/plan-work/SKILL.md +9 -0
  90. package/kits/builder/skills/pull-work/SKILL.md +10 -0
  91. package/kits/builder/skills/review-work/SKILL.md +11 -0
  92. package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
  93. package/kits/builder/skills/verify-work/SKILL.md +11 -0
  94. package/kits/knowledge/adapters/default-store/index.js +147 -18
  95. package/kits/knowledge/adapters/flow-runner/index.js +912 -16
  96. package/kits/knowledge/adapters/obsidian-store/index.js +97 -14
  97. package/kits/knowledge/adapters/shared/codec.js +265 -0
  98. package/kits/knowledge/docs/README.md +121 -2
  99. package/kits/knowledge/docs/store-contract.md +517 -7
  100. package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
  101. package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
  102. package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
  103. package/kits/knowledge/evals/contract-suite/suite.test.js +212 -0
  104. package/kits/knowledge/evals/freshness/suite.test.js +339 -0
  105. package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
  106. package/kits/knowledge/evals/retirement/suite.test.js +1 -1
  107. package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
  108. package/kits/knowledge/flows/promote.flow.json +84 -0
  109. package/kits/knowledge/kit.json +15 -0
  110. package/kits/knowledge/promote/distill.js +96 -0
  111. package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
  112. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
  113. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
  114. package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
  115. package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
  116. package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
  117. package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
  118. package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
  119. package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
  120. package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
  121. package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
  122. package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
  123. package/kits/knowledge/promote/fixtures/session/state.json +19 -0
  124. package/kits/knowledge/promote/health.js +137 -0
  125. package/kits/knowledge/promote/index.js +176 -0
  126. package/kits/knowledge/promote/ingest.js +103 -0
  127. package/kits/knowledge/promote/lib.js +132 -0
  128. package/kits/knowledge/promote/link.js +84 -0
  129. package/kits/knowledge/promote/promote.test.js +174 -0
  130. package/kits/knowledge/providers/conformance/suite.test.js +18 -0
  131. package/kits/knowledge/providers/index.js +1 -0
  132. package/kits/knowledge/providers/neo4j/connection.js +121 -0
  133. package/kits/knowledge/providers/neo4j/cypher.js +190 -0
  134. package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
  135. package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
  136. package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
  137. package/kits/knowledge/providers/neo4j/index.js +280 -0
  138. package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
  139. package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
  140. package/kits/knowledge/providers/neo4j/sync.js +235 -0
  141. package/package.json +4 -2
  142. package/schemas/workflow-handoff.schema.json +6 -0
  143. package/scripts/README.md +1 -0
  144. package/scripts/ci/mint-attestation.js +33 -6
  145. package/scripts/ci/trust-reconcile.js +144 -26
  146. package/scripts/freeze-adrs.mjs +364 -0
  147. package/scripts/hooks/config-protection.js +24 -4
  148. package/scripts/hooks/evidence-capture.js +4 -1
  149. package/scripts/hooks/lib/config-protection-remedies.js +9 -0
  150. package/scripts/hooks/lib/current-pointer.js +123 -0
  151. package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
  152. package/scripts/hooks/stop-goal-fit.js +262 -5
  153. package/scripts/hooks/workflow-steering.js +42 -0
  154. package/scripts/statusline/flow-agents-statusline.js +3 -1
  155. package/src/cli/assignment-provider.ts +239 -59
  156. package/src/cli/workflow-sidecar.ts +529 -43
  157. package/src/lib/flow-resolver.ts +35 -14
  158. package/src/tools/validate-source-tree.ts +2 -1
@@ -0,0 +1,364 @@
1
+ #!/usr/bin/env node
2
+ // freeze-adrs.mjs — ADR freeze-and-index tooling for flow-agents.
3
+ //
4
+ // Freezes numbered ADRs under docs/adr/ as immutable history and seeds the
5
+ // topic-keyed decision registry (docs/decisions/) with `needs-decision` stubs
6
+ // that carry the frozen ADR(s) as provenance evidence.
7
+ //
8
+ // Origin & provenance: this is the portable, zero-runtime-dependency standalone
9
+ // script (node >=22, ESM) piloted in kontourai/traverse per
10
+ // https://github.com/kontourai/flow-agents/issues/314 and rolled out across the
11
+ // portfolio (traverse#17, surface#115, flow#103, survey#108, and a fifth repo). This
12
+ // copy is the flow-agents cutover (https://github.com/kontourai/flow-agents/issues/332),
13
+ // the 6th application. It carries the two rollout fixes proven in the prior
14
+ // applications (surface#115 / flow#103):
15
+ // FIX 1 (frontmatter-aware banner placement): every flow-agents ADR opens with
16
+ // a YAML frontmatter block (`---\ntitle: ...\n---`). The traverse-original
17
+ // always prepended the banner at byte 0, which would push the frontmatter
18
+ // off the literal first line. `insertBanner()` detects a leading frontmatter
19
+ // block and inserts the banner AFTER its closing `---` instead.
20
+ // FIX 2 (whole-phrase lowercasing for Title Case terms): the traverse-original
21
+ // `defaultStubBody` only lowercased the first character of a subject when
22
+ // used mid-sentence, which read fine for traverse's sentence-case subjects
23
+ // but produced "trust Bundle" for Title Case glossary terms. `lowerPhrase()`
24
+ // lowercases every simple Title Case word (^[A-Z][a-z]+$) while preserving
25
+ // acronyms (MCP), CamelCase (TypeScript-first), and symbol tokens (`/`) —
26
+ // the flow-agents vocabulary contains all three.
27
+ //
28
+ // It does NOT vendor a copy of the registry validator or schema: flow-agents is
29
+ // the source of truth for both (scripts/check-decisions.cjs,
30
+ // schemas/decision-record.schema.json, wired as `npm run check:decisions` /
31
+ // `npm run gen:decisions-index` per #316). This script points at the existing
32
+ // validator to regenerate docs/decisions/index.md.
33
+ //
34
+ // Behavior (idempotent, content-preserving):
35
+ // 1. For each docs/adr/NNNN-*.md: insert a frozen banner as the ONLY body
36
+ // change (after any leading YAML frontmatter). Skipped if already present.
37
+ // 2. Generate docs/adr/index.md deterministically. A second run is diff-clean.
38
+ // 3. For each SUBJECT_GROUPS entry, create/update a `status: needs-decision`
39
+ // topic stub in docs/decisions/<slug>.md whose evidence[] links the frozen
40
+ // ADR(s). Existing stub frontmatter (status/decided) is preserved across
41
+ // reruns; only missing evidence entries are appended.
42
+ //
43
+ // Usage: node scripts/freeze-adrs.mjs (npm run freeze:adrs)
44
+
45
+ import fs from "node:fs";
46
+ import path from "node:path";
47
+ import { fileURLToPath } from "node:url";
48
+ import { execFileSync } from "node:child_process";
49
+
50
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
51
+ const ROOT = path.resolve(__dirname, "..");
52
+ const ADR_DIR = path.join(ROOT, "docs", "adr");
53
+ const ADR_INDEX_PATH = path.join(ADR_DIR, "index.md");
54
+ const DECISIONS_DIR = path.join(ROOT, "docs", "decisions");
55
+
56
+ const FROZEN_BANNER_MARKER = "FROZEN — immutable history.";
57
+ const FROZEN_BANNER =
58
+ "> **FROZEN — immutable history.** Superseding/current decisions live in " +
59
+ "[`docs/decisions/`](../decisions/index.md). Do not edit.\n\n";
60
+
61
+ // --- Subject grouping (flow-agents-specific, reviewed by hand) --------------
62
+ //
63
+ // Each ADR's decision subject is a NOUN drawn from (or added to) CONTEXT.md's
64
+ // domain vocabulary, per context/contracts/decision-registry-contract.md's slug
65
+ // rule. Multiple ADRs answering the same subject share one stub with multiple
66
+ // evidence refs (contract: "one file per decision SUBJECT"). Groups reference
67
+ // explicit ADR *filenames* (not numbers) because 0007 has two files sharing the
68
+ // number (0007-flow-skill-kit-tool-boundary.md and 0007-skill-audit.md), which a
69
+ // number-keyed map would collide.
70
+ //
71
+ // The four existing living topics (decision-records, promotion-gate,
72
+ // knowledge-store-provider, standing-directives) are NOT touched: none of the
73
+ // 22 numbered ADRs is about those subjects, so there is no evidence-merge case
74
+ // here — every frozen ADR subject gets a fresh needs-decision stub.
75
+ const SUBJECT_GROUPS = [
76
+ { slug: "workflow-enforcement", subject: "Workflow Enforcement", adrFiles: ["0001-flow-agents-consumes-flow.md"] },
77
+ { slug: "flow-kit", subject: "Flow Kit", adrFiles: ["0002-flow-kits-as-extension-unit.md", "0003-flow-agents-coordinates-kits-and-adapters.md"] },
78
+ { slug: "workflow-trust-state", subject: "Workflow trust state", adrFiles: ["0004-gates-expect-surface-claims.md", "0010-workflow-trust-state-as-hachure-bundle.md"] },
79
+ { slug: "kontour-resource-contract", subject: "Kontour Resource Contract", adrFiles: ["0005-kubernetes-inspired-resource-contracts.md"] },
80
+ { slug: "typescript-source-policy", subject: "TypeScript-first source policy", adrFiles: ["0006-typescript-first-source-policy.md"] },
81
+ { slug: "flow-skill-kit-tool-boundary", subject: "Flow / Skill / Kit / Tool boundary", adrFiles: ["0007-flow-skill-kit-tool-boundary.md", "0007-skill-audit.md"] },
82
+ { slug: "kit-operation-boundary", subject: "Kit operation boundary", adrFiles: ["0008-kit-operation-boundary.md"] },
83
+ { slug: "hook-core-kit-boundary", subject: "Hook core/kit boundary", adrFiles: ["0009-canonical-hook-core-kit-boundary.md"] },
84
+ { slug: "mcp-posture", subject: "MCP posture", adrFiles: ["0011-mcp-posture.md"] },
85
+ { slug: "agent-coordination", subject: "Agent coordination", adrFiles: ["0012-agent-coordination-as-liveness-claims.md", "0021-assignment-leases-and-stale-claim-takeover.md"] },
86
+ { slug: "context-lifecycle", subject: "Context lifecycle", adrFiles: ["0013-context-lifecycle.md"] },
87
+ { slug: "core-domain-kit-boundary", subject: "Core vs domain kit boundary", adrFiles: ["0014-core-vs-domain-kit-boundary.md"] },
88
+ { slug: "flow-flow-agents-boundary", subject: "Flow / Flow Agents boundary", adrFiles: ["0015-flow-flow-agents-boundary-reconciliation.md"] },
89
+ { slug: "three-hard-boundary-model", subject: "Three-hard-boundary model", adrFiles: ["0016-three-hard-boundary-model.md"] },
90
+ { slug: "anti-gaming-trust-security", subject: "Anti-gaming trust security", adrFiles: ["0017-anti-gaming-trust-security-model.md", "0018-freeze-local-shell-heuristics.md"] },
91
+ { slug: "kit-dependency-ownership", subject: "Kit dependency ownership", adrFiles: ["0019-kit-dependency-ownership.md"] },
92
+ { slug: "trust-reconcile", subject: "Trust-reconcile and delivery reconciliation", adrFiles: ["0020-trust-reconcile-manifest-and-claim-classification.md", "0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md"] },
93
+ ];
94
+
95
+ function listAdrFiles() {
96
+ if (!fs.existsSync(ADR_DIR)) return [];
97
+ return fs
98
+ .readdirSync(ADR_DIR)
99
+ .filter((name) => /^\d{4}-.*\.md$/.test(name))
100
+ .sort();
101
+ }
102
+
103
+ function adrNumberFromFilename(name) {
104
+ return parseInt(name.slice(0, 4), 10);
105
+ }
106
+
107
+ // FIX 1: frontmatter-aware banner insertion. If the file opens with a YAML
108
+ // frontmatter block, insert the banner immediately after its closing `---`
109
+ // (and the blank line that follows), never before the frontmatter.
110
+ function insertBanner(raw) {
111
+ if (raw.startsWith("---\n") || raw.startsWith("---\r\n")) {
112
+ const lines = raw.split(/\r?\n/);
113
+ let end = -1;
114
+ for (let i = 1; i < lines.length; i += 1) {
115
+ if (lines[i].trim() === "---") { end = i; break; }
116
+ }
117
+ if (end !== -1) {
118
+ // Skip a single blank line after the closing fence so the banner lands
119
+ // between the frontmatter and the first heading with clean spacing.
120
+ let insertAt = end + 1;
121
+ if (insertAt < lines.length && lines[insertAt].trim() === "") insertAt += 1;
122
+ const before = lines.slice(0, insertAt).join("\n");
123
+ const after = lines.slice(insertAt).join("\n");
124
+ return `${before}\n${FROZEN_BANNER}${after}`;
125
+ }
126
+ }
127
+ return FROZEN_BANNER + raw;
128
+ }
129
+
130
+ function freezeOne(file) {
131
+ const full = path.join(ADR_DIR, file);
132
+ const raw = fs.readFileSync(full, "utf8");
133
+ if (raw.includes(FROZEN_BANNER_MARKER)) {
134
+ return { file, changed: false, raw };
135
+ }
136
+ const frozen = insertBanner(raw);
137
+ fs.writeFileSync(full, frozen, "utf8");
138
+ return { file, changed: true, raw: frozen };
139
+ }
140
+
141
+ // Best-effort title/date extraction (index display only — never touches frozen
142
+ // ADR content). Widened for flow-agents' header forms: `# ADR NNNN: Title`
143
+ // headings and `Date:` / `**Date:**` / parenthesized `(YYYY-MM-DD)` date lines.
144
+ function parseAdrMeta(file, raw) {
145
+ const number = adrNumberFromFilename(file);
146
+ const lines = raw.split(/\r?\n/);
147
+ let title = file;
148
+ for (const line of lines) {
149
+ const m = line.match(/^#\s+(.*)$/);
150
+ if (m) {
151
+ title = m[1].trim().replace(/^ADR\s+\d+\s*[:—-]\s*/, "");
152
+ break;
153
+ }
154
+ }
155
+ const dateMatch =
156
+ raw.match(/^\s*\*{0,2}Date:?\*{0,2}\s*(\d{4}-\d{2}-\d{2})/m) ||
157
+ raw.match(/\((\d{4}-\d{2}-\d{2})\)/);
158
+ const date = dateMatch ? dateMatch[1] : null;
159
+ return { number, file, title, date };
160
+ }
161
+
162
+ function renderAdrIndex(entries) {
163
+ const sorted = [...entries].sort((a, b) => (a.number - b.number) || a.file.localeCompare(b.file));
164
+ const lines = [];
165
+ lines.push("---");
166
+ lines.push("title: ADR Index");
167
+ lines.push("---");
168
+ lines.push("");
169
+ lines.push("# ADR Index");
170
+ lines.push("");
171
+ lines.push("Generated by `node scripts/freeze-adrs.mjs` (`npm run freeze:adrs`). Do not edit by hand.");
172
+ lines.push(
173
+ "Numbered ADRs below are FROZEN immutable history (see the banner on each " +
174
+ "file). Current and superseding decisions live in " +
175
+ "[docs/decisions/](../decisions/index.md); a frozen ADR's subject is " +
176
+ "carried forward there as a `needs-decision` stub or a ratified decision."
177
+ );
178
+ lines.push("");
179
+ lines.push("| Number | Title | Date | Link |");
180
+ lines.push("| --- | --- | --- | --- |");
181
+ for (const e of sorted) {
182
+ const num = String(e.number).padStart(4, "0");
183
+ const date = e.date || "unknown";
184
+ lines.push(`| ${num} | ${e.title} | ${date} | [${e.file}](./${e.file}) |`);
185
+ }
186
+ lines.push("");
187
+ return lines.join("\n");
188
+ }
189
+
190
+ function readExistingStub(slug) {
191
+ const full = path.join(DECISIONS_DIR, `${slug}.md`);
192
+ if (!fs.existsSync(full)) return null;
193
+ const raw = fs.readFileSync(full, "utf8");
194
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
195
+ if (!fmMatch) return null;
196
+ const [, fmBlock, body] = fmMatch;
197
+ const statusMatch = fmBlock.match(/^status:\s*(.+)$/m);
198
+ const decidedMatch = fmBlock.match(/^decided:\s*(.+)$/m);
199
+ const evidence = [];
200
+ const evidenceBlockMatch = fmBlock.match(/^evidence:\n([\s\S]*?)(?:\n[a-z_]+:|$)/m);
201
+ if (evidenceBlockMatch) {
202
+ const itemRe = /-\s*kind:\s*(\S+)\n\s*ref:\s*(.+)/g;
203
+ let m;
204
+ while ((m = itemRe.exec(evidenceBlockMatch[1])) !== null) {
205
+ evidence.push({ kind: m[1].trim(), ref: m[2].trim() });
206
+ }
207
+ }
208
+ return {
209
+ status: statusMatch ? statusMatch[1].trim() : null,
210
+ decided: decidedMatch ? decidedMatch[1].trim() : null,
211
+ evidence,
212
+ body,
213
+ };
214
+ }
215
+
216
+ function evidenceKey(e) {
217
+ return `${e.kind}::${e.ref}`;
218
+ }
219
+
220
+ function renderStub({ subject, decided, evidence, body }) {
221
+ const lines = [];
222
+ lines.push("---");
223
+ lines.push("status: needs-decision");
224
+ lines.push(`subject: ${subject}`);
225
+ lines.push(`decided: ${decided}`);
226
+ lines.push("evidence:");
227
+ for (const e of evidence) {
228
+ lines.push(` - kind: ${e.kind}`);
229
+ lines.push(` ref: ${e.ref}`);
230
+ }
231
+ lines.push("---");
232
+ lines.push(body);
233
+ return lines.join("\n");
234
+ }
235
+
236
+ // FIX 2: whole-phrase lowercasing for Title Case terms, acronym/CamelCase-safe.
237
+ function lowerPhrase(s) {
238
+ return s
239
+ .split(/(\s+)/)
240
+ .map((tok) => (/^[A-Z][a-z]+$/.test(tok) ? tok.toLowerCase() : tok))
241
+ .join("");
242
+ }
243
+
244
+ function defaultStubBody(subject, adrFiles) {
245
+ const adrLinks = adrFiles.map((f) => `[${f}](../adr/${f})`).join(", ");
246
+ return `
247
+ # ${subject}
248
+
249
+ This subject has provenance in frozen ADR history (${adrLinks}) but no living
250
+ decision has been ratified yet under the topic-keyed decision registry
251
+ (\`context/contracts/decision-registry-contract.md\`). This stub records that the
252
+ subject is open and links the frozen ADR(s) as provenance; it is not a decision.
253
+
254
+ When a living decision is ratified for ${lowerPhrase(subject)}, update this
255
+ file's \`status\` to \`current\`, add rationale, and keep the \`adr\` evidence
256
+ links as provenance for the history that led here.
257
+ `.trimStart();
258
+ }
259
+
260
+ function todayIso() {
261
+ return new Date().toISOString().slice(0, 10);
262
+ }
263
+
264
+ function upsertStub(group) {
265
+ const adrFiles = group.adrFiles;
266
+ const desiredEvidence = [
267
+ ...adrFiles.map((f) => ({ kind: "adr", ref: `docs/adr/${f}` })),
268
+ ...(group.extraEvidence || []),
269
+ ];
270
+
271
+ const existing = readExistingStub(group.slug);
272
+ const decided = existing?.decided || todayIso();
273
+ const body = existing?.body || defaultStubBody(group.subject, adrFiles);
274
+
275
+ const mergedEvidence = [];
276
+ const seen = new Set();
277
+ const source = existing ? existing.evidence.concat(desiredEvidence) : desiredEvidence;
278
+ for (const e of source) {
279
+ const key = evidenceKey(e);
280
+ if (seen.has(key)) continue;
281
+ seen.add(key);
282
+ mergedEvidence.push(e);
283
+ }
284
+
285
+ const content = renderStub({ subject: group.subject, decided, evidence: mergedEvidence, body });
286
+
287
+ const full = path.join(DECISIONS_DIR, `${group.slug}.md`);
288
+ const priorRaw = fs.existsSync(full) ? fs.readFileSync(full, "utf8") : null;
289
+ if (priorRaw === content) {
290
+ return { slug: group.slug, changed: false };
291
+ }
292
+ fs.mkdirSync(DECISIONS_DIR, { recursive: true });
293
+ fs.writeFileSync(full, content, "utf8");
294
+ return { slug: group.slug, changed: true };
295
+ }
296
+
297
+ // Fail loudly if any numbered ADR file is unassigned or double-assigned, or a
298
+ // group references a missing file — silent drop of an ADR is exactly the bug a
299
+ // freeze must never ship.
300
+ function assertCoverage(adrFilenames) {
301
+ const assigned = new Map();
302
+ for (const g of SUBJECT_GROUPS) {
303
+ for (const f of g.adrFiles) {
304
+ if (assigned.has(f)) {
305
+ throw new Error(`ADR ${f} is assigned to two groups (${assigned.get(f)} and ${g.slug})`);
306
+ }
307
+ assigned.set(f, g.slug);
308
+ if (!adrFilenames.includes(f)) {
309
+ throw new Error(`group ${g.slug} references missing ADR file ${f}`);
310
+ }
311
+ }
312
+ }
313
+ const unassigned = adrFilenames.filter((f) => !assigned.has(f));
314
+ if (unassigned.length > 0) {
315
+ throw new Error(`ADR file(s) not assigned to any subject group: ${unassigned.join(", ")}`);
316
+ }
317
+ }
318
+
319
+ function main() {
320
+ const adrFilenames = listAdrFiles();
321
+ if (adrFilenames.length === 0) {
322
+ console.log("No docs/adr/NNNN-*.md files found; nothing to freeze.");
323
+ return 0;
324
+ }
325
+
326
+ assertCoverage(adrFilenames);
327
+
328
+ const frozen = adrFilenames.map(freezeOne);
329
+ const bannerChanged = frozen.filter((f) => f.changed);
330
+ for (const f of bannerChanged) {
331
+ console.log(`Froze ${path.relative(ROOT, path.join(ADR_DIR, f.file))} (banner inserted).`);
332
+ }
333
+
334
+ const meta = frozen.map((f) => parseAdrMeta(f.file, f.raw));
335
+ const adrIndexContent = renderAdrIndex(meta);
336
+ const priorIndex = fs.existsSync(ADR_INDEX_PATH) ? fs.readFileSync(ADR_INDEX_PATH, "utf8") : null;
337
+ if (priorIndex !== adrIndexContent) {
338
+ fs.writeFileSync(ADR_INDEX_PATH, adrIndexContent, "utf8");
339
+ console.log(`Wrote ${path.relative(ROOT, ADR_INDEX_PATH)}.`);
340
+ }
341
+
342
+ const stubResults = SUBJECT_GROUPS.map((g) => upsertStub(g));
343
+ for (const r of stubResults.filter((r) => r.changed)) {
344
+ console.log(`Wrote docs/decisions/${r.slug}.md (needs-decision stub).`);
345
+ }
346
+
347
+ // Chain the existing (not vendored) registry index generator so a single
348
+ // `npm run freeze:adrs` leaves docs/decisions/index.md current too.
349
+ const checkDecisions = path.join(ROOT, "scripts", "check-decisions.cjs");
350
+ if (fs.existsSync(checkDecisions)) {
351
+ execFileSync(process.execPath, [checkDecisions, "gen-index"], { cwd: ROOT, stdio: "inherit" });
352
+ }
353
+
354
+ const anyChange =
355
+ bannerChanged.length > 0 ||
356
+ priorIndex !== adrIndexContent ||
357
+ stubResults.some((r) => r.changed);
358
+ console.log(
359
+ anyChange ? "freeze-adrs: changes written." : "freeze-adrs: no-op (already frozen and current)."
360
+ );
361
+ return 0;
362
+ }
363
+
364
+ process.exit(main());
@@ -131,6 +131,18 @@ function checkProtectedPathPattern(filePath) {
131
131
  };
132
132
  }
133
133
 
134
+ // .kontourai/flow-agents/current/<actor>.json (#291) — the per-actor projection of the same
135
+ // pointer above. An agent could forge active_flow_id/active_step_id here exactly as it could
136
+ // via the legacy global file, so this is protected identically (same reason text).
137
+ // SAFE: the workflow CLI writes this via writePerActorCurrent → fs.writeFileSync,
138
+ // NOT via the Write/Edit tool — blocking the tool path does not break legit sidecar.
139
+ if (/(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\/[^/]+\.json$/.test(norm)) {
140
+ return {
141
+ name: '.kontourai/flow-agents/current/<actor>.json',
142
+ reason: 'an agent could forge active_flow_id/active_step_id to route the gate to a permissive FlowDefinition',
143
+ };
144
+ }
145
+
134
146
  // .kontourai/flow-agents/.goal-fit-block-streak.json controls soft-block
135
147
  // release counting. An agent could force early advisory-gate release by
136
148
  // writing a high count.
@@ -168,7 +180,9 @@ function checkProtectedPathPattern(filePath) {
168
180
  // copy a forged bundle here to corrupt the CI trust check.
169
181
  // SAFE: publishDelivery writes via fs.copyFileSync (not Write/Edit tool).
170
182
  // RESIDUAL: runtime-constructed paths and fs writes are unaffected.
171
- if (/(?:^|\/)delivery\/trust\.bundle$/.test(norm)) {
183
+ // #379: the optional (?:[^/]+\/)? segment also covers the per-session path
184
+ // delivery/<slug>/trust.bundle — the forgery surface moved with the write path.
185
+ if (/(?:^|\/)delivery\/(?:[^/]+\/)?trust\.bundle$/.test(norm)) {
172
186
  return {
173
187
  name: "delivery/trust.bundle",
174
188
  reason: "an agent could write a forged bundle to corrupt the CI trust-reconcile anchor",
@@ -177,7 +191,8 @@ function checkProtectedPathPattern(filePath) {
177
191
 
178
192
  // delivery/trust.checkpoint.json -- the signed checkpoint companion.
179
193
  // SAFE: publishDelivery writes via fs.copyFileSync, NOT via Write/Edit tool.
180
- if (/(?:^|\/)delivery\/trust\.checkpoint\.json$/.test(norm)) {
194
+ // #379: optional (?:[^/]+\/)? segment also covers delivery/<slug>/trust.checkpoint.json.
195
+ if (/(?:^|\/)delivery\/(?:[^/]+\/)?trust\.checkpoint\.json$/.test(norm)) {
181
196
  return {
182
197
  name: "delivery/trust.checkpoint.json",
183
198
  reason: "an agent could forge a signed delivery by writing a tampered checkpoint",
@@ -403,12 +418,15 @@ function checkCommandForBypass(command) {
403
418
  * Regex that matches a normalized (forward-slash) path ending with a protected
404
419
  * kill-switch file: shell profiles, .claude/settings*.json,
405
420
  * .kontourai/flow-agents/current.json,
421
+ * .kontourai/flow-agents/current/<actor>.json (#291 per-actor projection),
406
422
  * .kontourai/flow-agents/.goal-fit-block-streak.json,
407
423
  * .kontourai/flow-agents/<slug>/state.json,
408
424
  * .kontourai/flow-agents/<slug>/trust.bundle, and deprecated runtime-shaped
409
425
  * .flow-agents equivalents.
410
426
  */
411
- const REDIRECT_PROTECTED_RE = /(?:^|\/|~\/)(\.bash_profile|\.bashrc|\.profile|\.zprofile|\.zshrc)$|(?:^|\/)\.claude\/settings(?:\.local)?\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/\.goal-fit-block-streak\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/state\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/trust\.bundle$|(?:^|\/)delivery\/trust\.bundle$|(?:^|\/)delivery\/trust\.checkpoint\.json$/;
427
+ // #379: the delivery/ arms carry an optional (?:[^/]+\/)? segment so redirects/tee to the
428
+ // per-session path delivery/<slug>/trust.bundle (+ checkpoint) are caught, not just the flat path.
429
+ const REDIRECT_PROTECTED_RE = /(?:^|\/|~\/)(\.bash_profile|\.bashrc|\.profile|\.zprofile|\.zshrc)$|(?:^|\/)\.claude\/settings(?:\.local)?\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\/[^/]+\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/\.goal-fit-block-streak\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/state\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/trust\.bundle$|(?:^|\/)delivery\/(?:[^/]+\/)?trust\.bundle$|(?:^|\/)delivery\/(?:[^/]+\/)?trust\.checkpoint\.json$/;
412
430
 
413
431
  /**
414
432
  * Return true when a token (an unquoted redirect target or tee argument) matches
@@ -556,8 +574,10 @@ function checkInterpreterWriteToProtected(command) {
556
574
  * Delivery-protected path regex: delivery/trust.bundle and delivery/trust.checkpoint.json.
557
575
  * These are the CI anchor files whose contents must not be agent-forged.
558
576
  * Used by checkCopyMoveToProtected to catch `cp x delivery/trust.bundle`.
577
+ * #379: the optional (?:[^/]+\/)? segment also catches the per-session path
578
+ * `cp forged.json delivery/<slug>/trust.bundle`.
559
579
  */
560
- const DELIVERY_COPY_PROTECTED_RE = /(?:^|\/)delivery\/trust\.bundle$|(?:^|\/)delivery\/trust\.checkpoint\.json$/;
580
+ const DELIVERY_COPY_PROTECTED_RE = /(?:^|\/)delivery\/(?:[^/]+\/)?trust\.bundle$|(?:^|\/)delivery\/(?:[^/]+\/)?trust\.checkpoint\.json$/;
561
581
 
562
582
  /**
563
583
  * Return true when a normalized token matches a delivery-protected path.
@@ -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 };