@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.
- package/.github/workflows/ci.yml +12 -0
- package/CHANGELOG.md +32 -0
- package/CONTEXT.md +67 -1
- package/README.md +4 -0
- package/build/src/cli/assignment-provider.d.ts +188 -0
- package/build/src/cli/assignment-provider.js +193 -51
- package/build/src/cli/workflow-sidecar.d.ts +16 -6
- package/build/src/cli/workflow-sidecar.js +492 -45
- 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 -1
- package/context/contracts/assignment-provider-contract.md +10 -1
- package/context/contracts/execution-contract.md +78 -0
- package/context/contracts/probe-docs-write-contract.md +187 -0
- package/context/scripts/hooks/config-protection.js +24 -4
- package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/context/scripts/hooks/stop-goal-fit.js +262 -5
- package/context/scripts/hooks/workflow-steering.js +42 -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 +113 -0
- package/docs/adr/README.md +49 -0
- package/docs/adr/index.md +34 -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 +19 -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/kontour-resource-contract.md +18 -0
- package/docs/decisions/mcp-posture.md +18 -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 -1
- package/docs/workflow-usage-guide.md +1 -1
- package/evals/ci/run-baseline.sh +6 -0
- package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
- package/evals/integration/test_checkpoint_signing.sh +4 -3
- 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_gate_lockdown.sh +46 -0
- package/evals/integration/test_model_routing_escalation.sh +145 -0
- package/evals/integration/test_publish_delivery.sh +14 -6
- package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
- package/evals/integration/test_stop_hook_release.sh +552 -0
- package/evals/integration/test_trust_reconcile_negatives.sh +170 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
- package/evals/run.sh +10 -0
- package/evals/static/test_knowledge_providers.sh +13 -4
- package/evals/static/test_model_routing_hints.sh +107 -0
- package/evals/static/test_workflow_skills.sh +15 -2
- package/kits/builder/skills/builder-shape/SKILL.md +10 -0
- package/kits/builder/skills/deliver/SKILL.md +69 -11
- package/kits/builder/skills/design-probe/SKILL.md +47 -0
- package/kits/builder/skills/execute-plan/SKILL.md +13 -0
- package/kits/builder/skills/fix-bug/SKILL.md +17 -0
- package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
- package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
- package/kits/builder/skills/plan-work/SKILL.md +9 -0
- package/kits/builder/skills/pull-work/SKILL.md +10 -0
- package/kits/builder/skills/review-work/SKILL.md +11 -0
- package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
- package/kits/builder/skills/verify-work/SKILL.md +11 -0
- package/kits/knowledge/adapters/default-store/index.js +147 -18
- package/kits/knowledge/adapters/flow-runner/index.js +912 -16
- package/kits/knowledge/adapters/obsidian-store/index.js +97 -14
- package/kits/knowledge/adapters/shared/codec.js +265 -0
- package/kits/knowledge/docs/README.md +121 -2
- package/kits/knowledge/docs/store-contract.md +517 -7
- package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
- package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
- package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
- package/kits/knowledge/evals/contract-suite/suite.test.js +212 -0
- package/kits/knowledge/evals/freshness/suite.test.js +339 -0
- package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
- package/kits/knowledge/evals/retirement/suite.test.js +1 -1
- package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
- package/kits/knowledge/flows/promote.flow.json +84 -0
- package/kits/knowledge/kit.json +15 -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/suite.test.js +18 -0
- package/kits/knowledge/providers/index.js +1 -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/package.json +4 -2
- package/schemas/workflow-handoff.schema.json +6 -0
- package/scripts/README.md +1 -0
- package/scripts/ci/mint-attestation.js +33 -6
- package/scripts/ci/trust-reconcile.js +144 -26
- package/scripts/freeze-adrs.mjs +364 -0
- package/scripts/hooks/config-protection.js +24 -4
- 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 +262 -5
- package/scripts/hooks/workflow-steering.js +42 -0
- package/scripts/statusline/flow-agents-statusline.js +3 -1
- package/src/cli/assignment-provider.ts +239 -59
- package/src/cli/workflow-sidecar.ts +529 -43
- package/src/lib/flow-resolver.ts +35 -14
- package/src/tools/validate-source-tree.ts +2 -1
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync / materialize: import the file/work-item providers into Neo4j.
|
|
3
|
+
*
|
|
4
|
+
* The file stores remain the SOURCE OF TRUTH; the graph is a queryable
|
|
5
|
+
* materialized view. Sync reuses the #317 providers as READERS (no forked
|
|
6
|
+
* extractors) and MERGEs their combined graph into Neo4j with MERGE semantics,
|
|
7
|
+
* so a re-sync of unchanged data reports zero writes (AC1). A snapshot digest +
|
|
8
|
+
* timestamp is recorded on the graph so the second run can short-circuit and so
|
|
9
|
+
* the graph is point-in-time attributable.
|
|
10
|
+
*
|
|
11
|
+
* Marker tolerance (spike finding 3: 3/19 metadata markers failed JSON.parse in
|
|
12
|
+
* the wild): a provider that throws while reading MUST NOT kill the whole sync.
|
|
13
|
+
* Each provider is read behind a guard that falls back to an empty contribution
|
|
14
|
+
* and records the error; the work-item provider already falls back from a
|
|
15
|
+
* malformed metadata marker to prose refs, and this guard covers the rest.
|
|
16
|
+
*
|
|
17
|
+
* Pure planning (planSync/computeDigest) is separated from IO (syncToNeo4j) so
|
|
18
|
+
* idempotency is unit-testable with no driver and no Docker.
|
|
19
|
+
*
|
|
20
|
+
* @module providers/neo4j/sync
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import {
|
|
25
|
+
typeToLabel,
|
|
26
|
+
edgeTypeToRel,
|
|
27
|
+
nodeToProps,
|
|
28
|
+
edgeToProps,
|
|
29
|
+
SNAPSHOT_LABEL,
|
|
30
|
+
SNAPSHOT_ID,
|
|
31
|
+
READ_SNAPSHOT_CYPHER,
|
|
32
|
+
} from "./cypher.js";
|
|
33
|
+
|
|
34
|
+
/** Deterministic JSON (sorted keys) for hashing. */
|
|
35
|
+
function canonical(value) {
|
|
36
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
37
|
+
if (value && typeof value === "object") {
|
|
38
|
+
return `{${Object.keys(value)
|
|
39
|
+
.sort()
|
|
40
|
+
.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`)
|
|
41
|
+
.join(",")}}`;
|
|
42
|
+
}
|
|
43
|
+
return JSON.stringify(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The graph is a materialized view keyed on CONTENT, not on when it was read.
|
|
48
|
+
* Provenance carries volatile fields (retrieved_at, agent) that change every
|
|
49
|
+
* read — including them would defeat idempotency (a re-sync of unchanged stores
|
|
50
|
+
* would rewrite every element). Strip them for identity; the snapshot's
|
|
51
|
+
* synced_at records the point-in-time separately.
|
|
52
|
+
*/
|
|
53
|
+
function contentIdentity(el) {
|
|
54
|
+
const clone = JSON.parse(JSON.stringify(el));
|
|
55
|
+
if (clone.provenance) {
|
|
56
|
+
delete clone.provenance.retrieved_at;
|
|
57
|
+
delete clone.provenance.agent;
|
|
58
|
+
}
|
|
59
|
+
return clone;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Content hash of a single node/edge (drives the idempotent hash-guarded SET). */
|
|
63
|
+
export function hashElement(el) {
|
|
64
|
+
return createHash("sha256").update(canonical(contentIdentity(el))).digest("hex").slice(0, 16);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Digest of the whole materialized graph (drives the zero-write short-circuit). */
|
|
68
|
+
export function computeDigest(graph) {
|
|
69
|
+
const nodes = [...(graph.nodes || [])].map(contentIdentity).sort((a, b) => a.id.localeCompare(b.id));
|
|
70
|
+
const edges = [...(graph.edges || [])].map(contentIdentity).sort((a, b) => a.id.localeCompare(b.id));
|
|
71
|
+
return createHash("sha256").update(canonical({ nodes, edges })).digest("hex");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Read one provider's graph behind a guard; never let one provider kill sync. */
|
|
75
|
+
async function safeReadGraph(provider, errors) {
|
|
76
|
+
try {
|
|
77
|
+
const g = await provider.readGraph();
|
|
78
|
+
return { nodes: g.nodes || [], edges: g.edges || [] };
|
|
79
|
+
} catch (err) {
|
|
80
|
+
errors.push({ provider: provider?.id || provider?.constructor?.name || "unknown", error: String(err && err.message || err) });
|
|
81
|
+
return { nodes: [], edges: [] };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Materialize the combined graph from the provider readers. Dedupes nodes and
|
|
87
|
+
* edges by id (last write wins on id collision across providers).
|
|
88
|
+
* @param {object[]} providers #317 provider instances (vault, git-repo, work-item, …)
|
|
89
|
+
* @returns {Promise<{ nodes:object[], edges:object[], errors:object[] }>}
|
|
90
|
+
*/
|
|
91
|
+
export async function materializeGraph(providers) {
|
|
92
|
+
const errors = [];
|
|
93
|
+
const nodeById = new Map();
|
|
94
|
+
const edgeById = new Map();
|
|
95
|
+
for (const provider of providers) {
|
|
96
|
+
const { nodes, edges } = await safeReadGraph(provider, errors);
|
|
97
|
+
for (const n of nodes) nodeById.set(n.id, n);
|
|
98
|
+
for (const e of edges) edgeById.set(e.id, e);
|
|
99
|
+
}
|
|
100
|
+
return { nodes: [...nodeById.values()], edges: [...edgeById.values()], errors };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Pure sync plan. Given the materialized graph and the prior snapshot digest,
|
|
105
|
+
* decide whether anything changed and produce the parameterized MERGE
|
|
106
|
+
* statements. When the digest is unchanged, the plan is a no-op (AC1: second run
|
|
107
|
+
* reports zero writes) unless `force` is set.
|
|
108
|
+
*
|
|
109
|
+
* @param {{nodes:object[],edges:object[]}} graph
|
|
110
|
+
* @param {string|null} priorDigest
|
|
111
|
+
* @param {object} [options]
|
|
112
|
+
* @param {boolean} [options.force=false] emit statements even when unchanged
|
|
113
|
+
* @returns {{ digest:string, unchanged:boolean, statements:{cypher:string,params:object}[] }}
|
|
114
|
+
*/
|
|
115
|
+
export function planSync(graph, priorDigest, options = {}) {
|
|
116
|
+
const digest = computeDigest(graph);
|
|
117
|
+
const unchanged = digest === priorDigest;
|
|
118
|
+
if (unchanged && !options.force) {
|
|
119
|
+
return { digest, unchanged, statements: [] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const statements = [];
|
|
123
|
+
|
|
124
|
+
// Nodes grouped by contract type -> one MERGE per Neo4j label. Hash-guarded
|
|
125
|
+
// SET so an unchanged node produces zero propertiesSet on re-run.
|
|
126
|
+
const nodesByLabel = new Map();
|
|
127
|
+
for (const n of graph.nodes || []) {
|
|
128
|
+
const label = typeToLabel(n.type);
|
|
129
|
+
if (!nodesByLabel.has(label)) nodesByLabel.set(label, []);
|
|
130
|
+
nodesByLabel.get(label).push(nodeToProps(n, hashElement(n)));
|
|
131
|
+
}
|
|
132
|
+
for (const [label, rows] of nodesByLabel) {
|
|
133
|
+
statements.push({
|
|
134
|
+
cypher: `UNWIND $rows AS r MERGE (n:${label} {id:r.id}) WITH n, r WHERE coalesce(n._kg_hash,'') <> r._kg_hash SET n += r`,
|
|
135
|
+
params: { rows },
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Edges grouped by rel type. Endpoints matched by id regardless of label
|
|
140
|
+
// (spike: polyglot property bags "just work"); edges to a not-yet-synced node
|
|
141
|
+
// are skipped by the MATCH (external/dangling refs stay out of the view).
|
|
142
|
+
const edgesByRel = new Map();
|
|
143
|
+
for (const e of graph.edges || []) {
|
|
144
|
+
const rel = edgeTypeToRel(e.type);
|
|
145
|
+
if (!edgesByRel.has(rel)) edgesByRel.set(rel, []);
|
|
146
|
+
edgesByRel.get(rel).push(edgeToProps(e, hashElement(e)));
|
|
147
|
+
}
|
|
148
|
+
for (const [rel, rows] of edgesByRel) {
|
|
149
|
+
statements.push({
|
|
150
|
+
cypher: `UNWIND $rows AS r MATCH (a {id:r.from}) MATCH (b {id:r.to}) MERGE (a)-[x:${rel} {id:r.id}]->(b) WITH x, r WHERE coalesce(x._kg_hash,'') <> r._kg_hash SET x += r`,
|
|
151
|
+
params: { rows },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { digest, unchanged, statements };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Read the prior snapshot digest from Neo4j (null if never synced). */
|
|
159
|
+
async function readPriorDigest(session) {
|
|
160
|
+
const res = await session.run(READ_SNAPSHOT_CYPHER, { id: SNAPSHOT_ID });
|
|
161
|
+
const rec = res.records && res.records[0];
|
|
162
|
+
if (!rec) return null;
|
|
163
|
+
const props = rec.get("props");
|
|
164
|
+
return (props && props.digest) || null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function sumWrites(counters) {
|
|
168
|
+
const u = typeof counters.updates === "function" ? counters.updates() : counters;
|
|
169
|
+
return (u.nodesCreated || 0) + (u.relationshipsCreated || 0) + (u.propertiesSet || 0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Materialize the providers and MERGE them into Neo4j idempotently.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} options
|
|
176
|
+
* @param {object} options.driver neo4j driver (or the injected fake)
|
|
177
|
+
* @param {string} [options.database]
|
|
178
|
+
* @param {object[]} [options.providers] #317 providers to read (or pass graph)
|
|
179
|
+
* @param {{nodes,edges}} [options.graph] a pre-materialized graph (skips readers)
|
|
180
|
+
* @param {boolean} [options.force=false] re-run MERGEs even if the digest matches
|
|
181
|
+
* @returns {Promise<{ writes:number, unchanged:boolean, digest:string, nodes:number, edges:number, snapshot:object, errors:object[] }>}
|
|
182
|
+
*/
|
|
183
|
+
export async function syncToNeo4j(options = {}) {
|
|
184
|
+
const { driver, database, force = false } = options;
|
|
185
|
+
let errors = [];
|
|
186
|
+
let graph = options.graph;
|
|
187
|
+
if (!graph) {
|
|
188
|
+
const mat = await materializeGraph(options.providers || []);
|
|
189
|
+
graph = { nodes: mat.nodes, edges: mat.edges };
|
|
190
|
+
errors = mat.errors;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const session = driver.session(database ? { database } : undefined);
|
|
194
|
+
try {
|
|
195
|
+
const priorDigest = await readPriorDigest(session);
|
|
196
|
+
const plan = planSync(graph, priorDigest, { force });
|
|
197
|
+
|
|
198
|
+
if (plan.unchanged && !force) {
|
|
199
|
+
return {
|
|
200
|
+
writes: 0,
|
|
201
|
+
unchanged: true,
|
|
202
|
+
digest: plan.digest,
|
|
203
|
+
nodes: graph.nodes.length,
|
|
204
|
+
edges: graph.edges.length,
|
|
205
|
+
snapshot: { digest: plan.digest },
|
|
206
|
+
errors,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let writes = 0;
|
|
211
|
+
for (const stmt of plan.statements) {
|
|
212
|
+
const res = await session.run(stmt.cypher, stmt.params);
|
|
213
|
+
writes += sumWrites(res.summary.counters);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const syncedAt = new Date().toISOString();
|
|
217
|
+
const snapRes = await session.run(
|
|
218
|
+
`MERGE (s:${SNAPSHOT_LABEL} {id:$id}) WITH s, $digest AS d WHERE coalesce(s.digest,'') <> d SET s.digest = d, s.synced_at = $syncedAt, s.node_count = $nc, s.edge_count = $ec`,
|
|
219
|
+
{ id: SNAPSHOT_ID, digest: plan.digest, syncedAt, nc: graph.nodes.length, ec: graph.edges.length },
|
|
220
|
+
);
|
|
221
|
+
writes += sumWrites(snapRes.summary.counters);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
writes,
|
|
225
|
+
unchanged: plan.unchanged,
|
|
226
|
+
digest: plan.digest,
|
|
227
|
+
nodes: graph.nodes.length,
|
|
228
|
+
edges: graph.edges.length,
|
|
229
|
+
snapshot: { digest: plan.digest, synced_at: syncedAt },
|
|
230
|
+
errors,
|
|
231
|
+
};
|
|
232
|
+
} finally {
|
|
233
|
+
await session.close();
|
|
234
|
+
}
|
|
235
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kontourai/flow-agents",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "Flow Agents — a Kontour product that applies Flow and Veritas discipline as a portable process layer inside the agent tools you already use: Claude Code, Codex, Kiro, opencode, pi, and GitHub Actions — with framework adapters (AWS Strands preview) on the same policy-engine contract.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -139,6 +139,7 @@
|
|
|
139
139
|
"check:content-boundary": "node scripts/check-content-boundary.cjs",
|
|
140
140
|
"check:decisions": "node scripts/check-decisions.cjs check",
|
|
141
141
|
"gen:decisions-index": "node scripts/check-decisions.cjs gen-index",
|
|
142
|
+
"freeze:adrs": "node scripts/freeze-adrs.mjs",
|
|
142
143
|
"prepack": "npm run build --silent && npm run validate:source --",
|
|
143
144
|
"kit": "npm run build --silent && node build/src/cli.js kit"
|
|
144
145
|
},
|
|
@@ -152,6 +153,7 @@
|
|
|
152
153
|
},
|
|
153
154
|
"optionalDependencies": {
|
|
154
155
|
"@kontourai/surface": "^2.0.0",
|
|
155
|
-
"hachure": "^0.9.0"
|
|
156
|
+
"hachure": "^0.9.0",
|
|
157
|
+
"neo4j-driver": "^5.28.0"
|
|
156
158
|
}
|
|
157
159
|
}
|
package/scripts/README.md
CHANGED
|
@@ -70,6 +70,7 @@ renamed, or changes category, update the table and the validator together.
|
|
|
70
70
|
| `lib/actor-identity.js` | shared hook library | `evals/integration/test_actor_identity.sh` | Shared runtime-agnostic actor identity resolver (`resolveActor`) consumed by `workflow-steering.js` and `workflow-sidecar.js` to retire the shared `"local"` liveness-actor default (issue #287). |
|
|
71
71
|
| `lib/audit-transport.sh` | shared hook library | `evals/integration/test_hook_category_behaviors.sh`, `evals/integration/test_telemetry.sh` | Shared audit event transport functions. |
|
|
72
72
|
| `lib/config-protection-remedies.js` | shared hook library | `evals/integration/test_hook_category_behaviors.sh` | Sanctioned-remedy table for `config-protection.js` (WS8/ADR 0020 extraction). |
|
|
73
|
+
| `lib/current-pointer.js` | shared hook library | `evals/integration/test_current_json_per_actor.sh` | Shared per-actor "current" pointer reader/writer (`readCurrentPointer`, `writePerActorCurrent`) — the compat-shim fallback (per-actor `current/<actor>.json` first, legacy global `current.json` fallback) every actor-aware consumer routes through (issue #291). |
|
|
73
74
|
| `lib/hook-flags.js` | shared hook library | `evals/integration/test_hook_category_behaviors.sh` | Shared profile/disable flag parsing. |
|
|
74
75
|
| `lib/liveness-heartbeat.js` | shared hook library | `evals/integration/test_liveness_heartbeat.sh` | Shared tool-activity liveness heartbeat (`maybeEmitHeartbeat`); rides `postToolUse` across all four telemetry hook wrappers, throttled and fail-open (issue #288). |
|
|
75
76
|
| `lib/liveness-policy.js` | shared hook library | `evals/integration/test_workflow_sidecar_writer.sh`, `evals/integration/test_liveness_heartbeat.sh` | Shared liveness on/off predicate (`isLivenessEnabled`, default-on/opt-out) and TTL/heartbeat-throttle default resolution (issue #288). |
|
|
@@ -104,23 +104,50 @@ function loadCiResults(repoRoot) {
|
|
|
104
104
|
};
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Discover the delivery trust.bundle across the flat and per-session (#379) layouts.
|
|
109
|
+
* Prefers the flat `delivery/trust.bundle` (back-compat), then `delivery/<slug>/trust.bundle`
|
|
110
|
+
* for each immediate subdirectory (sorted). Returns the first existing path, or null.
|
|
111
|
+
* This mirrors scripts/ci/trust-reconcile.js resolveDeliveryCandidates() precedence so the
|
|
112
|
+
* attestation subject binds to the same bundle the reconciler read. best-effort — a missing
|
|
113
|
+
* or unreadable delivery/ yields null.
|
|
114
|
+
*/
|
|
115
|
+
function discoverDeliveryBundle(repoRoot) {
|
|
116
|
+
const deliveryRoot = path.join(repoRoot, 'delivery');
|
|
117
|
+
const flat = path.join(deliveryRoot, 'trust.bundle');
|
|
118
|
+
if (fs.existsSync(flat)) return flat;
|
|
119
|
+
let entries = [];
|
|
120
|
+
try {
|
|
121
|
+
entries = fs.readdirSync(deliveryRoot, { withFileTypes: true });
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const subdirs = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
126
|
+
for (const name of subdirs) {
|
|
127
|
+
const candidate = path.join(deliveryRoot, name, 'trust.bundle');
|
|
128
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
107
133
|
/**
|
|
108
134
|
* Determine the subject artifact for the in-toto statement.
|
|
109
135
|
*
|
|
110
|
-
* If delivery
|
|
111
|
-
* Otherwise synthesize a results artifact from the predicate
|
|
136
|
+
* If a delivery trust.bundle is present (flat or per-session, #379), use it as the subject
|
|
137
|
+
* (sha256 of its bytes). Otherwise synthesize a results artifact from the predicate.
|
|
112
138
|
*
|
|
113
139
|
* @param {object} predicate — the predicate that will appear in the statement
|
|
114
140
|
* @param {string} repoRoot
|
|
115
141
|
* @returns {{ subjectName: string, subjectDigest: string, fromBundle: boolean }}
|
|
116
142
|
*/
|
|
117
143
|
function resolveSubject(predicate, repoRoot) {
|
|
118
|
-
const bundlePath =
|
|
119
|
-
if (
|
|
144
|
+
const bundlePath = discoverDeliveryBundle(repoRoot);
|
|
145
|
+
if (bundlePath) {
|
|
120
146
|
const bundleBytes = fs.readFileSync(bundlePath);
|
|
121
147
|
const digest = sha256hex(bundleBytes);
|
|
122
|
-
|
|
123
|
-
|
|
148
|
+
const rel = path.relative(repoRoot, bundlePath);
|
|
149
|
+
process.stdout.write(`[mint-attestation] subject: ${rel} sha256=${digest.slice(0, 16)}...\n`);
|
|
150
|
+
return { subjectName: rel, subjectDigest: digest, fromBundle: true };
|
|
124
151
|
}
|
|
125
152
|
|
|
126
153
|
// No bundle present: synthesize subject from predicate JSON (self-attesting the results).
|
|
@@ -522,32 +522,139 @@ const DELIVERY_DIR = 'delivery';
|
|
|
522
522
|
|
|
523
523
|
/**
|
|
524
524
|
* Single seam: every delivery/ path resolution in this file routes through here.
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
* delivery/<
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
525
|
+
*
|
|
526
|
+
* #335/#379 (per-session delivery paths) is IMPLEMENTED here now, not deferred: a shared
|
|
527
|
+
* `delivery/<filename>` guarantees a git merge conflict between ANY two concurrent
|
|
528
|
+
* deliveries — and a conflicting (DIRTY) PR gets NO `pull_request` workflows scheduled, so
|
|
529
|
+
* the required Trust Reconcile check silently never runs (field incidents #330/#358/#378).
|
|
530
|
+
* The fix is per-session paths so concurrent deliveries write to DISTINCT files and never
|
|
531
|
+
* contend.
|
|
532
|
+
*
|
|
533
|
+
* Returned candidate order (precedence, first is highest):
|
|
534
|
+
* 1. the flat `delivery/<filename>` — kept FIRST for full back-compat. A repo that never
|
|
535
|
+
* adopts per-session layout sees byte-identical behavior; an already-committed flat
|
|
536
|
+
* bundle from before this change still resolves.
|
|
537
|
+
* 2. `delivery/<slug>/<filename>` for every immediate subdirectory of `delivery/`, sorted
|
|
538
|
+
* by name for determinism.
|
|
539
|
+
*
|
|
540
|
+
* Ordering here is ONLY the tie-break / fallback. Which candidate actually reconciles is
|
|
541
|
+
* decided by COMMIT-OWNERSHIP (discoverBundle() + bundleAttestsThisChange()), not directory
|
|
542
|
+
* order: among the candidates, the one whose checkpoint attests an ancestor-or-equal commit
|
|
543
|
+
* of THIS change wins, and stale siblings from other concurrent sessions are ignored.
|
|
544
|
+
* discoverBundle() and discoverDeclaredMarker() both iterate whatever this returns, so the
|
|
545
|
+
* per-session layout composes with zero call-site changes.
|
|
532
546
|
*/
|
|
533
547
|
function resolveDeliveryCandidates(repoRoot, filename) {
|
|
534
|
-
|
|
548
|
+
const deliveryRoot = path.join(repoRoot, DELIVERY_DIR);
|
|
549
|
+
const candidates = [path.join(deliveryRoot, filename)];
|
|
550
|
+
// #379: append delivery/<slug>/<filename> for each immediate subdirectory of delivery/.
|
|
551
|
+
// readdirSync is best-effort — a missing delivery/ (or an unreadable one) yields just the
|
|
552
|
+
// flat candidate, never throws.
|
|
553
|
+
let entries = [];
|
|
554
|
+
try {
|
|
555
|
+
entries = fs.readdirSync(deliveryRoot, { withFileTypes: true });
|
|
556
|
+
} catch {
|
|
557
|
+
entries = [];
|
|
558
|
+
}
|
|
559
|
+
const subdirs = entries
|
|
560
|
+
.filter((e) => e.isDirectory())
|
|
561
|
+
.map((e) => e.name)
|
|
562
|
+
.sort();
|
|
563
|
+
for (const name of subdirs) {
|
|
564
|
+
candidates.push(path.join(deliveryRoot, name, filename));
|
|
565
|
+
}
|
|
566
|
+
return candidates;
|
|
535
567
|
}
|
|
536
568
|
|
|
537
569
|
/**
|
|
538
570
|
* Auto-discover the delivery bundle when no explicit path is provided.
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
571
|
+
*
|
|
572
|
+
* Gathers every existing candidate — flat `delivery/trust.bundle`, flat
|
|
573
|
+
* `delivery/trust.checkpoint.json`, and their `delivery/<slug>/…` per-session siblings (via
|
|
574
|
+
* resolveDeliveryCandidates()) — with trust.bundle preferred over trust.checkpoint.json.
|
|
575
|
+
*
|
|
576
|
+
* #379 (per-session, ownership-aware selection): when `changeSha` is known, the candidate
|
|
577
|
+
* whose checkpoint attests an ancestor-or-equal commit of THIS change wins (same
|
|
578
|
+
* bundleAttestsThisChange() semantics the flat staleness gate already uses). Stale/older
|
|
579
|
+
* siblings from OTHER concurrent sessions are ignored.
|
|
580
|
+
*
|
|
581
|
+
* PREFER-NEWEST among owning candidates. More than one candidate can legitimately "own" the
|
|
582
|
+
* change at once: an inherited FLAT bundle's commit_sha can be a real ancestor of HEAD in a
|
|
583
|
+
* merge-commit repo (it was committed on the trunk's linear history before this branch
|
|
584
|
+
* point), AND this session's fresh per-session bundle attests a NEWER ancestor (the delivery
|
|
585
|
+
* commit right before HEAD). "First-fresh-wins" would then select the STALE inherited flat
|
|
586
|
+
* bundle purely because it sorts first — reconciling last delivery's claims against this
|
|
587
|
+
* change's CI. So among all owning candidates we pick the one attesting the NEWEST commit
|
|
588
|
+
* (the descendant-most: the owning candidate whose commit_sha every other owning candidate's
|
|
589
|
+
* commit_sha is an ancestor of), via the same `git merge-base --is-ancestor` primitive. This
|
|
590
|
+
* makes an inherited/older owning bundle HARMLESS whether or not it was ever pruned — the
|
|
591
|
+
* fresh per-session bundle wins on recency, not on being deleted first.
|
|
592
|
+
*
|
|
593
|
+
* When no candidate owns this change (or `changeSha` is unknown, e.g. a local run with no
|
|
594
|
+
* TRUST_RECONCILE_SHA), the first existing candidate is returned so the caller's staleness
|
|
595
|
+
* gate below handles it exactly as before: a stale bundle is treated as absent, a malformed
|
|
596
|
+
* one still raises the Step 2 read error.
|
|
597
|
+
*
|
|
598
|
+
* Returns null if no candidate exists at all — the caller then falls through to
|
|
599
|
+
* delivery/DECLARED marker resolution (ADR 0022 §1); bundle absence is not fail-open.
|
|
543
600
|
*/
|
|
544
|
-
function discoverBundle(repoRoot) {
|
|
601
|
+
function discoverBundle(repoRoot, changeSha) {
|
|
602
|
+
const candidates = [];
|
|
545
603
|
for (const filename of ['trust.bundle', 'trust.checkpoint.json']) {
|
|
546
604
|
for (const candidate of resolveDeliveryCandidates(repoRoot, filename)) {
|
|
547
|
-
if (fs.existsSync(candidate))
|
|
605
|
+
if (fs.existsSync(candidate)) candidates.push(candidate);
|
|
548
606
|
}
|
|
549
607
|
}
|
|
550
|
-
return null;
|
|
608
|
+
if (candidates.length === 0) return null;
|
|
609
|
+
|
|
610
|
+
const deliveryRoot = path.join(repoRoot, DELIVERY_DIR);
|
|
611
|
+
const hasPerSession = candidates.some((c) => path.dirname(c) !== deliveryRoot);
|
|
612
|
+
|
|
613
|
+
// Ownership-aware selection (#379). Only meaningful when we know this change's sha; a
|
|
614
|
+
// candidate that fails to parse is skipped here (never treated as owning) — if it turns
|
|
615
|
+
// out to be the only candidate, it is still returned below so Step 2's "failed to read
|
|
616
|
+
// bundle" diagnostic fires unchanged.
|
|
617
|
+
if (changeSha) {
|
|
618
|
+
const owning = []; // { candidate, bundleSha } — every candidate that attests this change
|
|
619
|
+
for (const candidate of candidates) {
|
|
620
|
+
let json = null;
|
|
621
|
+
try {
|
|
622
|
+
json = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
623
|
+
} catch {
|
|
624
|
+
json = null;
|
|
625
|
+
}
|
|
626
|
+
if (!json) continue;
|
|
627
|
+
const { fresh, bundleSha } = bundleAttestsThisChange(repoRoot, candidate, json, changeSha);
|
|
628
|
+
if (fresh) owning.push({ candidate, bundleSha });
|
|
629
|
+
}
|
|
630
|
+
if (owning.length > 0) {
|
|
631
|
+
// Prefer the owning candidate attesting the NEWEST (descendant-most) commit. `best` is
|
|
632
|
+
// upgraded to a candidate whose commit is a strict descendant of the current best's —
|
|
633
|
+
// i.e. best.sha is an ancestor of cand.sha. Every owning entry has a non-null bundleSha
|
|
634
|
+
// (bundleAttestsThisChange returns fresh=false otherwise). Ties / incomparable siblings
|
|
635
|
+
// keep the earlier (candidate-order) entry, so the flat path still wins a genuine tie.
|
|
636
|
+
let best = owning[0];
|
|
637
|
+
for (let i = 1; i < owning.length; i++) {
|
|
638
|
+
const cand = owning[i];
|
|
639
|
+
if (cand.bundleSha !== best.bundleSha && isAncestorCommit(repoRoot, best.bundleSha, cand.bundleSha)) {
|
|
640
|
+
best = cand;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (hasPerSession) {
|
|
644
|
+
process.stdout.write(`[trust-reconcile] #379: selected delivery candidate ${path.relative(repoRoot, best.candidate)} — attests this change ${changeSha} (${candidates.length} candidate(s) present across flat + per-session delivery/<slug>/; ${owning.length} owning, newest wins; older/stale ignored)\n`);
|
|
645
|
+
}
|
|
646
|
+
return best.candidate;
|
|
647
|
+
}
|
|
648
|
+
// No candidate attests this change. When per-session candidates were in play, emit a
|
|
649
|
+
// loud, grep-stable concurrency hint (#379) so the next agent can tell a concurrent
|
|
650
|
+
// per-session collision apart from a plain stale/absent bundle — the per-candidate
|
|
651
|
+
// stale line still prints in runTrustReconcile's staleness gate for the returned one.
|
|
652
|
+
if (hasPerSession) {
|
|
653
|
+
const rels = candidates.map((c) => path.relative(repoRoot, c)).join(', ');
|
|
654
|
+
process.stdout.write(`[trust-reconcile] #379: examined ${candidates.length} delivery candidate(s) (flat + per-session delivery/<slug>/); none attests this change ${changeSha}. If this is a concurrent-delivery collision, the OWNING session's bundle is not on this ref — re-publish this session's delivery, or check whether main moved under the PR. Candidates: ${rels}\n`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return candidates[0];
|
|
551
658
|
}
|
|
552
659
|
|
|
553
660
|
/**
|
|
@@ -813,22 +920,30 @@ function resolveDeclaredExemption(repoRoot, ctx) {
|
|
|
813
920
|
* src/cli/workflow-sidecar.ts). trust.bundle itself carries NO commit/branch metadata
|
|
814
921
|
* (schemaVersion 5: {schemaVersion, source, claims, evidence, policies, events} — confirmed
|
|
815
922
|
* by inspection), so when the discovered bundle IS trust.bundle, this falls through to its
|
|
816
|
-
*
|
|
817
|
-
*
|
|
923
|
+
* SIBLING trust.checkpoint.json for the binding — publishDelivery() always writes both
|
|
924
|
+
* together into the SAME directory.
|
|
925
|
+
*
|
|
926
|
+
* #379: the sibling is resolved from the bundle's OWN directory (path.dirname(bundlePath)),
|
|
927
|
+
* not from a global resolveDeliveryCandidates() scan. In the per-session layout each session
|
|
928
|
+
* gets its own delivery/<slug>/ dir, and a global scan would cross-contaminate — pairing a
|
|
929
|
+
* per-session trust.bundle with the FLAT (or a different session's) trust.checkpoint.json and
|
|
930
|
+
* reading the wrong commit binding. Same-directory resolution is identical to the prior
|
|
931
|
+
* behavior for the flat layout (delivery/trust.bundle ↔ delivery/trust.checkpoint.json) and
|
|
932
|
+
* correct for per-session.
|
|
818
933
|
*/
|
|
819
934
|
function extractBundleCommitSha(repoRoot, bundlePath, bundleJson) {
|
|
820
935
|
if (bundleJson && typeof bundleJson.commit_sha === 'string' && bundleJson.commit_sha.trim()) {
|
|
821
936
|
return bundleJson.commit_sha.trim();
|
|
822
937
|
}
|
|
823
|
-
|
|
824
|
-
|
|
938
|
+
const siblingCheckpoint = path.join(path.dirname(bundlePath), 'trust.checkpoint.json');
|
|
939
|
+
if (siblingCheckpoint !== bundlePath && fs.existsSync(siblingCheckpoint)) {
|
|
825
940
|
try {
|
|
826
|
-
const checkpoint = JSON.parse(fs.readFileSync(
|
|
941
|
+
const checkpoint = JSON.parse(fs.readFileSync(siblingCheckpoint, 'utf8'));
|
|
827
942
|
if (checkpoint && typeof checkpoint.commit_sha === 'string' && checkpoint.commit_sha.trim()) {
|
|
828
943
|
return checkpoint.commit_sha.trim();
|
|
829
944
|
}
|
|
830
945
|
} catch {
|
|
831
|
-
// Malformed sibling checkpoint — no usable binding from it;
|
|
946
|
+
// Malformed sibling checkpoint — no usable binding from it; fall through to null.
|
|
832
947
|
}
|
|
833
948
|
}
|
|
834
949
|
return null;
|
|
@@ -907,14 +1022,17 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null, mani
|
|
|
907
1022
|
// (.github/workflows/trust-reconcile.yml) never passes --bundle; it always relies on
|
|
908
1023
|
// auto-discovery, which is exactly the path PR #278's stale-bundle incident went through.
|
|
909
1024
|
const explicitBundlePath = bundle || process.env.TRUST_RECONCILE_BUNDLE || null;
|
|
910
|
-
let bundlePath = explicitBundlePath || discoverBundle(resolvedRepoRoot) || null;
|
|
911
|
-
const bundleWasAutoDiscovered = !explicitBundlePath && !!bundlePath;
|
|
912
1025
|
|
|
913
|
-
// Scope-matching context (ref/actor/sha) — resolved once, reused by
|
|
914
|
-
//
|
|
915
|
-
//
|
|
1026
|
+
// Scope-matching context (ref/actor/sha) — resolved once, reused by ownership-aware
|
|
1027
|
+
// auto-discovery (#379, immediately below), the bundle-ownership staleness check further
|
|
1028
|
+
// down (ADR 0022 addendum §2), and the delivery/DECLARED exemption path (bundle-absent
|
|
1029
|
+
// branch). Resolved BEFORE auto-discovery because discoverBundle() now uses scopeCtx.sha
|
|
1030
|
+
// to pick, among per-session candidates, the one that attests THIS change.
|
|
916
1031
|
const scopeCtx = resolveScopeContext(resolvedRepoRoot);
|
|
917
1032
|
|
|
1033
|
+
let bundlePath = explicitBundlePath || discoverBundle(resolvedRepoRoot, scopeCtx.sha) || null;
|
|
1034
|
+
const bundleWasAutoDiscovered = !explicitBundlePath && !!bundlePath;
|
|
1035
|
+
|
|
918
1036
|
// Event-scoped enforcement (ADR 0022 addendum, part 4): bundle-required enforcement
|
|
919
1037
|
// (and the staleness-gate consequence of it, immediately below) applies ONLY when this
|
|
920
1038
|
// run is gating a proposed change. A push run on main happens AFTER the gating PR run
|