@kontourai/flow-agents 2.4.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.
- package/.github/CODEOWNERS +8 -0
- package/.github/workflows/ci.yml +20 -0
- package/.github/workflows/trust-reconcile.yml +62 -4
- package/CHANGELOG.md +38 -0
- package/CONTEXT.md +88 -1
- package/README.md +4 -0
- package/build/src/cli/assignment-provider.d.ts +144 -0
- package/build/src/cli/assignment-provider.js +805 -0
- package/build/src/cli/effective-assignment-provider-settings.d.ts +1 -0
- package/build/src/cli/effective-assignment-provider-settings.js +125 -0
- package/build/src/cli/validate-workflow-artifacts.js +5 -1
- package/build/src/cli/workflow-sidecar.d.ts +2 -2
- package/build/src/cli/workflow-sidecar.js +549 -145
- package/build/src/cli.js +6 -0
- package/build/src/lib/flow-resolver.d.ts +12 -6
- package/build/src/lib/flow-resolver.js +30 -14
- package/build/src/tools/validate-source-tree.js +2 -0
- package/context/contracts/artifact-contract.md +2 -0
- package/context/contracts/assignment-provider-contract.md +248 -0
- package/context/contracts/builder-kit-workflow-state-contract.md +2 -0
- package/context/contracts/decision-registry-contract.md +2 -0
- package/context/contracts/delivery-contract.md +2 -0
- package/context/contracts/execution-contract.md +25 -0
- package/context/contracts/governance-adapter-contract.md +2 -0
- package/context/contracts/knowledge-store-contract.md +197 -0
- package/context/contracts/planning-contract.md +2 -0
- package/context/contracts/probe-docs-write-contract.md +187 -0
- package/context/contracts/review-contract.md +2 -0
- package/context/contracts/sandbox-policy.md +2 -0
- package/context/contracts/standing-directives.md +13 -0
- package/context/contracts/verification-contract.md +2 -0
- package/context/contracts/work-item-contract.md +2 -0
- package/context/scripts/hooks/config-protection.js +14 -1
- package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/context/scripts/hooks/stop-goal-fit.js +4 -2
- package/context/scripts/hooks/workflow-steering.js +42 -0
- package/context/settings/assignment-provider-settings.json +33 -0
- package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
- package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
- package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
- package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
- package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
- package/docs/adr/0006-typescript-first-source-policy.md +2 -0
- package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
- package/docs/adr/0007-skill-audit.md +2 -0
- package/docs/adr/0008-kit-operation-boundary.md +2 -0
- package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
- package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
- package/docs/adr/0011-mcp-posture.md +2 -0
- package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
- package/docs/adr/0013-context-lifecycle.md +2 -0
- package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
- package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
- package/docs/adr/0016-three-hard-boundary-model.md +2 -0
- package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
- package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
- package/docs/adr/0019-kit-dependency-ownership.md +2 -0
- package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
- package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +182 -0
- package/docs/adr/README.md +49 -0
- package/docs/adr/index.md +34 -0
- package/docs/context-map.md +1 -0
- package/docs/decisions/agent-coordination.md +20 -0
- package/docs/decisions/anti-gaming-trust-security.md +20 -0
- package/docs/decisions/context-lifecycle.md +18 -0
- package/docs/decisions/core-domain-kit-boundary.md +18 -0
- package/docs/decisions/flow-flow-agents-boundary.md +18 -0
- package/docs/decisions/flow-kit.md +20 -0
- package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
- package/docs/decisions/graph-knowledge-provider.md +63 -0
- package/docs/decisions/hook-core-kit-boundary.md +18 -0
- package/docs/decisions/index.md +22 -0
- package/docs/decisions/kit-dependency-ownership.md +18 -0
- package/docs/decisions/kit-operation-boundary.md +18 -0
- package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
- package/docs/decisions/knowledge-store-provider.md +51 -0
- package/docs/decisions/kontour-resource-contract.md +18 -0
- package/docs/decisions/mcp-posture.md +18 -0
- package/docs/decisions/model-routing.md +63 -0
- package/docs/decisions/standing-directives.md +66 -0
- package/docs/decisions/three-hard-boundary-model.md +18 -0
- package/docs/decisions/trust-reconcile.md +20 -0
- package/docs/decisions/typescript-source-policy.md +48 -0
- package/docs/decisions/workflow-enforcement.md +18 -0
- package/docs/decisions/workflow-trust-state.md +20 -0
- package/docs/fixture-ownership.md +1 -0
- package/docs/workflow-shared-contracts.md +2 -1
- package/docs/workflow-usage-guide.md +7 -1
- package/evals/ci/run-baseline.sh +10 -0
- package/evals/fixtures/assignment-provider/actor-a.json +6 -0
- package/evals/fixtures/assignment-provider/actor-b.json +6 -0
- package/evals/fixtures/assignment-provider/github-issue-claimed.json +27 -0
- package/evals/fixtures/assignment-provider/github-issue-unassigned.json +7 -0
- package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
- package/evals/fixtures/assignment-provider/liveness-fresh.json +9 -0
- package/evals/fixtures/assignment-provider/liveness-stale.json +9 -0
- package/evals/integration/test_assignment_provider_github.sh +318 -0
- package/evals/integration/test_assignment_provider_local_file.sh +222 -0
- package/evals/integration/test_critique_supersession_roundtrip.sh +182 -0
- package/evals/integration/test_current_json_per_actor.sh +516 -0
- package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_gate_lockdown.sh +10 -0
- package/evals/integration/test_publish_delivery.sh +21 -4
- package/evals/integration/test_pull_work_assignment_join.sh +132 -0
- package/evals/integration/test_pull_work_liveness_preflight.sh +20 -7
- package/evals/integration/test_reconcile_soundness.sh +33 -9
- package/evals/integration/test_trust_reconcile.sh +9 -8
- package/evals/integration/test_trust_reconcile_negatives.sh +608 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +91 -3
- package/evals/run.sh +14 -0
- package/evals/static/test_flowdef_codeowners_coverage.sh +6 -0
- package/evals/static/test_knowledge_providers.sh +32 -0
- package/evals/static/test_workflow_skills.sh +15 -2
- package/kits/builder/skills/deliver/SKILL.md +36 -0
- package/kits/builder/skills/design-probe/SKILL.md +37 -0
- package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
- package/kits/builder/skills/pull-work/SKILL.md +78 -10
- package/kits/knowledge/adapters/default-store/index.js +92 -4
- package/kits/knowledge/adapters/obsidian-store/index.js +69 -4
- package/kits/knowledge/adapters/shared/codec.js +141 -0
- package/kits/knowledge/docs/README.md +121 -2
- package/kits/knowledge/docs/store-contract.md +112 -4
- package/kits/knowledge/evals/contract-suite/suite.test.js +176 -0
- package/kits/knowledge/flows/promote.flow.json +84 -0
- package/kits/knowledge/kit.json +50 -0
- package/kits/knowledge/promote/distill.js +96 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
- package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
- package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
- package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
- package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
- package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
- package/kits/knowledge/promote/fixtures/session/state.json +19 -0
- package/kits/knowledge/promote/health.js +137 -0
- package/kits/knowledge/promote/index.js +176 -0
- package/kits/knowledge/promote/ingest.js +103 -0
- package/kits/knowledge/promote/lib.js +132 -0
- package/kits/knowledge/promote/link.js +84 -0
- package/kits/knowledge/promote/promote.test.js +174 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/CONTEXT.md +12 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md +13 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md +14 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md +14 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/learnings/fixture-learning.md +7 -0
- package/kits/knowledge/providers/conformance/fixtures/work-item/issues.json +30 -0
- package/kits/knowledge/providers/conformance/suite.test.js +143 -0
- package/kits/knowledge/providers/git-repo/index.js +236 -0
- package/kits/knowledge/providers/health/health-pass.test.js +99 -0
- package/kits/knowledge/providers/health/index.js +153 -0
- package/kits/knowledge/providers/index.js +25 -0
- package/kits/knowledge/providers/lib/model.js +91 -0
- package/kits/knowledge/providers/lib/schema-validate.js +119 -0
- package/kits/knowledge/providers/markdown-vault/index.js +169 -0
- package/kits/knowledge/providers/neo4j/connection.js +121 -0
- package/kits/knowledge/providers/neo4j/cypher.js +190 -0
- package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
- package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
- package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
- package/kits/knowledge/providers/neo4j/index.js +280 -0
- package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
- package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
- package/kits/knowledge/providers/neo4j/sync.js +235 -0
- package/kits/knowledge/providers/work-item/index.js +204 -0
- package/package.json +7 -3
- package/schemas/assignment-provider-settings.schema.json +125 -0
- package/schemas/knowledge/edge.schema.json +54 -0
- package/schemas/knowledge/health-report.schema.json +45 -0
- package/schemas/knowledge/node.schema.json +49 -0
- package/schemas/knowledge/proposal.schema.json +53 -0
- package/scripts/README.md +1 -0
- package/scripts/ci/trust-reconcile.js +521 -24
- package/scripts/freeze-adrs.mjs +364 -0
- package/scripts/hooks/config-protection.js +14 -1
- package/scripts/hooks/evidence-capture.js +4 -1
- package/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/scripts/hooks/lib/current-pointer.js +123 -0
- package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
- package/scripts/hooks/stop-goal-fit.js +4 -2
- package/scripts/hooks/workflow-steering.js +42 -0
- package/scripts/statusline/flow-agents-statusline.js +3 -1
- package/src/cli/assignment-provider.ts +927 -0
- package/src/cli/effective-assignment-provider-settings.ts +112 -0
- package/src/cli/validate-workflow-artifacts.ts +5 -1
- package/src/cli/workflow-sidecar.ts +577 -139
- package/src/cli.ts +6 -0
- package/src/lib/flow-resolver.ts +35 -14
- package/src/tools/validate-source-tree.ts +2 -0
|
@@ -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.
|
|
@@ -403,12 +415,13 @@ function checkCommandForBypass(command) {
|
|
|
403
415
|
* Regex that matches a normalized (forward-slash) path ending with a protected
|
|
404
416
|
* kill-switch file: shell profiles, .claude/settings*.json,
|
|
405
417
|
* .kontourai/flow-agents/current.json,
|
|
418
|
+
* .kontourai/flow-agents/current/<actor>.json (#291 per-actor projection),
|
|
406
419
|
* .kontourai/flow-agents/.goal-fit-block-streak.json,
|
|
407
420
|
* .kontourai/flow-agents/<slug>/state.json,
|
|
408
421
|
* .kontourai/flow-agents/<slug>/trust.bundle, and deprecated runtime-shaped
|
|
409
422
|
* .flow-agents equivalents.
|
|
410
423
|
*/
|
|
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$/;
|
|
424
|
+
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
425
|
|
|
413
426
|
/**
|
|
414
427
|
* Return true when a token (an unquoted redirect target or tee argument) matches
|
|
@@ -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 =
|
|
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 };
|