@kontourai/flow-agents 3.1.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 +4 -0
- package/CHANGELOG.md +17 -0
- package/build/src/cli/assignment-provider.d.ts +45 -0
- package/build/src/cli/assignment-provider.js +97 -12
- package/build/src/cli/workflow-sidecar.d.ts +14 -4
- package/build/src/cli/workflow-sidecar.js +100 -10
- package/context/contracts/assignment-provider-contract.md +1 -1
- package/context/contracts/execution-contract.md +78 -0
- package/context/scripts/hooks/config-protection.js +11 -4
- package/context/scripts/hooks/stop-goal-fit.js +259 -4
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
- package/evals/ci/run-baseline.sh +2 -0
- package/evals/integration/test_checkpoint_signing.sh +4 -3
- package/evals/integration/test_gate_lockdown.sh +36 -0
- package/evals/integration/test_model_routing_escalation.sh +145 -0
- package/evals/integration/test_publish_delivery.sh +14 -6
- package/evals/integration/test_stop_hook_release.sh +552 -0
- package/evals/integration/test_trust_reconcile_negatives.sh +170 -0
- package/evals/run.sh +6 -0
- package/evals/static/test_model_routing_hints.sh +107 -0
- package/kits/builder/skills/builder-shape/SKILL.md +10 -0
- package/kits/builder/skills/deliver/SKILL.md +52 -11
- package/kits/builder/skills/design-probe/SKILL.md +10 -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/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 +56 -15
- package/kits/knowledge/adapters/flow-runner/index.js +912 -16
- package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
- package/kits/knowledge/adapters/shared/codec.js +124 -0
- package/kits/knowledge/docs/store-contract.md +405 -3
- 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 +36 -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/package.json +1 -1
- package/schemas/workflow-handoff.schema.json +6 -0
- package/scripts/ci/mint-attestation.js +33 -6
- package/scripts/ci/trust-reconcile.js +144 -26
- package/scripts/hooks/config-protection.js +11 -4
- package/scripts/hooks/stop-goal-fit.js +259 -4
- package/src/cli/assignment-provider.ts +110 -12
- package/src/cli/workflow-sidecar.ts +99 -10
|
@@ -61,7 +61,7 @@ function makeRunner(store, dir) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function readTelemetryEvents(dir) {
|
|
64
|
-
const sinkPath = path.join(dir, ".telemetry", "full.jsonl");
|
|
64
|
+
const sinkPath = path.join(dir, ".kontourai", "telemetry", "full.jsonl");
|
|
65
65
|
if (!fs.existsSync(sinkPath)) return [];
|
|
66
66
|
return fs.readFileSync(sinkPath, "utf8")
|
|
67
67
|
.trim()
|
|
@@ -366,3 +366,94 @@ describe("Knowledge Kit Audit-Freshness Suite (#106)", () => {
|
|
|
366
366
|
assert.deepEqual(flaggedIds, ["decision-old", "radar-stale"]);
|
|
367
367
|
});
|
|
368
368
|
});
|
|
369
|
+
|
|
370
|
+
// ===========================================================================
|
|
371
|
+
// Record-carried expiry (#341, store-contract Addendum J)
|
|
372
|
+
//
|
|
373
|
+
// Extends the audit with record-owned freshness: a record past its OWN
|
|
374
|
+
// `expires_at` / `ttl_seconds` is flagged WITHOUT any caller-supplied threshold,
|
|
375
|
+
// each flag citing the expiry as the threshold that fired. Isolated fixtures
|
|
376
|
+
// (own temp dir) so the #106 shared-fixture counts above are untouched.
|
|
377
|
+
// ===========================================================================
|
|
378
|
+
|
|
379
|
+
describe("Knowledge Kit Audit-Freshness — record-carried expiry (#341)", () => {
|
|
380
|
+
let dir, store, runner;
|
|
381
|
+
let expiredId, freshId, plainId;
|
|
382
|
+
|
|
383
|
+
before(async () => {
|
|
384
|
+
dir = makeTempDir();
|
|
385
|
+
store = makeStore(dir);
|
|
386
|
+
runner = makeRunner(store, dir);
|
|
387
|
+
// A record whose OWN expires_at is in the past (relative to NOW), in a
|
|
388
|
+
// category with NO configured threshold — the expiry path must still fire.
|
|
389
|
+
expiredId = await store.create({
|
|
390
|
+
id: "own-expiry-past", type: "raw", title: "Expired by its own clock",
|
|
391
|
+
body: "b", category: "misc.scratch",
|
|
392
|
+
expires_at: "2026-06-01T00:00:00.000Z",
|
|
393
|
+
provenance: { agent: "fixture" },
|
|
394
|
+
});
|
|
395
|
+
// A record expiring in the future — not flagged.
|
|
396
|
+
freshId = await store.create({
|
|
397
|
+
id: "own-expiry-future", type: "raw", title: "Not yet expired",
|
|
398
|
+
body: "b", category: "misc.scratch",
|
|
399
|
+
expires_at: "2026-08-01T00:00:00.000Z",
|
|
400
|
+
provenance: { agent: "fixture" },
|
|
401
|
+
});
|
|
402
|
+
// A record with no freshness fields in an opt-out category — never flagged.
|
|
403
|
+
plainId = await store.create({
|
|
404
|
+
id: "own-expiry-none", type: "raw", title: "No expiry at all",
|
|
405
|
+
body: "b", category: "misc.scratch",
|
|
406
|
+
provenance: { agent: "fixture" },
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
after(() => { if (dir) fs.rmSync(dir, { recursive: true, force: true }); });
|
|
411
|
+
|
|
412
|
+
test("flags a record past its OWN expires_at with NO caller thresholds", async () => {
|
|
413
|
+
const result = await runner.auditFreshness({ now: NOW }); // no thresholds supplied
|
|
414
|
+
const flag = result.flags.find((f) => f.recordId === expiredId);
|
|
415
|
+
assert.ok(flag, "past-expiry record flagged without any caller-supplied threshold");
|
|
416
|
+
assert.equal(flag.reason, "expiry", "flag reason is 'expiry'");
|
|
417
|
+
assert.equal(flag.expiresAt, "2026-06-01T00:00:00.000Z",
|
|
418
|
+
"flag cites the record's own expiry as the threshold that fired");
|
|
419
|
+
assert.equal(flag.matchedThresholdKey, "expires_at");
|
|
420
|
+
assert.equal(flag.thresholdDays, null, "expiry flag cites a timestamp, not a day-count");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("only the expired record is flagged; fresh + no-expiry are not", async () => {
|
|
424
|
+
const result = await runner.auditFreshness({ now: NOW });
|
|
425
|
+
assert.deepEqual(result.flags.map((f) => f.recordId), [expiredId]);
|
|
426
|
+
assert.ok(!result.flags.some((f) => f.recordId === freshId));
|
|
427
|
+
assert.ok(!result.flags.some((f) => f.recordId === plainId));
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("ttl_seconds derives expiry from created_at (no thresholds)", async () => {
|
|
431
|
+
const tdir = makeTempDir();
|
|
432
|
+
const tstore = makeStore(tdir);
|
|
433
|
+
const trunner = makeRunner(tstore, tdir);
|
|
434
|
+
try {
|
|
435
|
+
const id = await tstore.create({
|
|
436
|
+
type: "raw", title: "Ttl expiry", body: "b", category: "misc.scratch",
|
|
437
|
+
ttl_seconds: 60, provenance: { agent: "fixture" },
|
|
438
|
+
});
|
|
439
|
+
const rec = await tstore.get(id);
|
|
440
|
+
const expiryMs = Date.parse(rec.created_at) + 60 * 1000;
|
|
441
|
+
// Just before expiry: not flagged. At/after expiry: flagged.
|
|
442
|
+
let result = await trunner.auditFreshness({ now: expiryMs - 1000 });
|
|
443
|
+
assert.ok(!result.flags.some((f) => f.recordId === id), "not flagged before ttl expiry");
|
|
444
|
+
result = await trunner.auditFreshness({ now: expiryMs });
|
|
445
|
+
const flag = result.flags.find((f) => f.recordId === id);
|
|
446
|
+
assert.ok(flag, "flagged at ttl expiry");
|
|
447
|
+
assert.equal(flag.reason, "expiry");
|
|
448
|
+
} finally {
|
|
449
|
+
fs.rmSync(tdir, { recursive: true, force: true });
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("read-only: the expiry audit mutates no record", async () => {
|
|
454
|
+
const before = fs.readFileSync(path.join(dir, "records", `${expiredId}.md`), "utf8");
|
|
455
|
+
await runner.auditFreshness({ now: NOW });
|
|
456
|
+
const after = fs.readFileSync(path.join(dir, "records", `${expiredId}.md`), "utf8");
|
|
457
|
+
assert.equal(after, before, "the record is byte-identical after the expiry audit");
|
|
458
|
+
});
|
|
459
|
+
});
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge Kit — Incremental Consolidation Eval Suite (#343, Addendum L)
|
|
3
|
+
*
|
|
4
|
+
* The whole-body consolidate contract (evals/consolidation/suite.test.js) makes
|
|
5
|
+
* the caller author the ENTIRE updated snapshot body to add one decision — heavy
|
|
6
|
+
* enough that real sessions skip it and the living decision snapshot stops living
|
|
7
|
+
* (ops field report record 0e439c57). This suite covers the LIGHTER append-mode
|
|
8
|
+
* contract added in #343, alongside — not replacing — the whole-body suite.
|
|
9
|
+
*
|
|
10
|
+
* Adapter-parameterized (mirrors evals/contract-suite/suite.test.js): set
|
|
11
|
+
* KNOWLEDGE_ADAPTER to an adapter module path, or pass --adapter=<path>, to run
|
|
12
|
+
* the contract-relevant cases against a second store implementation. Defaults to
|
|
13
|
+
* the bundled default-store adapter.
|
|
14
|
+
*
|
|
15
|
+
* AC1 (R1) — append mode: consolidate with only an appended entry + new compiled
|
|
16
|
+
* ref produces a snapshot whose body contains the prior entries PLUS
|
|
17
|
+
* the new one, without the caller supplying prior content.
|
|
18
|
+
* AC3 (R3) — after incremental consolidation the snapshot links every
|
|
19
|
+
* contributing compiled record (including the new one) with kind
|
|
20
|
+
* "source", provenance.source_ids is complete, and superseded
|
|
21
|
+
* predecessors remain queryable (Addendum A.7).
|
|
22
|
+
* AC4 (R4) — two sequential consolidations from DIFFERENT sessions/agents, each
|
|
23
|
+
* appending one distinct entry, yield a snapshot containing BOTH
|
|
24
|
+
* entries — no lost update. Asserted on both adapters.
|
|
25
|
+
*
|
|
26
|
+
* Run (default adapter):
|
|
27
|
+
* node --test kits/knowledge/evals/consolidate-incremental/suite.test.js
|
|
28
|
+
* Run against the Obsidian adapter (AC4 both-adapters requirement):
|
|
29
|
+
* KNOWLEDGE_ADAPTER=kits/knowledge/adapters/obsidian-store/index.js \
|
|
30
|
+
* node --test kits/knowledge/evals/consolidate-incremental/suite.test.js
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { test, describe } from "node:test";
|
|
34
|
+
import assert from "node:assert/strict";
|
|
35
|
+
import * as fs from "node:fs";
|
|
36
|
+
import * as path from "node:path";
|
|
37
|
+
import * as os from "node:os";
|
|
38
|
+
import { fileURLToPath } from "node:url";
|
|
39
|
+
|
|
40
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
const KIT_ROOT = path.resolve(__dirname, "../..");
|
|
42
|
+
const REPO_ROOT = path.resolve(__dirname, "../../../..");
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Adapter resolution (mirrors contract-suite)
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
function resolveAdapterPath() {
|
|
49
|
+
const flag = process.argv.find((a) => a.startsWith("--adapter="));
|
|
50
|
+
if (flag) return path.resolve(flag.slice("--adapter=".length));
|
|
51
|
+
if (process.env.KNOWLEDGE_ADAPTER) return path.resolve(process.env.KNOWLEDGE_ADAPTER);
|
|
52
|
+
return path.join(KIT_ROOT, "adapters/default-store/index.js");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const adapterPath = resolveAdapterPath();
|
|
56
|
+
const adapterModule = await import(adapterPath);
|
|
57
|
+
const AdapterClass =
|
|
58
|
+
adapterModule.default || adapterModule.DefaultKnowledgeStore || adapterModule.ObsidianKnowledgeStore;
|
|
59
|
+
const ADAPTER_LABEL = path.relative(REPO_ROOT, adapterPath);
|
|
60
|
+
|
|
61
|
+
const runnerMod = await import(path.join(KIT_ROOT, "adapters/flow-runner/index.js"));
|
|
62
|
+
const {
|
|
63
|
+
KnowledgeFlowRunner,
|
|
64
|
+
normalizeConsolidateAppend,
|
|
65
|
+
defaultConsolidateEntryRenderer,
|
|
66
|
+
regenerateSnapshotBodyFromRecords,
|
|
67
|
+
} = runnerMod;
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Helpers
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
function makeTempDir() {
|
|
74
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "knowledge-consolidate-incremental-"));
|
|
75
|
+
}
|
|
76
|
+
function makeStore(dir) {
|
|
77
|
+
return new AdapterClass({ storeRoot: dir });
|
|
78
|
+
}
|
|
79
|
+
function makeRunner(store, dir, { agent, sessionId } = {}) {
|
|
80
|
+
return new KnowledgeFlowRunner({
|
|
81
|
+
store,
|
|
82
|
+
workspace: dir,
|
|
83
|
+
agent: agent || "incremental-test-runner",
|
|
84
|
+
sessionId: sessionId || "incremental-session-001",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function makeCompiled(store, { title, body, category = "ops.decisions", sources = [] }) {
|
|
89
|
+
return store.create({
|
|
90
|
+
type: "compiled",
|
|
91
|
+
title,
|
|
92
|
+
body,
|
|
93
|
+
category,
|
|
94
|
+
provenance: { agent: "fixture", source_ids: sources },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Resolve the current (non-superseded) snapshot for a topic.
|
|
99
|
+
async function currentSnapshot(store, topic) {
|
|
100
|
+
const snaps = await store.listByType("snapshot");
|
|
101
|
+
const matches = snaps.filter((s) => (s.tags || []).includes(`topic:${topic}`));
|
|
102
|
+
const live = matches.find((s) => !(s.mutation_log || []).some((e) => e.op === "superseded-by"));
|
|
103
|
+
return live || matches[matches.length - 1] || null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ===========================================================================
|
|
107
|
+
// Adapter-independent unit tests for the append-mode helpers (run once).
|
|
108
|
+
// ===========================================================================
|
|
109
|
+
|
|
110
|
+
describe("append-mode helpers (unit)", () => {
|
|
111
|
+
test("normalizeConsolidateAppend accepts appendEntryRecordId and appendEntry.recordId", () => {
|
|
112
|
+
assert.deepEqual(normalizeConsolidateAppend({ appendEntryRecordId: "rec-1" }), { recordId: "rec-1" });
|
|
113
|
+
assert.deepEqual(normalizeConsolidateAppend({ appendEntry: { recordId: "rec-2" } }), { recordId: "rec-2" });
|
|
114
|
+
assert.equal(normalizeConsolidateAppend({ proposedBody: "x" }), null);
|
|
115
|
+
assert.equal(normalizeConsolidateAppend({}), null);
|
|
116
|
+
assert.equal(normalizeConsolidateAppend({ appendEntryRecordId: " " }), null);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("defaultConsolidateEntryRenderer renders a decision-log section", () => {
|
|
120
|
+
const out = defaultConsolidateEntryRenderer({ title: "Use Postgres", body: "We adopt Postgres." });
|
|
121
|
+
assert.equal(out, "## Use Postgres\n\nWe adopt Postgres.");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("regenerateSnapshotBodyFromRecords joins entries and prepends header", () => {
|
|
125
|
+
const records = [
|
|
126
|
+
{ title: "A", body: "alpha" },
|
|
127
|
+
{ title: "B", body: "beta" },
|
|
128
|
+
];
|
|
129
|
+
const body = regenerateSnapshotBodyFromRecords(records, { header: "# Decisions" });
|
|
130
|
+
assert.ok(body.startsWith("# Decisions\n\n"), "header prepended");
|
|
131
|
+
assert.ok(body.includes("## A\n\nalpha"), "first entry rendered");
|
|
132
|
+
assert.ok(body.includes("## B\n\nbeta"), "second entry rendered");
|
|
133
|
+
assert.ok(body.includes("\n\n---\n\n"), "entries separated by a rule");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("regenerateSnapshotBodyFromRecords honours a custom entryRenderer", () => {
|
|
137
|
+
const body = regenerateSnapshotBodyFromRecords(
|
|
138
|
+
[{ id: "x", body: "one" }, { id: "y", body: "two" }],
|
|
139
|
+
{ entryRenderer: (r) => `- ${r.body}` }
|
|
140
|
+
);
|
|
141
|
+
assert.equal(body, "- one\n\n---\n\n- two");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ===========================================================================
|
|
146
|
+
// AC1 (R1) — append mode regenerates prior + new without caller-supplied body.
|
|
147
|
+
// ===========================================================================
|
|
148
|
+
|
|
149
|
+
describe(`[${ADAPTER_LABEL}] AC1 — append mode regenerates body from records + new entry`, () => {
|
|
150
|
+
test("append produces a snapshot containing prior entries plus the new one", async () => {
|
|
151
|
+
const dir = makeTempDir();
|
|
152
|
+
try {
|
|
153
|
+
const store = makeStore(dir);
|
|
154
|
+
const runner = makeRunner(store, dir);
|
|
155
|
+
|
|
156
|
+
// Prior contribution already recorded in a live snapshot's provenance.
|
|
157
|
+
const recPrior = await makeCompiled(store, {
|
|
158
|
+
title: "Decision: REST for the public API",
|
|
159
|
+
body: "We will expose the public API over REST.",
|
|
160
|
+
});
|
|
161
|
+
const priorSnapshotId = await store.create({
|
|
162
|
+
type: "snapshot",
|
|
163
|
+
title: "Snapshot: ops.decisions",
|
|
164
|
+
body: defaultConsolidateEntryRenderer(await store.get(recPrior)),
|
|
165
|
+
category: "ops.decisions",
|
|
166
|
+
tags: ["topic:ops.decisions"],
|
|
167
|
+
links: [{ target_id: recPrior, kind: "source" }],
|
|
168
|
+
provenance: { agent: "fixture", source_ids: [recPrior] },
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// New contribution — the ONLY body content the caller supplies is this
|
|
172
|
+
// compiled record; the consolidate call passes no prior text.
|
|
173
|
+
const recNew = await makeCompiled(store, {
|
|
174
|
+
title: "Decision: URL-path versioning",
|
|
175
|
+
body: "API versioning is done via URL path (/v1/, /v2/).",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const result = await runner.consolidate(
|
|
179
|
+
{ topic: "ops.decisions", category: "ops.decisions" },
|
|
180
|
+
{
|
|
181
|
+
appendEntryRecordId: recNew,
|
|
182
|
+
rationale: "Append the versioning decision to the living snapshot.",
|
|
183
|
+
decision: "apply",
|
|
184
|
+
}
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
assert.ok(result.newSnapshotId, "AC1: a new snapshot was produced");
|
|
188
|
+
const snap = await store.get(result.newSnapshotId);
|
|
189
|
+
|
|
190
|
+
assert.ok(
|
|
191
|
+
snap.body.includes("expose the public API over REST"),
|
|
192
|
+
"AC1: regenerated body contains the PRIOR entry (not supplied by caller)"
|
|
193
|
+
);
|
|
194
|
+
assert.ok(
|
|
195
|
+
snap.body.includes("versioning is done via URL path"),
|
|
196
|
+
"AC1: regenerated body contains the NEW appended entry"
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// The prior snapshot is superseded by the new one.
|
|
200
|
+
assert.notEqual(result.newSnapshotId, priorSnapshotId, "AC1: distinct new snapshot id");
|
|
201
|
+
} finally {
|
|
202
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("append against an empty topic seeds a first snapshot from the new entry", async () => {
|
|
207
|
+
const dir = makeTempDir();
|
|
208
|
+
try {
|
|
209
|
+
const store = makeStore(dir);
|
|
210
|
+
const runner = makeRunner(store, dir);
|
|
211
|
+
|
|
212
|
+
const recNew = await makeCompiled(store, {
|
|
213
|
+
title: "Decision: adopt Postgres",
|
|
214
|
+
body: "Postgres is the primary datastore.",
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const result = await runner.consolidate(
|
|
218
|
+
{ topic: "ops.data", category: "ops.data" },
|
|
219
|
+
{ appendEntryRecordId: recNew, rationale: "First decision.", decision: "apply" }
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const snap = await store.get(result.newSnapshotId);
|
|
223
|
+
assert.ok(snap.body.includes("Postgres is the primary datastore"), "AC1: first entry present");
|
|
224
|
+
assert.deepEqual(snap.provenance.source_ids, [recNew], "AC1: source_ids is exactly the new entry");
|
|
225
|
+
} finally {
|
|
226
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("appendEntry: { recordId } shape is accepted", async () => {
|
|
231
|
+
const dir = makeTempDir();
|
|
232
|
+
try {
|
|
233
|
+
const store = makeStore(dir);
|
|
234
|
+
const runner = makeRunner(store, dir);
|
|
235
|
+
const rec = await makeCompiled(store, { title: "D1", body: "decision one" });
|
|
236
|
+
const result = await runner.consolidate(
|
|
237
|
+
{ topic: "ops.alt", category: "ops.alt" },
|
|
238
|
+
{ appendEntry: { recordId: rec }, rationale: "r", decision: "apply" }
|
|
239
|
+
);
|
|
240
|
+
const snap = await store.get(result.newSnapshotId);
|
|
241
|
+
assert.ok(snap.body.includes("decision one"));
|
|
242
|
+
} finally {
|
|
243
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ===========================================================================
|
|
249
|
+
// AC3 (R3) — provenance + source links complete; superseded predecessors queryable.
|
|
250
|
+
// ===========================================================================
|
|
251
|
+
|
|
252
|
+
describe(`[${ADAPTER_LABEL}] AC3 — provenance/source-link completeness + supersede-not-delete`, () => {
|
|
253
|
+
test("new snapshot links every contributing record incl. the new one; predecessor queryable", async () => {
|
|
254
|
+
const dir = makeTempDir();
|
|
255
|
+
try {
|
|
256
|
+
const store = makeStore(dir);
|
|
257
|
+
const runner = makeRunner(store, dir);
|
|
258
|
+
|
|
259
|
+
const recA = await makeCompiled(store, { title: "DA", body: "decision A" });
|
|
260
|
+
const first = await runner.consolidate(
|
|
261
|
+
{ topic: "ops.prov", category: "ops.prov" },
|
|
262
|
+
{ appendEntryRecordId: recA, rationale: "seed A", decision: "apply" }
|
|
263
|
+
);
|
|
264
|
+
const firstSnapshotId = first.newSnapshotId;
|
|
265
|
+
|
|
266
|
+
const recB = await makeCompiled(store, { title: "DB", body: "decision B" });
|
|
267
|
+
const second = await runner.consolidate(
|
|
268
|
+
{ topic: "ops.prov", category: "ops.prov" },
|
|
269
|
+
{ appendEntryRecordId: recB, rationale: "append B", decision: "apply" }
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const snap = await store.get(second.newSnapshotId);
|
|
273
|
+
|
|
274
|
+
// provenance.source_ids complete = both contributing records, incl. new.
|
|
275
|
+
assert.deepEqual(
|
|
276
|
+
[...snap.provenance.source_ids].sort(),
|
|
277
|
+
[recA, recB].sort(),
|
|
278
|
+
"AC3: provenance.source_ids lists every contributing record"
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
// source links to both compiled records.
|
|
282
|
+
const { forward } = await store.getLinks(second.newSnapshotId);
|
|
283
|
+
const sourceTargets = forward.filter((l) => l.kind === "source").map((l) => l.target_id).sort();
|
|
284
|
+
assert.deepEqual(sourceTargets, [recA, recB].sort(), "AC3: kind:'source' links to every contributor");
|
|
285
|
+
|
|
286
|
+
// Superseded predecessor remains queryable with provenance intact
|
|
287
|
+
// (Addendum A.7). The portable guarantees across adapters are: get(id)
|
|
288
|
+
// returns the full record, and the supersession is discoverable via the
|
|
289
|
+
// reverse "supersedes" link. (The default-store also keeps it in
|
|
290
|
+
// listByType('snapshot'); the Obsidian adapter archives it out of the
|
|
291
|
+
// working set — that listByType behavior is asserted for default-store by
|
|
292
|
+
// evals/consolidation/suite.test.js, so it is not re-asserted here where
|
|
293
|
+
// the adapter is parameterized.)
|
|
294
|
+
const pred = await store.get(firstSnapshotId);
|
|
295
|
+
assert.ok(pred, "AC3: superseded predecessor still returned by get()");
|
|
296
|
+
assert.equal(pred.body, "## DA\n\ndecision A", "AC3: predecessor body intact after supersession");
|
|
297
|
+
const { reverse } = await store.getLinks(firstSnapshotId);
|
|
298
|
+
assert.ok(
|
|
299
|
+
reverse.some((l) => l.source_id === second.newSnapshotId && l.kind === "supersedes"),
|
|
300
|
+
"AC3: predecessor discoverable via reverse 'supersedes' link (A.7)"
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
// Every contributing compiled record is still queryable.
|
|
304
|
+
for (const id of [recA, recB]) {
|
|
305
|
+
const rec = await store.get(id);
|
|
306
|
+
assert.equal(rec.type, "compiled", `AC3: contributing record ${id} intact`);
|
|
307
|
+
}
|
|
308
|
+
} finally {
|
|
309
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("append emits gate telemetry at each consolidate gate", async () => {
|
|
314
|
+
const dir = makeTempDir();
|
|
315
|
+
try {
|
|
316
|
+
const store = makeStore(dir);
|
|
317
|
+
const runner = makeRunner(store, dir);
|
|
318
|
+
const rec = await makeCompiled(store, { title: "DT", body: "telemetry decision" });
|
|
319
|
+
const result = await runner.consolidate(
|
|
320
|
+
{ topic: "ops.tel", category: "ops.tel" },
|
|
321
|
+
{ appendEntryRecordId: rec, rationale: "r", decision: "apply" }
|
|
322
|
+
);
|
|
323
|
+
const names = result.telemetryEvents.map((e) => e.tool?.name || "");
|
|
324
|
+
for (const gate of ["related-event-gate", "propose-gate", "evidence-gate", "apply-gate"]) {
|
|
325
|
+
assert.ok(names.some((n) => n.includes(gate)), `AC3: ${gate} telemetry emitted`);
|
|
326
|
+
}
|
|
327
|
+
} finally {
|
|
328
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ===========================================================================
|
|
334
|
+
// AC4 (R4) — sequential cross-session appends lose no entries. Both adapters.
|
|
335
|
+
// ===========================================================================
|
|
336
|
+
|
|
337
|
+
describe(`[${ADAPTER_LABEL}] AC4 — two sequential cross-session appends, no lost update`, () => {
|
|
338
|
+
test("distinct sessions each append one entry; final snapshot contains both", async () => {
|
|
339
|
+
const dir = makeTempDir();
|
|
340
|
+
try {
|
|
341
|
+
const store = makeStore(dir);
|
|
342
|
+
|
|
343
|
+
// Session 1 appends decision A.
|
|
344
|
+
const runner1 = makeRunner(store, dir, { agent: "agent-session-1", sessionId: "session-1" });
|
|
345
|
+
const recA = await makeCompiled(store, {
|
|
346
|
+
title: "Decision: adopt Postgres",
|
|
347
|
+
body: "Primary datastore is Postgres.",
|
|
348
|
+
});
|
|
349
|
+
await runner1.consolidate(
|
|
350
|
+
{ topic: "ops.stack", category: "ops.stack" },
|
|
351
|
+
{ appendEntryRecordId: recA, rationale: "Session 1 records the datastore decision.", decision: "apply", session_id: "session-1" }
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
// Session 2 (different agent + session) appends decision B. It resolves the
|
|
355
|
+
// CURRENT snapshot (session 1's), so it must NOT lose decision A.
|
|
356
|
+
const runner2 = makeRunner(store, dir, { agent: "agent-session-2", sessionId: "session-2" });
|
|
357
|
+
const recB = await makeCompiled(store, {
|
|
358
|
+
title: "Decision: adopt Redis cache",
|
|
359
|
+
body: "Add Redis as a caching layer.",
|
|
360
|
+
});
|
|
361
|
+
const result2 = await runner2.consolidate(
|
|
362
|
+
{ topic: "ops.stack", category: "ops.stack" },
|
|
363
|
+
{ appendEntryRecordId: recB, rationale: "Session 2 records the cache decision.", decision: "apply", session_id: "session-2" }
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
const live = await currentSnapshot(store, "ops.stack");
|
|
367
|
+
assert.ok(live, "AC4: a live snapshot exists");
|
|
368
|
+
assert.equal(live.id, result2.newSnapshotId, "AC4: session 2's snapshot is the live one");
|
|
369
|
+
|
|
370
|
+
assert.ok(
|
|
371
|
+
live.body.includes("Primary datastore is Postgres"),
|
|
372
|
+
"AC4: session 1's entry survives (no lost update)"
|
|
373
|
+
);
|
|
374
|
+
assert.ok(
|
|
375
|
+
live.body.includes("Add Redis as a caching layer"),
|
|
376
|
+
"AC4: session 2's entry is present"
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
// Provenance links BOTH contributing records.
|
|
380
|
+
assert.deepEqual(
|
|
381
|
+
[...live.provenance.source_ids].sort(),
|
|
382
|
+
[recA, recB].sort(),
|
|
383
|
+
"AC4: final provenance.source_ids contains both entries"
|
|
384
|
+
);
|
|
385
|
+
} finally {
|
|
386
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("appending the same entry twice is idempotent (no duplicate source_id)", async () => {
|
|
391
|
+
const dir = makeTempDir();
|
|
392
|
+
try {
|
|
393
|
+
const store = makeStore(dir);
|
|
394
|
+
const runner = makeRunner(store, dir);
|
|
395
|
+
const rec = await makeCompiled(store, { title: "D", body: "the decision" });
|
|
396
|
+
|
|
397
|
+
await runner.consolidate(
|
|
398
|
+
{ topic: "ops.idem", category: "ops.idem" },
|
|
399
|
+
{ appendEntryRecordId: rec, rationale: "first", decision: "apply" }
|
|
400
|
+
);
|
|
401
|
+
const second = await runner.consolidate(
|
|
402
|
+
{ topic: "ops.idem", category: "ops.idem" },
|
|
403
|
+
{ appendEntryRecordId: rec, rationale: "again", decision: "apply" }
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
const snap = await store.get(second.newSnapshotId);
|
|
407
|
+
assert.deepEqual(snap.provenance.source_ids, [rec], "AC4: no duplicate source_id on re-append");
|
|
408
|
+
} finally {
|
|
409
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// ===========================================================================
|
|
415
|
+
// Back-compat + input validation for append mode.
|
|
416
|
+
// ===========================================================================
|
|
417
|
+
|
|
418
|
+
describe(`[${ADAPTER_LABEL}] append-mode input validation + back-compat`, () => {
|
|
419
|
+
test("proposedBody + append mode are mutually exclusive", async () => {
|
|
420
|
+
const dir = makeTempDir();
|
|
421
|
+
try {
|
|
422
|
+
const store = makeStore(dir);
|
|
423
|
+
const runner = makeRunner(store, dir);
|
|
424
|
+
const rec = await makeCompiled(store, { title: "D", body: "d" });
|
|
425
|
+
await assert.rejects(
|
|
426
|
+
() => runner.consolidate(
|
|
427
|
+
{ topic: "ops.x", category: "ops.x" },
|
|
428
|
+
{ appendEntryRecordId: rec, proposedBody: "full body", rationale: "r", decision: "apply" }
|
|
429
|
+
),
|
|
430
|
+
(err) => {
|
|
431
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
432
|
+
assert.match(err.message, /mutually exclusive/);
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
);
|
|
436
|
+
} finally {
|
|
437
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("append entry must be an existing compiled record", async () => {
|
|
442
|
+
const dir = makeTempDir();
|
|
443
|
+
try {
|
|
444
|
+
const store = makeStore(dir);
|
|
445
|
+
const runner = makeRunner(store, dir);
|
|
446
|
+
|
|
447
|
+
await assert.rejects(
|
|
448
|
+
() => runner.consolidate(
|
|
449
|
+
{ topic: "ops.y", category: "ops.y" },
|
|
450
|
+
{ appendEntryRecordId: "does-not-exist", rationale: "r", decision: "apply" }
|
|
451
|
+
),
|
|
452
|
+
(err) => {
|
|
453
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
454
|
+
assert.match(err.message, /not found/);
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
const raw = await store.create({
|
|
460
|
+
type: "raw", title: "R", body: "raw", category: "ops.y", provenance: { agent: "t" },
|
|
461
|
+
});
|
|
462
|
+
await assert.rejects(
|
|
463
|
+
() => runner.consolidate(
|
|
464
|
+
{ topic: "ops.y", category: "ops.y" },
|
|
465
|
+
{ appendEntryRecordId: raw, rationale: "r", decision: "apply" }
|
|
466
|
+
),
|
|
467
|
+
(err) => {
|
|
468
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
469
|
+
assert.match(err.message, /expected "compiled"/);
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
);
|
|
473
|
+
} finally {
|
|
474
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("whole-body path still works unchanged (back-compat)", async () => {
|
|
479
|
+
const dir = makeTempDir();
|
|
480
|
+
try {
|
|
481
|
+
const store = makeStore(dir);
|
|
482
|
+
const runner = makeRunner(store, dir);
|
|
483
|
+
await makeCompiled(store, { title: "D", body: "decision body", category: "ops.bc" });
|
|
484
|
+
const result = await runner.consolidate(
|
|
485
|
+
{ topic: "ops.bc", category: "ops.bc" },
|
|
486
|
+
{ proposedBody: "Whole-body snapshot content.", rationale: "r", decision: "apply" }
|
|
487
|
+
);
|
|
488
|
+
const snap = await store.get(result.newSnapshotId);
|
|
489
|
+
assert.equal(snap.body, "Whole-body snapshot content.", "back-compat: whole body applied verbatim");
|
|
490
|
+
} finally {
|
|
491
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
});
|
|
@@ -57,7 +57,7 @@ function makeRunner(store, storeDir) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
function readTelemetryEvents(dir) {
|
|
60
|
-
const sinkPath = path.join(dir, ".telemetry", "full.jsonl");
|
|
60
|
+
const sinkPath = path.join(dir, ".kontourai", "telemetry", "full.jsonl");
|
|
61
61
|
if (!fs.existsSync(sinkPath)) return [];
|
|
62
62
|
return fs.readFileSync(sinkPath, "utf8")
|
|
63
63
|
.trim()
|
|
@@ -220,6 +220,42 @@ describe("Knowledge Kit Store Contract Suite", () => {
|
|
|
220
220
|
});
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
+
// -----------------------------------------------------------------------
|
|
224
|
+
// §2b freshness fields round-trip (AC1, #341 — Addendum J)
|
|
225
|
+
// -----------------------------------------------------------------------
|
|
226
|
+
describe("create/update: expires_at & ttl_seconds round-trip through get (AC1)", () => {
|
|
227
|
+
let dir, store;
|
|
228
|
+
before(() => { dir = makeTempDir(); store = makeStore(dir); });
|
|
229
|
+
after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
230
|
+
|
|
231
|
+
test("create with expires_at + ttl_seconds round-trips (typed) through get", async () => {
|
|
232
|
+
const id = await store.create({
|
|
233
|
+
type: "raw",
|
|
234
|
+
title: "Freshness raw",
|
|
235
|
+
body: "b",
|
|
236
|
+
category: "radar.signals",
|
|
237
|
+
expires_at: "2026-09-01T00:00:00.000Z",
|
|
238
|
+
ttl_seconds: 3600,
|
|
239
|
+
provenance: { agent: "tester" },
|
|
240
|
+
});
|
|
241
|
+
const rec = await store.get(id);
|
|
242
|
+
assert.equal(rec.expires_at, "2026-09-01T00:00:00.000Z", "expires_at round-trips exactly");
|
|
243
|
+
assert.equal(rec.ttl_seconds, 3600, "ttl_seconds round-trips as a number");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("update sets, then clears, expires_at", async () => {
|
|
247
|
+
const id = await store.create({
|
|
248
|
+
type: "raw", title: "Set/clear", body: "b", category: "radar.signals",
|
|
249
|
+
provenance: { agent: "tester" },
|
|
250
|
+
});
|
|
251
|
+
assert.equal((await store.get(id)).expires_at, undefined, "no expiry initially");
|
|
252
|
+
await store.update(id, { expires_at: "2027-01-01T00:00:00.000Z" }, { agent: "tester" });
|
|
253
|
+
assert.equal((await store.get(id)).expires_at, "2027-01-01T00:00:00.000Z", "expires_at set via update");
|
|
254
|
+
await store.update(id, { expires_at: null }, { agent: "tester" });
|
|
255
|
+
assert.equal((await store.get(id)).expires_at, undefined, "expires_at cleared via update(null)");
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
223
259
|
// -----------------------------------------------------------------------
|
|
224
260
|
// §3 links + graph index
|
|
225
261
|
// -----------------------------------------------------------------------
|