@kontourai/flow-agents 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/.github/workflows/ci.yml +12 -0
  2. package/CHANGELOG.md +32 -0
  3. package/CONTEXT.md +67 -1
  4. package/README.md +4 -0
  5. package/build/src/cli/assignment-provider.d.ts +188 -0
  6. package/build/src/cli/assignment-provider.js +193 -51
  7. package/build/src/cli/workflow-sidecar.d.ts +16 -6
  8. package/build/src/cli/workflow-sidecar.js +492 -45
  9. package/build/src/lib/flow-resolver.d.ts +12 -6
  10. package/build/src/lib/flow-resolver.js +30 -14
  11. package/build/src/tools/validate-source-tree.js +2 -1
  12. package/context/contracts/assignment-provider-contract.md +10 -1
  13. package/context/contracts/execution-contract.md +78 -0
  14. package/context/contracts/probe-docs-write-contract.md +187 -0
  15. package/context/scripts/hooks/config-protection.js +24 -4
  16. package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
  17. package/context/scripts/hooks/stop-goal-fit.js +262 -5
  18. package/context/scripts/hooks/workflow-steering.js +42 -0
  19. package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
  20. package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
  21. package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
  22. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  23. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  24. package/docs/adr/0006-typescript-first-source-policy.md +2 -0
  25. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  26. package/docs/adr/0007-skill-audit.md +2 -0
  27. package/docs/adr/0008-kit-operation-boundary.md +2 -0
  28. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
  29. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
  30. package/docs/adr/0011-mcp-posture.md +2 -0
  31. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
  32. package/docs/adr/0013-context-lifecycle.md +2 -0
  33. package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
  34. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
  35. package/docs/adr/0016-three-hard-boundary-model.md +2 -0
  36. package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
  37. package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
  38. package/docs/adr/0019-kit-dependency-ownership.md +2 -0
  39. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
  40. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
  41. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +113 -0
  42. package/docs/adr/README.md +49 -0
  43. package/docs/adr/index.md +34 -0
  44. package/docs/decisions/agent-coordination.md +20 -0
  45. package/docs/decisions/anti-gaming-trust-security.md +20 -0
  46. package/docs/decisions/context-lifecycle.md +18 -0
  47. package/docs/decisions/core-domain-kit-boundary.md +18 -0
  48. package/docs/decisions/flow-flow-agents-boundary.md +18 -0
  49. package/docs/decisions/flow-kit.md +20 -0
  50. package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
  51. package/docs/decisions/graph-knowledge-provider.md +63 -0
  52. package/docs/decisions/hook-core-kit-boundary.md +18 -0
  53. package/docs/decisions/index.md +19 -0
  54. package/docs/decisions/kit-dependency-ownership.md +18 -0
  55. package/docs/decisions/kit-operation-boundary.md +18 -0
  56. package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
  57. package/docs/decisions/kontour-resource-contract.md +18 -0
  58. package/docs/decisions/mcp-posture.md +18 -0
  59. package/docs/decisions/three-hard-boundary-model.md +18 -0
  60. package/docs/decisions/trust-reconcile.md +20 -0
  61. package/docs/decisions/typescript-source-policy.md +48 -0
  62. package/docs/decisions/workflow-enforcement.md +18 -0
  63. package/docs/decisions/workflow-trust-state.md +20 -0
  64. package/docs/fixture-ownership.md +1 -1
  65. package/docs/workflow-usage-guide.md +1 -1
  66. package/evals/ci/run-baseline.sh +6 -0
  67. package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
  68. package/evals/integration/test_checkpoint_signing.sh +4 -3
  69. package/evals/integration/test_current_json_per_actor.sh +516 -0
  70. package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
  71. package/evals/integration/test_gate_lockdown.sh +46 -0
  72. package/evals/integration/test_model_routing_escalation.sh +145 -0
  73. package/evals/integration/test_publish_delivery.sh +14 -6
  74. package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
  75. package/evals/integration/test_stop_hook_release.sh +552 -0
  76. package/evals/integration/test_trust_reconcile_negatives.sh +170 -0
  77. package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
  78. package/evals/run.sh +10 -0
  79. package/evals/static/test_knowledge_providers.sh +13 -4
  80. package/evals/static/test_model_routing_hints.sh +107 -0
  81. package/evals/static/test_workflow_skills.sh +15 -2
  82. package/kits/builder/skills/builder-shape/SKILL.md +10 -0
  83. package/kits/builder/skills/deliver/SKILL.md +69 -11
  84. package/kits/builder/skills/design-probe/SKILL.md +47 -0
  85. package/kits/builder/skills/execute-plan/SKILL.md +13 -0
  86. package/kits/builder/skills/fix-bug/SKILL.md +17 -0
  87. package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
  88. package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
  89. package/kits/builder/skills/plan-work/SKILL.md +9 -0
  90. package/kits/builder/skills/pull-work/SKILL.md +10 -0
  91. package/kits/builder/skills/review-work/SKILL.md +11 -0
  92. package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
  93. package/kits/builder/skills/verify-work/SKILL.md +11 -0
  94. package/kits/knowledge/adapters/default-store/index.js +147 -18
  95. package/kits/knowledge/adapters/flow-runner/index.js +912 -16
  96. package/kits/knowledge/adapters/obsidian-store/index.js +97 -14
  97. package/kits/knowledge/adapters/shared/codec.js +265 -0
  98. package/kits/knowledge/docs/README.md +121 -2
  99. package/kits/knowledge/docs/store-contract.md +517 -7
  100. package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
  101. package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
  102. package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
  103. package/kits/knowledge/evals/contract-suite/suite.test.js +212 -0
  104. package/kits/knowledge/evals/freshness/suite.test.js +339 -0
  105. package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
  106. package/kits/knowledge/evals/retirement/suite.test.js +1 -1
  107. package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
  108. package/kits/knowledge/flows/promote.flow.json +84 -0
  109. package/kits/knowledge/kit.json +15 -0
  110. package/kits/knowledge/promote/distill.js +96 -0
  111. package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
  112. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
  113. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
  114. package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
  115. package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
  116. package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
  117. package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
  118. package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
  119. package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
  120. package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
  121. package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
  122. package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
  123. package/kits/knowledge/promote/fixtures/session/state.json +19 -0
  124. package/kits/knowledge/promote/health.js +137 -0
  125. package/kits/knowledge/promote/index.js +176 -0
  126. package/kits/knowledge/promote/ingest.js +103 -0
  127. package/kits/knowledge/promote/lib.js +132 -0
  128. package/kits/knowledge/promote/link.js +84 -0
  129. package/kits/knowledge/promote/promote.test.js +174 -0
  130. package/kits/knowledge/providers/conformance/suite.test.js +18 -0
  131. package/kits/knowledge/providers/index.js +1 -0
  132. package/kits/knowledge/providers/neo4j/connection.js +121 -0
  133. package/kits/knowledge/providers/neo4j/cypher.js +190 -0
  134. package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
  135. package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
  136. package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
  137. package/kits/knowledge/providers/neo4j/index.js +280 -0
  138. package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
  139. package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
  140. package/kits/knowledge/providers/neo4j/sync.js +235 -0
  141. package/package.json +4 -2
  142. package/schemas/workflow-handoff.schema.json +6 -0
  143. package/scripts/README.md +1 -0
  144. package/scripts/ci/mint-attestation.js +33 -6
  145. package/scripts/ci/trust-reconcile.js +144 -26
  146. package/scripts/freeze-adrs.mjs +364 -0
  147. package/scripts/hooks/config-protection.js +24 -4
  148. package/scripts/hooks/evidence-capture.js +4 -1
  149. package/scripts/hooks/lib/config-protection-remedies.js +9 -0
  150. package/scripts/hooks/lib/current-pointer.js +123 -0
  151. package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
  152. package/scripts/hooks/stop-goal-fit.js +262 -5
  153. package/scripts/hooks/workflow-steering.js +42 -0
  154. package/scripts/statusline/flow-agents-statusline.js +3 -1
  155. package/src/cli/assignment-provider.ts +239 -59
  156. package/src/cli/workflow-sidecar.ts +529 -43
  157. package/src/lib/flow-resolver.ts +35 -14
  158. package/src/tools/validate-source-tree.ts +2 -1
@@ -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
  // -----------------------------------------------------------------------
@@ -873,4 +909,180 @@ describe("Knowledge Kit Store Contract Suite", () => {
873
909
  assert.ok(typeof record.body === "string", "body is a string");
874
910
  });
875
911
  });
912
+
913
+ // -----------------------------------------------------------------------
914
+ // §15 identity resolution — short-id prefix (AC1, R1)
915
+ //
916
+ // `get`/`getLinks` accept an unambiguous id prefix (>= 8 chars). An ambiguous
917
+ // prefix throws AMBIGUOUS_ID (a distinct code), never guesses or returns null.
918
+ // On-disk identity is unchanged (R4): records are still keyed by full id.
919
+ // -----------------------------------------------------------------------
920
+ describe("identity: short-id prefix resolution (AC1)", () => {
921
+ let dir, store;
922
+ before(() => { dir = makeTempDir(); store = makeStore(dir); });
923
+ after(() => fs.rmSync(dir, { recursive: true, force: true }));
924
+
925
+ test("get resolves a unique 8-char id prefix to the record", async () => {
926
+ const id = await store.create({
927
+ type: "concept", title: "Prefix Target", body: "b",
928
+ category: "decision.strategy", provenance: { agent: "tester" },
929
+ });
930
+ const prefix = id.slice(0, 8);
931
+ assert.equal(prefix.length, 8, "sanity: prefix is 8 chars");
932
+ const rec = await store.get(prefix);
933
+ assert.ok(rec, "8-char prefix resolves to a record");
934
+ assert.equal(rec.id, id, "prefix resolves to the correct full id");
935
+ });
936
+
937
+ test("getLinks resolves a unique 8-char id prefix", async () => {
938
+ const targetId = await store.create({
939
+ type: "concept", title: "PrefixLinkTarget", body: "t",
940
+ category: "test", provenance: { agent: "tester" },
941
+ });
942
+ const sourceId = await store.create({
943
+ type: "raw", title: "PrefixLinkSource", body: "s", category: "test",
944
+ links: [{ target_id: targetId, kind: "related" }],
945
+ provenance: { agent: "tester" },
946
+ });
947
+ const { forward } = await store.getLinks(sourceId.slice(0, 8));
948
+ assert.ok(forward.some((l) => l.target_id === targetId && l.kind === "related"),
949
+ "getLinks via an 8-char prefix returns the record's forward links");
950
+ });
951
+
952
+ test("an ambiguous prefix throws AMBIGUOUS_ID (never returns null)", async () => {
953
+ // Two records whose full ids deliberately share an 8-char prefix. create
954
+ // accepts an explicit id (§1.1: adapter MAY generate), so the collision is
955
+ // deterministic without depending on random UUIDs colliding.
956
+ await store.create({
957
+ id: "abcdef12-0000-0000-0000-000000000001",
958
+ type: "raw", title: "Ambiguous One", body: "1", category: "test",
959
+ provenance: { agent: "tester" },
960
+ });
961
+ await store.create({
962
+ id: "abcdef12-0000-0000-0000-000000000002",
963
+ type: "raw", title: "Ambiguous Two", body: "2", category: "test",
964
+ provenance: { agent: "tester" },
965
+ });
966
+ await assert.rejects(
967
+ () => store.get("abcdef12"),
968
+ (err) => {
969
+ assert.equal(err.code, "AMBIGUOUS_ID",
970
+ `expected AMBIGUOUS_ID, got code=${err.code}: ${err.message}`);
971
+ return true;
972
+ }
973
+ );
974
+ });
975
+
976
+ test("a prefix shorter than the 8-char minimum does not resolve (null)", async () => {
977
+ await store.create({
978
+ id: "deadbeef-1111-1111-1111-111111111111",
979
+ type: "raw", title: "Short Prefix Guard", body: "x", category: "test",
980
+ provenance: { agent: "tester" },
981
+ });
982
+ assert.equal(await store.get("dead"), null, "a <8-char token is never a prefix match");
983
+ });
984
+
985
+ test("exact full-id get is unchanged (R4)", async () => {
986
+ const id = await store.create({
987
+ type: "raw", title: "Exact Id", body: "e", category: "test",
988
+ provenance: { agent: "tester" },
989
+ });
990
+ const rec = await store.get(id);
991
+ assert.equal(rec.id, id, "exact-id get still returns the record");
992
+ });
993
+ });
994
+
995
+ // -----------------------------------------------------------------------
996
+ // §16 identity resolution — slug aliases (AC2, R2)
997
+ //
998
+ // Records carry human-readable, category-scoped slug aliases; `get`/`getLinks`
999
+ // resolve them through the same surface, whether set at create or via update.
1000
+ // -----------------------------------------------------------------------
1001
+ describe("identity: slug alias resolution (AC2)", () => {
1002
+ let dir, store;
1003
+ before(() => { dir = makeTempDir(); store = makeStore(dir); });
1004
+ after(() => fs.rmSync(dir, { recursive: true, force: true }));
1005
+
1006
+ test("a record created with a slug alias resolves via get(slug) and getLinks(slug)", async () => {
1007
+ const slug = "decision.strategy/2026-07-03-gtm-direction";
1008
+ const targetId = await store.create({
1009
+ type: "concept", title: "GTM Target", body: "t",
1010
+ category: "decision.strategy", provenance: { agent: "tester" },
1011
+ });
1012
+ const id = await store.create({
1013
+ type: "compiled", title: "GTM Direction", body: "the decision",
1014
+ category: "decision.strategy", aliases: [slug],
1015
+ links: [{ target_id: targetId, kind: "related" }],
1016
+ provenance: { agent: "tester" },
1017
+ });
1018
+
1019
+ const rec = await store.get(slug);
1020
+ assert.ok(rec, "slug resolves to a record");
1021
+ assert.equal(rec.id, id, "get(slug) returns the aliased record");
1022
+
1023
+ const { forward } = await store.getLinks(slug);
1024
+ assert.ok(forward.some((l) => l.target_id === targetId),
1025
+ "getLinks(slug) returns the aliased record's links");
1026
+ });
1027
+
1028
+ test("a slug alias added via update resolves through the same surface", async () => {
1029
+ const id = await store.create({
1030
+ type: "concept", title: "Later Slug", body: "b",
1031
+ category: "decision.strategy", provenance: { agent: "tester" },
1032
+ });
1033
+ await store.update(id, { aliases: ["decision.strategy/added-later"] }, { agent: "tester" });
1034
+ const rec = await store.get("decision.strategy/added-later");
1035
+ assert.ok(rec, "update-added slug resolves");
1036
+ assert.equal(rec.id, id, "get(added slug) returns the record");
1037
+ });
1038
+
1039
+ test("a malformed slug alias is rejected at create (MISSING_EVIDENCE)", () =>
1040
+ assertMissingEvidence(
1041
+ () => store.create({
1042
+ type: "raw", title: "Bad Slug", body: "b", category: "test",
1043
+ aliases: ["Not A Valid Slug!"], provenance: { agent: "tester" },
1044
+ }),
1045
+ "malformed slug alias"
1046
+ ));
1047
+ });
1048
+
1049
+ // -----------------------------------------------------------------------
1050
+ // §17 identity resolution — survives restructure (AC3, R3)
1051
+ //
1052
+ // After `update` recategorizes a record (obsidian: the file relocates folders),
1053
+ // the previously issued slug AND short-id prefix still resolve to the same
1054
+ // record — the alias map is keyed by full id, never by category or path.
1055
+ // -----------------------------------------------------------------------
1056
+ describe("identity: alias resolution survives restructure (AC3)", () => {
1057
+ let dir, store;
1058
+ before(() => { dir = makeTempDir(); store = makeStore(dir); });
1059
+ after(() => fs.rmSync(dir, { recursive: true, force: true }));
1060
+
1061
+ test("old slug and short-id prefix still resolve after a recategorize", async () => {
1062
+ const slug = "decision.strategy/2026-06-18-market-fork";
1063
+ const id = await store.create({
1064
+ type: "concept", title: "Market Fork Decision", body: "original",
1065
+ category: "decision.strategy", aliases: [slug],
1066
+ provenance: { agent: "tester" },
1067
+ });
1068
+ const prefix = id.slice(0, 8);
1069
+
1070
+ // Pre-move sanity: both handles resolve.
1071
+ assert.equal((await store.get(slug)).id, id, "slug resolves before move");
1072
+ assert.equal((await store.get(prefix)).id, id, "prefix resolves before move");
1073
+
1074
+ // Restructure: recategorize. In the obsidian adapter this relocates the
1075
+ // note from decision/strategy/ to decision/gtm/.
1076
+ await store.update(id, { category: "decision.gtm" }, { agent: "tester" });
1077
+ const moved = await store.get(id);
1078
+ assert.equal(moved.category, "decision.gtm", "category changed on disk");
1079
+
1080
+ // The whole point of the alias map: old handles survive the restructure.
1081
+ assert.equal((await store.get(slug)).id, id, "old slug survives the restructure");
1082
+ assert.equal((await store.get(prefix)).id, id, "short-id prefix survives the restructure");
1083
+
1084
+ const { reverse } = await store.getLinks(id);
1085
+ assert.ok(Array.isArray(reverse), "getLinks still answers for the moved record");
1086
+ });
1087
+ });
876
1088
  });
@@ -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
+ });