@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.
Files changed (52) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +17 -0
  3. package/build/src/cli/assignment-provider.d.ts +45 -0
  4. package/build/src/cli/assignment-provider.js +97 -12
  5. package/build/src/cli/workflow-sidecar.d.ts +14 -4
  6. package/build/src/cli/workflow-sidecar.js +100 -10
  7. package/context/contracts/assignment-provider-contract.md +1 -1
  8. package/context/contracts/execution-contract.md +78 -0
  9. package/context/scripts/hooks/config-protection.js +11 -4
  10. package/context/scripts/hooks/stop-goal-fit.js +259 -4
  11. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
  12. package/evals/ci/run-baseline.sh +2 -0
  13. package/evals/integration/test_checkpoint_signing.sh +4 -3
  14. package/evals/integration/test_gate_lockdown.sh +36 -0
  15. package/evals/integration/test_model_routing_escalation.sh +145 -0
  16. package/evals/integration/test_publish_delivery.sh +14 -6
  17. package/evals/integration/test_stop_hook_release.sh +552 -0
  18. package/evals/integration/test_trust_reconcile_negatives.sh +170 -0
  19. package/evals/run.sh +6 -0
  20. package/evals/static/test_model_routing_hints.sh +107 -0
  21. package/kits/builder/skills/builder-shape/SKILL.md +10 -0
  22. package/kits/builder/skills/deliver/SKILL.md +52 -11
  23. package/kits/builder/skills/design-probe/SKILL.md +10 -0
  24. package/kits/builder/skills/execute-plan/SKILL.md +13 -0
  25. package/kits/builder/skills/fix-bug/SKILL.md +17 -0
  26. package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
  27. package/kits/builder/skills/plan-work/SKILL.md +9 -0
  28. package/kits/builder/skills/pull-work/SKILL.md +10 -0
  29. package/kits/builder/skills/review-work/SKILL.md +11 -0
  30. package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
  31. package/kits/builder/skills/verify-work/SKILL.md +11 -0
  32. package/kits/knowledge/adapters/default-store/index.js +56 -15
  33. package/kits/knowledge/adapters/flow-runner/index.js +912 -16
  34. package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
  35. package/kits/knowledge/adapters/shared/codec.js +124 -0
  36. package/kits/knowledge/docs/store-contract.md +405 -3
  37. package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
  38. package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
  39. package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
  40. package/kits/knowledge/evals/contract-suite/suite.test.js +36 -0
  41. package/kits/knowledge/evals/freshness/suite.test.js +339 -0
  42. package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
  43. package/kits/knowledge/evals/retirement/suite.test.js +1 -1
  44. package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
  45. package/package.json +1 -1
  46. package/schemas/workflow-handoff.schema.json +6 -0
  47. package/scripts/ci/mint-attestation.js +33 -6
  48. package/scripts/ci/trust-reconcile.js +144 -26
  49. package/scripts/hooks/config-protection.js +11 -4
  50. package/scripts/hooks/stop-goal-fit.js +259 -4
  51. package/src/cli/assignment-provider.ts +110 -12
  52. package/src/cli/workflow-sidecar.ts +99 -10
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Knowledge Kit — Record Freshness & Status Suite (#341, store-contract Addendum J)
3
+ *
4
+ * Record-carried freshness (`expires_at` / `ttl_seconds`) aligned to Kontour's
5
+ * own Hachure trust vocabulary (expiresAt / ttlSeconds; stale / superseded), plus
6
+ * a first-class stale-listing query. "No silent rot" applied to our own store:
7
+ * `stale` and `superseded` are DERIVED — computed on query, never written back.
8
+ *
9
+ * PARAMETERIZED by adapter module — set KNOWLEDGE_ADAPTER to an adapter's
10
+ * absolute path, or pass --adapter=<path>. Defaults to the bundled default-store
11
+ * adapter. Verification runs this file for the default adapter AND the obsidian
12
+ * adapter (KNOWLEDGE_ADAPTER=kits/knowledge/adapters/obsidian-store/index.js).
13
+ *
14
+ * AC map (issue #341):
15
+ * AC1 (R1) — create/update with expires_at/ttl_seconds round-trips through get
16
+ * AC2 (R2) — a superseded record (via the supersede op) is queryable as superseded
17
+ * AC3 (R3) — stale-filtered listing returns exactly the expired records; the
18
+ * auditFreshness expiry path is exercised here too (its primary case
19
+ * lives in evals/audit-freshness/suite.test.js)
20
+ * AC4 (R4) — expiry transitions: past → stale, future → not, inclusive boundary,
21
+ * no-freshness-fields → never stale; injectable `now` makes it deterministic
22
+ * AC5 (R5) — backward compat: a record with no freshness fields behaves as before
23
+ *
24
+ * Run:
25
+ * node --test kits/knowledge/evals/freshness/suite.test.js
26
+ * KNOWLEDGE_ADAPTER=kits/knowledge/adapters/obsidian-store/index.js \
27
+ * node --test kits/knowledge/evals/freshness/suite.test.js
28
+ */
29
+
30
+ import { test, describe, before, after } from "node:test";
31
+ import assert from "node:assert/strict";
32
+ import * as fs from "node:fs";
33
+ import * as path from "node:path";
34
+ import * as os from "node:os";
35
+ import { fileURLToPath } from "node:url";
36
+
37
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
+ const KIT_ROOT = path.resolve(__dirname, "../..");
39
+ const REPO_ROOT = path.resolve(KIT_ROOT, "../..");
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Adapter resolution — same convention as evals/contract-suite/suite.test.js
43
+ // ---------------------------------------------------------------------------
44
+
45
+ function resolveAdapterPath() {
46
+ const flag = process.argv.find((a) => a.startsWith("--adapter="));
47
+ if (flag) return path.resolve(flag.slice("--adapter=".length));
48
+ if (process.env.KNOWLEDGE_ADAPTER) return path.resolve(process.env.KNOWLEDGE_ADAPTER);
49
+ return path.join(KIT_ROOT, "adapters/default-store/index.js");
50
+ }
51
+
52
+ const adapterPath = resolveAdapterPath();
53
+ const adapterModule = await import(adapterPath);
54
+ const AdapterClass = adapterModule.default || adapterModule.DefaultKnowledgeStore;
55
+ const ADAPTER_LABEL = path.relative(REPO_ROOT, adapterPath);
56
+
57
+ const runnerPath = path.join(KIT_ROOT, "adapters/flow-runner/index.js");
58
+ const { KnowledgeFlowRunner } = await import(runnerPath);
59
+ const codecPath = path.join(KIT_ROOT, "adapters/shared/codec.js");
60
+ const { isSupersededByLinks } = await import(codecPath);
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Helpers
64
+ // ---------------------------------------------------------------------------
65
+
66
+ function makeTempDir() {
67
+ return fs.mkdtempSync(path.join(os.tmpdir(), "knowledge-freshness-"));
68
+ }
69
+ function makeStore(dir) {
70
+ return new AdapterClass({ storeRoot: dir });
71
+ }
72
+ // Telemetry sink kept separate from the store dir.
73
+ function makeRunner(store, workspaceDir) {
74
+ return new KnowledgeFlowRunner({
75
+ store,
76
+ workspace: workspaceDir,
77
+ agent: "freshness-test-runner",
78
+ sessionId: "freshness-session-001",
79
+ });
80
+ }
81
+
82
+ // A fixed reference "now" so expiry math is deterministic regardless of wall clock.
83
+ const NOW = "2026-07-01T00:00:00.000Z";
84
+ const NOW_MS = Date.parse(NOW);
85
+ const PAST = "2026-06-01T00:00:00.000Z"; // before NOW → expired
86
+ const FUTURE = "2026-08-01T00:00:00.000Z"; // after NOW → not expired
87
+
88
+ async function mkRecord(store, over = {}) {
89
+ return store.create({
90
+ type: "raw",
91
+ title: over.title || "Freshness fixture",
92
+ body: over.body || "body",
93
+ category: over.category || "radar.signals",
94
+ provenance: { agent: "fixture" },
95
+ ...over.fields,
96
+ });
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Suite
101
+ // ---------------------------------------------------------------------------
102
+
103
+ describe(`Knowledge Kit Freshness & Status Suite (#341) [${ADAPTER_LABEL}]`, () => {
104
+ // -----------------------------------------------------------------------
105
+ // AC1 (R1) — round-trip of freshness fields through get
106
+ // -----------------------------------------------------------------------
107
+ describe("AC1: freshness fields round-trip through get", () => {
108
+ let dir, store;
109
+ before(() => { dir = makeTempDir(); store = makeStore(dir); });
110
+ after(() => fs.rmSync(dir, { recursive: true, force: true }));
111
+
112
+ test("create with expires_at AND ttl_seconds round-trips (typed) through get", async () => {
113
+ const id = await store.create({
114
+ type: "raw", title: "Has both", body: "b", category: "radar.signals",
115
+ expires_at: FUTURE, ttl_seconds: 3600,
116
+ provenance: { agent: "fixture" },
117
+ });
118
+ const rec = await store.get(id);
119
+ assert.equal(rec.expires_at, FUTURE, "expires_at round-trips exactly (string)");
120
+ assert.equal(rec.ttl_seconds, 3600, "ttl_seconds round-trips as a NUMBER, not a string");
121
+ assert.equal(typeof rec.ttl_seconds, "number", "ttl_seconds is coerced back to number on read");
122
+ });
123
+
124
+ test("create with only ttl_seconds round-trips", async () => {
125
+ const id = await mkRecord(store, { title: "Only ttl", fields: { ttl_seconds: 120 } });
126
+ const rec = await store.get(id);
127
+ assert.equal(rec.ttl_seconds, 120);
128
+ assert.equal(rec.expires_at, undefined, "no expires_at when only ttl supplied");
129
+ });
130
+
131
+ test("update can SET expires_at on a record created without one", async () => {
132
+ const id = await mkRecord(store, { title: "Set later" });
133
+ let rec = await store.get(id);
134
+ assert.equal(rec.expires_at, undefined, "starts with no expiry");
135
+ await store.update(id, { expires_at: FUTURE }, { agent: "fixture" });
136
+ rec = await store.get(id);
137
+ assert.equal(rec.expires_at, FUTURE, "update set expires_at");
138
+ // update records the freshness field in its mutation-log evidence.
139
+ const last = rec.mutation_log[rec.mutation_log.length - 1];
140
+ assert.ok(last.evidence.fields.includes("expires_at"), "mutation log cites expires_at");
141
+ });
142
+
143
+ test("update can CLEAR expires_at with null", async () => {
144
+ const id = await mkRecord(store, { title: "Clear it", fields: { expires_at: FUTURE } });
145
+ assert.equal((await store.get(id)).expires_at, FUTURE);
146
+ await store.update(id, { expires_at: null }, { agent: "fixture" });
147
+ assert.equal((await store.get(id)).expires_at, undefined, "null clears expires_at");
148
+ });
149
+ });
150
+
151
+ // -----------------------------------------------------------------------
152
+ // AC4 (R4) — expiry transitions via the { stale: true } listing filter
153
+ // -----------------------------------------------------------------------
154
+ describe("AC4: expiry transitions (past → stale, future → not, boundary, no-freshness)", () => {
155
+ let dir, store;
156
+ let pastId, futureId, boundaryId, plainId, ttlPastId;
157
+ before(async () => {
158
+ dir = makeTempDir(); store = makeStore(dir);
159
+ pastId = await mkRecord(store, { title: "Past", category: "radar.a", fields: { expires_at: PAST } });
160
+ futureId = await mkRecord(store, { title: "Future", category: "radar.b", fields: { expires_at: FUTURE } });
161
+ boundaryId = await mkRecord(store, { title: "Boundary", category: "radar.c", fields: { expires_at: NOW } });
162
+ plainId = await mkRecord(store, { title: "Plain", category: "radar.d" }); // no freshness
163
+ });
164
+ after(() => fs.rmSync(dir, { recursive: true, force: true }));
165
+
166
+ test("past expires_at → listed as stale at now", async () => {
167
+ const stale = await store.listByType("raw", { stale: true, now: NOW });
168
+ assert.ok(stale.some((r) => r.id === pastId), "past-expiry record is stale");
169
+ });
170
+
171
+ test("future expires_at → NOT stale at now", async () => {
172
+ const stale = await store.listByType("raw", { stale: true, now: NOW });
173
+ assert.ok(!stale.some((r) => r.id === futureId), "future-expiry record is not stale");
174
+ });
175
+
176
+ test("boundary: now === expires_at → stale (inclusive)", async () => {
177
+ const stale = await store.listByType("raw", { stale: true, now: NOW });
178
+ assert.ok(stale.some((r) => r.id === boundaryId), "record expiring exactly at now IS stale");
179
+ });
180
+
181
+ test("no freshness fields → never stale (backward compat)", async () => {
182
+ const stale = await store.listByType("raw", { stale: true, now: NOW });
183
+ assert.ok(!stale.some((r) => r.id === plainId), "record with no expiry is never stale");
184
+ });
185
+
186
+ test("stale-filtered listing returns EXACTLY the expired records (AC3)", async () => {
187
+ const stale = await store.listByType("raw", { stale: true, now: NOW });
188
+ assert.deepEqual(
189
+ stale.map((r) => r.id).sort(),
190
+ [pastId, boundaryId].sort(),
191
+ "exactly past + boundary records are stale; future + plain are not"
192
+ );
193
+ });
194
+
195
+ test("ttl_seconds derives expiry from created_at; boundary inclusive", async () => {
196
+ const id = await mkRecord(store, { title: "Ttl", category: "radar.ttl", fields: { ttl_seconds: 60 } });
197
+ const rec = await store.get(id);
198
+ const expiryMs = Date.parse(rec.created_at) + 60 * 1000;
199
+ // 1ms before expiry → not stale; at expiry → stale (inclusive boundary).
200
+ let stale = await store.listByType("raw", { stale: true, now: expiryMs - 1 });
201
+ assert.ok(!stale.some((r) => r.id === id), "not stale 1ms before ttl expiry");
202
+ stale = await store.listByType("raw", { stale: true, now: expiryMs });
203
+ assert.ok(stale.some((r) => r.id === id), "stale at exactly ttl expiry (inclusive)");
204
+ });
205
+
206
+ test("expires_at takes precedence over ttl_seconds", async () => {
207
+ // ttl would expire in 60s (fresh at NOW-ish) but explicit expires_at is in the PAST.
208
+ const id = await mkRecord(store, {
209
+ title: "Precedence", category: "radar.prec",
210
+ fields: { expires_at: PAST, ttl_seconds: 999999 },
211
+ });
212
+ const stale = await store.listByType("raw", { stale: true, now: NOW });
213
+ assert.ok(stale.some((r) => r.id === id), "explicit past expires_at wins over a long ttl");
214
+ });
215
+
216
+ test("listByCategory honours the stale filter too", async () => {
217
+ const stale = await store.listByCategory("radar.a", { stale: true, now: NOW });
218
+ assert.deepEqual(stale.map((r) => r.id), [pastId], "category listing returns the stale record");
219
+ const notStale = await store.listByCategory("radar.b", { stale: true, now: NOW });
220
+ assert.equal(notStale.length, 0, "future-expiry category yields no stale records");
221
+ });
222
+ });
223
+
224
+ // -----------------------------------------------------------------------
225
+ // AC3 (R3) — auditFreshness flags a record past its OWN expiry, no thresholds
226
+ // -----------------------------------------------------------------------
227
+ describe("AC3: auditFreshness expiry path (no caller thresholds)", () => {
228
+ let dir, store, runner;
229
+ let expiredId;
230
+ before(async () => {
231
+ dir = makeTempDir(); store = makeStore(dir); runner = makeRunner(store, dir);
232
+ expiredId = await mkRecord(store, { title: "Expired", category: "misc.scratch", fields: { expires_at: PAST } });
233
+ await mkRecord(store, { title: "Fresh", category: "misc.scratch", fields: { expires_at: FUTURE } });
234
+ await mkRecord(store, { title: "No expiry", category: "misc.scratch" });
235
+ });
236
+ after(() => fs.rmSync(dir, { recursive: true, force: true }));
237
+
238
+ test("expired record flagged with NO thresholds, flag cites its own expiry", async () => {
239
+ const result = await runner.auditFreshness({ now: NOW }); // no thresholds at all
240
+ const flag = result.flags.find((f) => f.recordId === expiredId);
241
+ assert.ok(flag, "the past-expiry record is flagged without any caller threshold");
242
+ assert.equal(flag.reason, "expiry", "flag reason is 'expiry'");
243
+ assert.equal(flag.expiresAt, PAST, "flag cites the record's own expiry as the threshold that fired");
244
+ assert.equal(flag.matchedThresholdKey, "expires_at");
245
+ assert.equal(flag.thresholdDays, null, "an expiry flag cites a timestamp, not a day-count");
246
+ });
247
+
248
+ test("only the expired record is flagged (fresh + no-expiry are not)", async () => {
249
+ const result = await runner.auditFreshness({ now: NOW });
250
+ assert.deepEqual(result.flags.map((f) => f.recordId), [expiredId]);
251
+ });
252
+ });
253
+
254
+ // -----------------------------------------------------------------------
255
+ // AC2 (R2) — a superseded record is queryable as superseded
256
+ // -----------------------------------------------------------------------
257
+ describe("AC2: superseded state is queryable via the supersede relationship", () => {
258
+ let dir, store;
259
+ let oldId, newId;
260
+ before(async () => {
261
+ dir = makeTempDir(); store = makeStore(dir);
262
+ oldId = await mkRecord(store, { title: "Old snapshot", category: "ops.decisions" });
263
+ newId = await mkRecord(store, { title: "New snapshot", category: "ops.decisions" });
264
+ await store.supersede(newId, [oldId], { agent: "fixture", rationale: "Newer decision replaces it." });
265
+ });
266
+ after(() => fs.rmSync(dir, { recursive: true, force: true }));
267
+
268
+ test("superseded record carries an incoming supersedes edge (getLinks.reverse)", async () => {
269
+ const { reverse } = await store.getLinks(oldId);
270
+ assert.ok(isSupersededByLinks(reverse), "the superseded record is queryable as superseded");
271
+ assert.ok(reverse.some((l) => l.source_id === newId && l.kind === "supersedes"),
272
+ "the reverse edge names the superseding record");
273
+ });
274
+
275
+ test("the superseding record is NOT itself superseded", async () => {
276
+ const { reverse } = await store.getLinks(newId);
277
+ assert.equal(isSupersededByLinks(reverse), false, "the new record is not superseded");
278
+ });
279
+
280
+ test("superseded record carries a superseded-by mutation-log entry", async () => {
281
+ const rec = await store.get(oldId);
282
+ assert.ok((rec.mutation_log || []).some((e) => e.op === "superseded-by"),
283
+ "superseded-by log entry present (supersede-not-delete)");
284
+ });
285
+ });
286
+
287
+ // -----------------------------------------------------------------------
288
+ // Validation — malformed freshness is rejected at the mutation boundary
289
+ // -----------------------------------------------------------------------
290
+ describe("validation: malformed freshness rejected with MISSING_EVIDENCE", () => {
291
+ let dir, store;
292
+ before(() => { dir = makeTempDir(); store = makeStore(dir); });
293
+ after(() => fs.rmSync(dir, { recursive: true, force: true }));
294
+
295
+ const rejects = (fn, hint) =>
296
+ assert.rejects(fn, (err) => { assert.equal(err.code, "MISSING_EVIDENCE", hint); return true; });
297
+
298
+ test("create rejects a non-ISO expires_at", () =>
299
+ rejects(() => store.create({
300
+ type: "raw", title: "Bad", body: "b", category: "radar.signals",
301
+ expires_at: "not-a-date", provenance: { agent: "fixture" },
302
+ }), "non-ISO expires_at"));
303
+
304
+ test("create rejects a non-positive ttl_seconds", () =>
305
+ rejects(() => store.create({
306
+ type: "raw", title: "Bad", body: "b", category: "radar.signals",
307
+ ttl_seconds: 0, provenance: { agent: "fixture" },
308
+ }), "zero ttl_seconds"));
309
+
310
+ test("update rejects a negative ttl_seconds", async () => {
311
+ const id = await mkRecord(store, { title: "For update" });
312
+ await rejects(() => store.update(id, { ttl_seconds: -5 }, { agent: "fixture" }), "negative ttl_seconds");
313
+ });
314
+ });
315
+
316
+ // -----------------------------------------------------------------------
317
+ // AC5 (R5) — records without freshness fields are untouched
318
+ // -----------------------------------------------------------------------
319
+ describe("AC5: backward compatibility", () => {
320
+ let dir, store;
321
+ before(() => { dir = makeTempDir(); store = makeStore(dir); });
322
+ after(() => fs.rmSync(dir, { recursive: true, force: true }));
323
+
324
+ test("a record with no freshness fields has none after round-trip and is never stale", async () => {
325
+ const id = await mkRecord(store, { title: "Plain old record" });
326
+ const rec = await store.get(id);
327
+ assert.equal(rec.expires_at, undefined);
328
+ assert.equal(rec.ttl_seconds, undefined);
329
+ const stale = await store.listByType("raw", { stale: true, now: "2999-01-01T00:00:00.000Z" });
330
+ assert.equal(stale.length, 0, "no record is stale when none carry freshness fields");
331
+ });
332
+
333
+ test("default listing (no stale option) is unaffected by freshness", async () => {
334
+ await mkRecord(store, { title: "Expired but listed", category: "radar.x", fields: { expires_at: PAST } });
335
+ const all = await store.listByType("raw");
336
+ assert.ok(all.length >= 1, "stale records still appear in the default (non-stale-filtered) listing");
337
+ });
338
+ });
339
+ });