@kontourai/flow-agents 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +12 -0
- package/CHANGELOG.md +32 -0
- package/CONTEXT.md +67 -1
- package/README.md +4 -0
- package/build/src/cli/assignment-provider.d.ts +188 -0
- package/build/src/cli/assignment-provider.js +193 -51
- package/build/src/cli/workflow-sidecar.d.ts +16 -6
- package/build/src/cli/workflow-sidecar.js +492 -45
- package/build/src/lib/flow-resolver.d.ts +12 -6
- package/build/src/lib/flow-resolver.js +30 -14
- package/build/src/tools/validate-source-tree.js +2 -1
- package/context/contracts/assignment-provider-contract.md +10 -1
- package/context/contracts/execution-contract.md +78 -0
- package/context/contracts/probe-docs-write-contract.md +187 -0
- package/context/scripts/hooks/config-protection.js +24 -4
- package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/context/scripts/hooks/stop-goal-fit.js +262 -5
- package/context/scripts/hooks/workflow-steering.js +42 -0
- package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
- package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
- package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
- package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
- package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
- package/docs/adr/0006-typescript-first-source-policy.md +2 -0
- package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
- package/docs/adr/0007-skill-audit.md +2 -0
- package/docs/adr/0008-kit-operation-boundary.md +2 -0
- package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
- package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
- package/docs/adr/0011-mcp-posture.md +2 -0
- package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
- package/docs/adr/0013-context-lifecycle.md +2 -0
- package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
- package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
- package/docs/adr/0016-three-hard-boundary-model.md +2 -0
- package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
- package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
- package/docs/adr/0019-kit-dependency-ownership.md +2 -0
- package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
- package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +113 -0
- package/docs/adr/README.md +49 -0
- package/docs/adr/index.md +34 -0
- package/docs/decisions/agent-coordination.md +20 -0
- package/docs/decisions/anti-gaming-trust-security.md +20 -0
- package/docs/decisions/context-lifecycle.md +18 -0
- package/docs/decisions/core-domain-kit-boundary.md +18 -0
- package/docs/decisions/flow-flow-agents-boundary.md +18 -0
- package/docs/decisions/flow-kit.md +20 -0
- package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
- package/docs/decisions/graph-knowledge-provider.md +63 -0
- package/docs/decisions/hook-core-kit-boundary.md +18 -0
- package/docs/decisions/index.md +19 -0
- package/docs/decisions/kit-dependency-ownership.md +18 -0
- package/docs/decisions/kit-operation-boundary.md +18 -0
- package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
- package/docs/decisions/kontour-resource-contract.md +18 -0
- package/docs/decisions/mcp-posture.md +18 -0
- package/docs/decisions/three-hard-boundary-model.md +18 -0
- package/docs/decisions/trust-reconcile.md +20 -0
- package/docs/decisions/typescript-source-policy.md +48 -0
- package/docs/decisions/workflow-enforcement.md +18 -0
- package/docs/decisions/workflow-trust-state.md +20 -0
- package/docs/fixture-ownership.md +1 -1
- package/docs/workflow-usage-guide.md +1 -1
- package/evals/ci/run-baseline.sh +6 -0
- package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
- package/evals/integration/test_checkpoint_signing.sh +4 -3
- package/evals/integration/test_current_json_per_actor.sh +516 -0
- package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
- package/evals/integration/test_gate_lockdown.sh +46 -0
- package/evals/integration/test_model_routing_escalation.sh +145 -0
- package/evals/integration/test_publish_delivery.sh +14 -6
- package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
- package/evals/integration/test_stop_hook_release.sh +552 -0
- package/evals/integration/test_trust_reconcile_negatives.sh +170 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
- package/evals/run.sh +10 -0
- package/evals/static/test_knowledge_providers.sh +13 -4
- package/evals/static/test_model_routing_hints.sh +107 -0
- package/evals/static/test_workflow_skills.sh +15 -2
- package/kits/builder/skills/builder-shape/SKILL.md +10 -0
- package/kits/builder/skills/deliver/SKILL.md +69 -11
- package/kits/builder/skills/design-probe/SKILL.md +47 -0
- package/kits/builder/skills/execute-plan/SKILL.md +13 -0
- package/kits/builder/skills/fix-bug/SKILL.md +17 -0
- package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
- package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
- package/kits/builder/skills/plan-work/SKILL.md +9 -0
- package/kits/builder/skills/pull-work/SKILL.md +10 -0
- package/kits/builder/skills/review-work/SKILL.md +11 -0
- package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
- package/kits/builder/skills/verify-work/SKILL.md +11 -0
- package/kits/knowledge/adapters/default-store/index.js +147 -18
- package/kits/knowledge/adapters/flow-runner/index.js +912 -16
- package/kits/knowledge/adapters/obsidian-store/index.js +97 -14
- package/kits/knowledge/adapters/shared/codec.js +265 -0
- package/kits/knowledge/docs/README.md +121 -2
- package/kits/knowledge/docs/store-contract.md +517 -7
- package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
- package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
- package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
- package/kits/knowledge/evals/contract-suite/suite.test.js +212 -0
- package/kits/knowledge/evals/freshness/suite.test.js +339 -0
- package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
- package/kits/knowledge/evals/retirement/suite.test.js +1 -1
- package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
- package/kits/knowledge/flows/promote.flow.json +84 -0
- package/kits/knowledge/kit.json +15 -0
- package/kits/knowledge/promote/distill.js +96 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
- package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
- package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
- package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
- package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
- package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
- package/kits/knowledge/promote/fixtures/session/state.json +19 -0
- package/kits/knowledge/promote/health.js +137 -0
- package/kits/knowledge/promote/index.js +176 -0
- package/kits/knowledge/promote/ingest.js +103 -0
- package/kits/knowledge/promote/lib.js +132 -0
- package/kits/knowledge/promote/link.js +84 -0
- package/kits/knowledge/promote/promote.test.js +174 -0
- package/kits/knowledge/providers/conformance/suite.test.js +18 -0
- package/kits/knowledge/providers/index.js +1 -0
- package/kits/knowledge/providers/neo4j/connection.js +121 -0
- package/kits/knowledge/providers/neo4j/cypher.js +190 -0
- package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
- package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
- package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
- package/kits/knowledge/providers/neo4j/index.js +280 -0
- package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
- package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
- package/kits/knowledge/providers/neo4j/sync.js +235 -0
- package/package.json +4 -2
- package/schemas/workflow-handoff.schema.json +6 -0
- package/scripts/README.md +1 -0
- package/scripts/ci/mint-attestation.js +33 -6
- package/scripts/ci/trust-reconcile.js +144 -26
- package/scripts/freeze-adrs.mjs +364 -0
- package/scripts/hooks/config-protection.js +24 -4
- package/scripts/hooks/evidence-capture.js +4 -1
- package/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/scripts/hooks/lib/current-pointer.js +123 -0
- package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
- package/scripts/hooks/stop-goal-fit.js +262 -5
- package/scripts/hooks/workflow-steering.js +42 -0
- package/scripts/statusline/flow-agents-statusline.js +3 -1
- package/src/cli/assignment-provider.ts +239 -59
- package/src/cli/workflow-sidecar.ts +529 -43
- package/src/lib/flow-resolver.ts +35 -14
- package/src/tools/validate-source-tree.ts +2 -1
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge Kit — Supersede/Retire Propagation Eval Suite (#342)
|
|
3
|
+
*
|
|
4
|
+
* knowledge.flag-superseded-citers enumerates the first-degree inbound citers of
|
|
5
|
+
* a superseded / retired record and emits one flag per citer, each carrying the
|
|
6
|
+
* superseding context. Two citer sources:
|
|
7
|
+
* - store records — the reverse-link index (store contract §5, getLinks.reverse)
|
|
8
|
+
* - docs — the #340 inbound-reference citation index (byRecord), scanned
|
|
9
|
+
* from caller-configured doc globs (opt-in)
|
|
10
|
+
*
|
|
11
|
+
* The operation is READ-ONLY (mutates no record, appends no mutation-log entry —
|
|
12
|
+
* the existing gated supersede/retire ops are untouched) and FAIL-CLOSED on the
|
|
13
|
+
* evidence guarantee: a flag can never be emitted without superseding context, so
|
|
14
|
+
* a record with no supersession evidence yields zero flags (no false flags).
|
|
15
|
+
*
|
|
16
|
+
* Field motivation (ops design partner, record 0e439c57): a "sell the
|
|
17
|
+
* combination" decision was superseded/lost in a store restructure and four docs
|
|
18
|
+
* cited it invisibly for weeks. Supersession preserves the record and surfaces
|
|
19
|
+
* store citers via reverse links, but doc citers stayed invisible — this op
|
|
20
|
+
* closes that loop.
|
|
21
|
+
*
|
|
22
|
+
* PARAMETERIZED by adapter module — set KNOWLEDGE_ADAPTER to an adapter's
|
|
23
|
+
* absolute path, or pass --adapter=<path>. Defaults to the bundled default-store
|
|
24
|
+
* adapter. AC4 runs this file for the default adapter AND the obsidian adapter.
|
|
25
|
+
*
|
|
26
|
+
* AC map (issue #342):
|
|
27
|
+
* AC1 (R1) — a record with one store-record citer + one doc citer: the
|
|
28
|
+
* enumeration returns both, sourced from the reverse-link index and
|
|
29
|
+
* the citation index respectively.
|
|
30
|
+
* AC2 (R2) — after supersede(newId,[oldId]) each flag carries citerRef + citedId
|
|
31
|
+
* + newId; a superseded record with no citers yields an empty flag
|
|
32
|
+
* list; a non-superseded record yields zero flags (no false flags);
|
|
33
|
+
* retire-with-supersededByRef carries the retire evidence.
|
|
34
|
+
* AC3 (R3) — read-only invariant (byte-identical store before/after).
|
|
35
|
+
* AC4 (R4) — parameterized across adapters (this file, run per KNOWLEDGE_ADAPTER);
|
|
36
|
+
* contract addendum documents the operation + flag shape.
|
|
37
|
+
*
|
|
38
|
+
* Run:
|
|
39
|
+
* node --test kits/knowledge/evals/supersede-propagation/suite.test.js
|
|
40
|
+
* KNOWLEDGE_ADAPTER=kits/knowledge/adapters/obsidian-store/index.js \
|
|
41
|
+
* node --test kits/knowledge/evals/supersede-propagation/suite.test.js
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { test, describe, beforeEach, afterEach } from "node:test";
|
|
45
|
+
import assert from "node:assert/strict";
|
|
46
|
+
import * as fs from "node:fs";
|
|
47
|
+
import * as path from "node:path";
|
|
48
|
+
import * as os from "node:os";
|
|
49
|
+
import { fileURLToPath } from "node:url";
|
|
50
|
+
|
|
51
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
52
|
+
const KIT_ROOT = path.resolve(__dirname, "../..");
|
|
53
|
+
const REPO_ROOT = path.resolve(KIT_ROOT, "../..");
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Adapter resolution — same convention as evals/inbound-references/suite.test.js
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
function resolveAdapterPath() {
|
|
60
|
+
const flag = process.argv.find((a) => a.startsWith("--adapter="));
|
|
61
|
+
if (flag) return path.resolve(flag.slice("--adapter=".length));
|
|
62
|
+
if (process.env.KNOWLEDGE_ADAPTER) return path.resolve(process.env.KNOWLEDGE_ADAPTER);
|
|
63
|
+
return path.join(KIT_ROOT, "adapters/default-store/index.js");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const adapterPath = resolveAdapterPath();
|
|
67
|
+
const adapterModule = await import(adapterPath);
|
|
68
|
+
const AdapterClass = adapterModule.default || adapterModule.DefaultKnowledgeStore;
|
|
69
|
+
const ADAPTER_LABEL = path.relative(REPO_ROOT, adapterPath);
|
|
70
|
+
|
|
71
|
+
const runnerPath = path.join(KIT_ROOT, "adapters/flow-runner/index.js");
|
|
72
|
+
const { KnowledgeFlowRunner, flagSupersededCiters } = await import(runnerPath);
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Helpers
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
function makeTempDir(tag) {
|
|
79
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), `knowledge-supersede-prop-${tag}-`));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function makeStore(dir) {
|
|
83
|
+
return new AdapterClass({ storeRoot: dir });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The runner's workspace (telemetry sink) is kept SEPARATE from the store dir so
|
|
87
|
+
// gate telemetry never lands inside the store — letting AC3 assert the whole
|
|
88
|
+
// store tree is byte-identical, not just its record files.
|
|
89
|
+
function makeRunner(store, workspaceDir) {
|
|
90
|
+
return new KnowledgeFlowRunner({
|
|
91
|
+
store,
|
|
92
|
+
workspace: workspaceDir,
|
|
93
|
+
agent: "supersede-prop-test-runner",
|
|
94
|
+
sessionId: "supersede-prop-session-001",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function writeDoc(root, rel, body) {
|
|
99
|
+
const abs = path.join(root, rel);
|
|
100
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
101
|
+
fs.writeFileSync(abs, body, "utf8");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Recursive { relPath -> content } snapshot of every file under a dir. */
|
|
105
|
+
function snapshotTree(root) {
|
|
106
|
+
const out = {};
|
|
107
|
+
const walk = (abs, rel) => {
|
|
108
|
+
for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
|
|
109
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
110
|
+
if (e.isDirectory()) walk(path.join(abs, e.name), r);
|
|
111
|
+
else if (e.isFile()) out[r] = fs.readFileSync(path.join(abs, e.name), "utf8");
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
walk(root, "");
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Deterministic, valid-hex UUIDs.
|
|
119
|
+
const OLD = "aaaaaaaa-1111-4111-8111-111111111111"; // the superseded decision
|
|
120
|
+
const NEW = "bbbbbbbb-2222-4222-8222-222222222222"; // the superseding decision
|
|
121
|
+
const CITER = "cccccccc-3333-4333-8333-333333333333"; // a store record that cites OLD
|
|
122
|
+
const LONE = "dddddddd-4444-4444-8444-444444444444"; // superseded, no citers
|
|
123
|
+
const LONE_NEW = "eeeeeeee-5555-4555-8555-555555555555"; // supersedes LONE
|
|
124
|
+
const LIVE = "ffffffff-6666-4666-8666-666666666666"; // never superseded, but cited
|
|
125
|
+
const LIVE_CITER = "abababab-7777-4777-8777-777777777777"; // cites LIVE
|
|
126
|
+
const RET = "cdcdcdcd-8888-4888-8888-888888888888"; // retired-with-supersededByRef
|
|
127
|
+
const RET_CITER = "efefefef-9999-4999-8999-999999999999"; // cites RET
|
|
128
|
+
|
|
129
|
+
async function record(store, id, title, category) {
|
|
130
|
+
await store.create({
|
|
131
|
+
id, type: "compiled", title, body: `Body of ${title}`,
|
|
132
|
+
category, provenance: { agent: "fixture" },
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Suite
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
describe(`Knowledge Kit Supersede/Retire Propagation Suite (#342) [adapter: ${ADAPTER_LABEL}]`, () => {
|
|
141
|
+
let storeDir;
|
|
142
|
+
let workspaceDir;
|
|
143
|
+
let store;
|
|
144
|
+
let runner;
|
|
145
|
+
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
storeDir = makeTempDir("store");
|
|
148
|
+
workspaceDir = makeTempDir("ws");
|
|
149
|
+
store = makeStore(storeDir);
|
|
150
|
+
runner = makeRunner(store, workspaceDir);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
afterEach(() => {
|
|
154
|
+
if (storeDir) fs.rmSync(storeDir, { recursive: true, force: true });
|
|
155
|
+
if (workspaceDir) fs.rmSync(workspaceDir, { recursive: true, force: true });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("AC1: a record with one store-record citer + one doc citer enumerates BOTH, from the two indexes", async () => {
|
|
159
|
+
const docsRoot = makeTempDir("ac1-docs");
|
|
160
|
+
try {
|
|
161
|
+
await record(store, OLD, "Sell the combination (6/17 decision)", "decision.strategy");
|
|
162
|
+
await record(store, NEW, "GTM direction (7/3)", "decision.strategy");
|
|
163
|
+
await record(store, CITER, "Vision doc mirror", "decision.strategy");
|
|
164
|
+
// Store-record citer: CITER --related--> OLD (reverse-link index, §5).
|
|
165
|
+
await store.link(CITER, [{ target_id: OLD, kind: "related" }], { agent: "fixture" });
|
|
166
|
+
// Supersede OLD with NEW (Addendum A.5).
|
|
167
|
+
await store.supersede(NEW, [OLD], {
|
|
168
|
+
agent: "fixture",
|
|
169
|
+
rationale: "GTM direction supersedes the 6/17 sell-the-combination decision.",
|
|
170
|
+
});
|
|
171
|
+
// Doc citer: NOW.md cites OLD by full UUID (#340 citation index).
|
|
172
|
+
writeDoc(docsRoot, "NOW.md", `# NOW\n\nAnchored on decision ${OLD}.\n`);
|
|
173
|
+
|
|
174
|
+
const result = await runner.flagSupersededCiters(OLD, {
|
|
175
|
+
docGlobs: ["NOW.md"],
|
|
176
|
+
docsRoot,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
assert.equal(result.superseded, true, "OLD is superseded");
|
|
180
|
+
assert.equal(result.storeCiters, 1, "one store-record citer from the reverse-link index");
|
|
181
|
+
assert.equal(result.docCiters, 1, "one doc citer from the #340 citation index");
|
|
182
|
+
assert.equal(result.flags.length, 2, "both citers flagged");
|
|
183
|
+
|
|
184
|
+
const recordFlag = result.flags.find((f) => f.citerKind === "record");
|
|
185
|
+
const docFlag = result.flags.find((f) => f.citerKind === "doc");
|
|
186
|
+
assert.ok(recordFlag, "a store-record citer flag is present");
|
|
187
|
+
assert.equal(recordFlag.citerRef, CITER, "record flag names the citing record id");
|
|
188
|
+
assert.equal(recordFlag.linkKind, "related", "record flag carries the reverse link kind (index-sourced)");
|
|
189
|
+
|
|
190
|
+
assert.ok(docFlag, "a doc citer flag is present");
|
|
191
|
+
assert.equal(docFlag.doc, "NOW.md", "doc flag names the citing doc path");
|
|
192
|
+
assert.equal(docFlag.token, OLD, "doc flag names the cited token (citation index)");
|
|
193
|
+
assert.equal(typeof docFlag.line, "number", "doc flag names a line");
|
|
194
|
+
} finally {
|
|
195
|
+
fs.rmSync(docsRoot, { recursive: true, force: true });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("AC2: every emitted flag carries citerRef + citedId + the superseding record id (newId)", async () => {
|
|
200
|
+
const docsRoot = makeTempDir("ac2-ctx-docs");
|
|
201
|
+
try {
|
|
202
|
+
await record(store, OLD, "Old decision", "decision.strategy");
|
|
203
|
+
await record(store, NEW, "New decision", "decision.strategy");
|
|
204
|
+
await record(store, CITER, "Citer record", "decision.strategy");
|
|
205
|
+
await store.link(CITER, [{ target_id: OLD, kind: "related" }], { agent: "fixture" });
|
|
206
|
+
await store.supersede(NEW, [OLD], { agent: "fixture", rationale: "superseded." });
|
|
207
|
+
writeDoc(docsRoot, "strategy/vision.md", `# Vision\n\nSee ${OLD}.\n`);
|
|
208
|
+
|
|
209
|
+
const result = await runner.flagSupersededCiters(OLD, {
|
|
210
|
+
docGlobs: ["strategy/*.md"],
|
|
211
|
+
docsRoot,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
assert.equal(result.flags.length, 2);
|
|
215
|
+
for (const f of result.flags) {
|
|
216
|
+
assert.ok(f.citerRef, "flag carries a citer ref");
|
|
217
|
+
assert.equal(f.citedId, OLD, "flag names the cited (superseded) id");
|
|
218
|
+
assert.ok(
|
|
219
|
+
Array.isArray(f.supersededByIds) && f.supersededByIds.includes(NEW),
|
|
220
|
+
"flag carries the superseding record id (newId) as evidence"
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
} finally {
|
|
224
|
+
fs.rmSync(docsRoot, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("AC2: a superseded record with NO citers yields an empty flag list (no false flags)", async () => {
|
|
229
|
+
await record(store, LONE, "Lonely superseded decision", "decision.pricing");
|
|
230
|
+
await record(store, LONE_NEW, "Replacement decision", "decision.pricing");
|
|
231
|
+
await store.supersede(LONE_NEW, [LONE], { agent: "fixture", rationale: "replaced." });
|
|
232
|
+
|
|
233
|
+
const result = await runner.flagSupersededCiters(LONE, {});
|
|
234
|
+
assert.equal(result.superseded, true, "record is superseded");
|
|
235
|
+
assert.deepEqual(result.flags, [], "no citers → no flags");
|
|
236
|
+
assert.equal(result.storeCiters, 0, "the superseding record itself is not counted as a citer");
|
|
237
|
+
assert.equal(result.docCiters, 0);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("AC2: a NON-superseded record emits ZERO flags even when it has citers (evidence guarantee, fail closed)", async () => {
|
|
241
|
+
const docsRoot = makeTempDir("ac2-live-docs");
|
|
242
|
+
try {
|
|
243
|
+
await record(store, LIVE, "Live decision", "decision.strategy");
|
|
244
|
+
await record(store, LIVE_CITER, "Citer of live decision", "decision.strategy");
|
|
245
|
+
await store.link(LIVE_CITER, [{ target_id: LIVE, kind: "related" }], { agent: "fixture" });
|
|
246
|
+
writeDoc(docsRoot, "NOW.md", `# NOW\n\nCurrent authority ${LIVE}.\n`);
|
|
247
|
+
|
|
248
|
+
const result = await runner.flagSupersededCiters(LIVE, {
|
|
249
|
+
docGlobs: ["NOW.md"],
|
|
250
|
+
docsRoot,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
assert.equal(result.superseded, false, "record shows no supersession evidence");
|
|
254
|
+
assert.deepEqual(result.supersededByIds, [], "no superseding record ids");
|
|
255
|
+
assert.equal(result.supersededByRef, null, "no retire supersededByRef");
|
|
256
|
+
assert.deepEqual(result.flags, [], "no supersession evidence → no flag can be emitted");
|
|
257
|
+
// The inbound edges are still visible via the counts (enumeration is R1),
|
|
258
|
+
// even though gating (R2) suppresses the flags.
|
|
259
|
+
assert.equal(result.storeCiters, 1, "enumeration still surfaces the store citer");
|
|
260
|
+
assert.equal(result.docCiters, 1, "enumeration still surfaces the doc citer");
|
|
261
|
+
} finally {
|
|
262
|
+
fs.rmSync(docsRoot, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("R2: retire-with-supersededByRef flags citers carrying the retire evidence", async () => {
|
|
267
|
+
await record(store, RET, "Retired-and-superseded decision", "decision.ops");
|
|
268
|
+
await record(store, RET_CITER, "Citer of retired decision", "decision.ops");
|
|
269
|
+
await store.link(RET_CITER, [{ target_id: RET, kind: "related" }], { agent: "fixture" });
|
|
270
|
+
// Retire RET pointing at a superseding artifact ref (Addendum B.4).
|
|
271
|
+
await store.retire(RET, "retired", {
|
|
272
|
+
agent: "fixture",
|
|
273
|
+
rationale: "Superseded by the new GTM record; archiving.",
|
|
274
|
+
supersededByRef: NEW,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const result = await runner.flagSupersededCiters(RET, {});
|
|
278
|
+
assert.equal(result.superseded, true, "retire-with-supersededByRef counts as supersession");
|
|
279
|
+
assert.equal(result.supersededByRef, NEW, "the retire supersededByRef is surfaced");
|
|
280
|
+
assert.equal(result.retiredStatus, "retired", "retired status is surfaced");
|
|
281
|
+
assert.equal(result.flags.length, 1, "the store citer is flagged");
|
|
282
|
+
const [f] = result.flags;
|
|
283
|
+
assert.equal(f.citerRef, RET_CITER);
|
|
284
|
+
assert.equal(f.citedId, RET);
|
|
285
|
+
assert.equal(f.supersededByRef, NEW, "flag carries the retire evidence");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("AC3: read-only invariant — the store is byte-identical before and after the enumeration", async () => {
|
|
289
|
+
const docsRoot = makeTempDir("ac3-docs");
|
|
290
|
+
try {
|
|
291
|
+
await record(store, OLD, "Old decision", "decision.strategy");
|
|
292
|
+
await record(store, NEW, "New decision", "decision.strategy");
|
|
293
|
+
await record(store, CITER, "Citer record", "decision.strategy");
|
|
294
|
+
await store.link(CITER, [{ target_id: OLD, kind: "related" }], { agent: "fixture" });
|
|
295
|
+
await store.supersede(NEW, [OLD], { agent: "fixture", rationale: "superseded." });
|
|
296
|
+
writeDoc(docsRoot, "NOW.md", `# NOW\n\n${OLD}\n`);
|
|
297
|
+
|
|
298
|
+
const before = snapshotTree(storeDir);
|
|
299
|
+
await runner.flagSupersededCiters(OLD, { docGlobs: ["NOW.md"], docsRoot });
|
|
300
|
+
const after = snapshotTree(storeDir);
|
|
301
|
+
|
|
302
|
+
assert.deepEqual(after, before, "no store file is created, deleted, or mutated by the enumeration");
|
|
303
|
+
} finally {
|
|
304
|
+
fs.rmSync(docsRoot, { recursive: true, force: true });
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("opt-in: with no doc globs the enumeration returns store citers only (no doc scan)", async () => {
|
|
309
|
+
await record(store, OLD, "Old decision", "decision.strategy");
|
|
310
|
+
await record(store, NEW, "New decision", "decision.strategy");
|
|
311
|
+
await record(store, CITER, "Citer record", "decision.strategy");
|
|
312
|
+
await store.link(CITER, [{ target_id: OLD, kind: "related" }], { agent: "fixture" });
|
|
313
|
+
await store.supersede(NEW, [OLD], { agent: "fixture", rationale: "superseded." });
|
|
314
|
+
|
|
315
|
+
const result = await runner.flagSupersededCiters(OLD, {});
|
|
316
|
+
assert.equal(result.storeCiters, 1, "store citer enumerated");
|
|
317
|
+
assert.equal(result.docCiters, 0, "no doc scan when globs are unconfigured");
|
|
318
|
+
assert.equal(result.flags.length, 1);
|
|
319
|
+
assert.equal(result.flags[0].citerKind, "record");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("gate telemetry: collect-gate and flag-gate events are emitted", async () => {
|
|
323
|
+
await record(store, OLD, "Old decision", "decision.strategy");
|
|
324
|
+
await record(store, NEW, "New decision", "decision.strategy");
|
|
325
|
+
await store.supersede(NEW, [OLD], { agent: "fixture", rationale: "superseded." });
|
|
326
|
+
|
|
327
|
+
const result = await runner.flagSupersededCiters(OLD, {});
|
|
328
|
+
const names = result.telemetryEvents.map((e) => e.gate || (e.data && e.data.gate)).filter(Boolean);
|
|
329
|
+
assert.ok(
|
|
330
|
+
result.telemetryEvents.length >= 4,
|
|
331
|
+
"collect-gate + flag-gate produce at least 4 in/out events"
|
|
332
|
+
);
|
|
333
|
+
assert.ok(names.includes("collect-gate") || result.telemetryEvents.length >= 4, "collect-gate present");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("module-level flagSupersededCiters export delegates to the runner", async () => {
|
|
337
|
+
const docsRoot = makeTempDir("mod-docs");
|
|
338
|
+
try {
|
|
339
|
+
await record(store, OLD, "Old decision", "decision.strategy");
|
|
340
|
+
await record(store, NEW, "New decision", "decision.strategy");
|
|
341
|
+
await record(store, CITER, "Citer record", "decision.strategy");
|
|
342
|
+
await store.link(CITER, [{ target_id: OLD, kind: "related" }], { agent: "fixture" });
|
|
343
|
+
await store.supersede(NEW, [OLD], { agent: "fixture", rationale: "superseded." });
|
|
344
|
+
writeDoc(docsRoot, "NOW.md", `# NOW\n\n${OLD}\n`);
|
|
345
|
+
|
|
346
|
+
const result = await flagSupersededCiters(OLD, {
|
|
347
|
+
store, workspace: workspaceDir, agent: "supersede-prop-test-runner",
|
|
348
|
+
docGlobs: ["NOW.md"], docsRoot,
|
|
349
|
+
});
|
|
350
|
+
assert.equal(result.superseded, true);
|
|
351
|
+
assert.equal(result.flags.length, 2, "delegated call enumerates both citers");
|
|
352
|
+
} finally {
|
|
353
|
+
fs.rmSync(docsRoot, { recursive: true, force: true });
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("missing record fails closed (never a silent empty pass)", async () => {
|
|
358
|
+
await assert.rejects(
|
|
359
|
+
() => runner.flagSupersededCiters("deadbeef-0000-4000-8000-000000000000", {}),
|
|
360
|
+
/record not found/,
|
|
361
|
+
"an unknown record id throws, not returns an empty flag list"
|
|
362
|
+
);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
// AC4 (doc claim) — the contract addendum is committed. Adapter-independent, so
|
|
368
|
+
// run once (only under the default adapter to avoid duplicate reporting).
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
describe("Supersede/Retire Propagation — contract doc addendum (#342)", () => {
|
|
372
|
+
test("AC4: store-contract.md documents the operation as Addendum K", () => {
|
|
373
|
+
if (!ADAPTER_LABEL.includes("default-store")) return; // adapter-independent
|
|
374
|
+
const contract = fs.readFileSync(
|
|
375
|
+
path.join(KIT_ROOT, "docs/store-contract.md"), "utf8"
|
|
376
|
+
);
|
|
377
|
+
assert.match(contract, /## Addendum K —.*Supersede/i, "Addendum K heading present");
|
|
378
|
+
assert.match(contract, /flagSupersededCiters/, "documents the flow-runner operation name");
|
|
379
|
+
assert.match(contract, /supersededByIds/, "documents the flag superseding-context shape");
|
|
380
|
+
assert.match(contract, /read-only|read only/i, "documents the read-only invariant");
|
|
381
|
+
assert.match(contract, /no false flags|fail closed|fail-closed/i, "documents the evidence guarantee");
|
|
382
|
+
assert.match(contract, /byRecord|citation index/i, "documents the doc-citer citation-index source");
|
|
383
|
+
});
|
|
384
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "knowledge.promote",
|
|
3
|
+
"version": "1.0",
|
|
4
|
+
"description": "Knowledge Kit promote sub-flow (the codebase-facing 'flow within a flow'): ingest a delivered session's artifacts -> distill durable draft deltas (decision topics per the registry contract, CONTEXT.md vocabulary, learnings) -> link provenance (PR, merge SHA, session archive, touched topics) -> health-check the registry for contradictions (overlapping subject nouns with divergent status/content) and propose merge-repair. Invokable standalone AND composable from the Builder promote step via uses_flow. PROPOSALS-ONLY: every output lands as a draft under <session>/proposals/ for the promote step's human/agent to apply; the sub-flow never writes docs directly.",
|
|
5
|
+
"steps": [
|
|
6
|
+
{ "id": "ingest", "next": "distill" },
|
|
7
|
+
{ "id": "distill", "next": "link" },
|
|
8
|
+
{ "id": "link", "next": "health" },
|
|
9
|
+
{ "id": "health", "next": "done" },
|
|
10
|
+
{ "id": "done", "next": null }
|
|
11
|
+
],
|
|
12
|
+
"exports": [
|
|
13
|
+
"knowledge.promote.ingest",
|
|
14
|
+
"knowledge.promote.distill",
|
|
15
|
+
"knowledge.promote.link",
|
|
16
|
+
"knowledge.promote.health"
|
|
17
|
+
],
|
|
18
|
+
"gates": {
|
|
19
|
+
"ingest-gate": {
|
|
20
|
+
"step": "ingest",
|
|
21
|
+
"expects": [
|
|
22
|
+
{
|
|
23
|
+
"id": "session-residue-ingested",
|
|
24
|
+
"kind": "trust.bundle",
|
|
25
|
+
"required": true,
|
|
26
|
+
"description": "A completed session directory has been read (read-only) and its durable residue collected: the plan / Definition Of Done, verification evidence, review critique, learning records, and delegate transcripts. Ingest never mutates the session and never reads outside the given session directory. The residue is the raw material the remaining steps distill — nothing is proposed yet.",
|
|
27
|
+
"bundle_claim": {
|
|
28
|
+
"claimType": "knowledge.promote.ingest",
|
|
29
|
+
"subjectType": "artifact",
|
|
30
|
+
"accepted_statuses": ["trusted", "accepted"]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"distill-gate": {
|
|
36
|
+
"step": "distill",
|
|
37
|
+
"expects": [
|
|
38
|
+
{
|
|
39
|
+
"id": "draft-deltas-schema-valid",
|
|
40
|
+
"kind": "trust.bundle",
|
|
41
|
+
"required": true,
|
|
42
|
+
"description": "The distilled residue is rendered as DRAFT deltas that validate BEFORE they are proposed: decision-registry topic files whose frontmatter satisfies schemas/decision-record.schema.json (one file per decision SUBJECT, the subject a CONTEXT.md vocabulary noun), CONTEXT.md vocabulary additions for any new subject noun, and docs/learnings entries. At least one schema-valid draft decision delta is produced for a session that ratified a durable decision. Every draft is written ONLY under <session>/proposals/ — the sub-flow proposes, it never writes into docs/.",
|
|
43
|
+
"bundle_claim": {
|
|
44
|
+
"claimType": "knowledge.promote.distill",
|
|
45
|
+
"subjectType": "artifact",
|
|
46
|
+
"accepted_statuses": ["trusted", "accepted"]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
"link-gate": {
|
|
52
|
+
"step": "link",
|
|
53
|
+
"expects": [
|
|
54
|
+
{
|
|
55
|
+
"id": "provenance-links-attached",
|
|
56
|
+
"kind": "trust.bundle",
|
|
57
|
+
"required": true,
|
|
58
|
+
"description": "Every draft delta carries its provenance: the pull request, the merge commit SHA, the archived session artifact path, and the touched decision topics. Links become evidence[] refs on the draft decision topics (kinds pr / commit / session-archive / doc) so a reader can trace each proposed delta back to the delivery that produced it. Secret-shaped refs are refused.",
|
|
59
|
+
"bundle_claim": {
|
|
60
|
+
"claimType": "knowledge.promote.link",
|
|
61
|
+
"subjectType": "artifact",
|
|
62
|
+
"accepted_statuses": ["trusted", "accepted"]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
},
|
|
67
|
+
"health-gate": {
|
|
68
|
+
"step": "health",
|
|
69
|
+
"expects": [
|
|
70
|
+
{
|
|
71
|
+
"id": "contradiction-report-and-merge-proposal",
|
|
72
|
+
"kind": "trust.bundle",
|
|
73
|
+
"required": true,
|
|
74
|
+
"description": "The registry is health-checked over the #317 git-repo provider interface (the SAME reader every provider shares — no forked reader): the proposed draft decisions are folded into the existing registry graph and the duplicate-detection health verb flags topics with overlapping subject nouns. Where two CURRENT decisions collide over one subject with divergent content, the step emits a merge-repair PROPOSAL naming BOTH topics and a merge target. Merge-repair is a proposal, never an auto-edit; it lands under <session>/proposals/health/ for the operator to apply through the registry's supersede/merge edit.",
|
|
75
|
+
"bundle_claim": {
|
|
76
|
+
"claimType": "knowledge.promote.health",
|
|
77
|
+
"subjectType": "artifact",
|
|
78
|
+
"accepted_statuses": ["trusted", "accepted"]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
package/kits/knowledge/kit.json
CHANGED
|
@@ -59,6 +59,11 @@
|
|
|
59
59
|
"id": "knowledge.hygiene-review",
|
|
60
60
|
"path": "flows/hygiene-review.flow.json",
|
|
61
61
|
"description": "Hygiene orchestrator (#106 #5, closes the issue): a THIN, opt-in orchestrator over the four hygiene flows (audit-freshness, detect-contradictions, glossary-sync, canonicalize-category). orchestrate → review-gate. Runs each opted-in audit via its EXISTING flow-runner method + gates (reimplements no detection), folds their findings into one operator-facing review of proposed actions normalized to adopt / retire / merge, each citing its sourceFlow + the evidence its origin flow's gate already vouched. Read-only by default; forks NO propose→approve gate — the operator enacts each decision through the source flow's existing gated op (retire via knowledge.retire; adopt via capture/compile or glossary-sync apply; merge via update/recategorize). The only mutation it can trigger is glossary apply, delegated verbatim to knowledge.glossary-sync's propose→apply lineage (consume-never-fork)."
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"id": "knowledge.promote",
|
|
65
|
+
"path": "flows/promote.flow.json",
|
|
66
|
+
"description": "Knowledge promote sub-flow (issue #313, the codebase-facing 'flow within a flow'): ingest a delivered session's artifacts -> distill schema-valid DRAFT decision/vocabulary/learning deltas per the decision-registry contract -> link provenance (PR, merge SHA, session archive, touched topics) -> health-check the registry for contradictions (overlapping subject nouns + divergent content) and propose merge-repair. Invokable standalone AND composable from the Builder promote step via uses_flow. PROPOSALS-ONLY: outputs land under <session>/proposals/ for the promote step to apply; the sub-flow never writes docs directly. Executable logic: promote/index.js."
|
|
62
67
|
}
|
|
63
68
|
],
|
|
64
69
|
"docs": [
|
|
@@ -116,6 +121,11 @@
|
|
|
116
121
|
"id": "knowledge.store-providers",
|
|
117
122
|
"path": "providers/index.js",
|
|
118
123
|
"description": "Public entry point for the knowledge store providers, health verbs, graph model helpers, and schema validator. See context/contracts/knowledge-store-contract.md."
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"id": "knowledge.promote-runner",
|
|
127
|
+
"path": "promote/index.js",
|
|
128
|
+
"description": "Executable logic for the knowledge.promote sub-flow (issue #313): runPromote({sessionDir, repoRoot, provenance}) runs ingest -> distill -> link -> health and writes DRAFT proposals ONLY under <session>/proposals/. Builds on the #317 knowledge-store provider interface (git-repo provider as the shared registry reader; provider-agnostic duplicate-detection health verb) and validates every draft decision delta against schemas/decision-record.schema.json before proposing. Proposals-only by construction; never mutates docs/, CONTEXT.md, or any path outside the session dir."
|
|
119
129
|
}
|
|
120
130
|
],
|
|
121
131
|
"evals": [
|
|
@@ -188,6 +198,11 @@
|
|
|
188
198
|
"id": "knowledge.provider-health-pass-suite",
|
|
189
199
|
"path": "providers/health/health-pass.test.js",
|
|
190
200
|
"description": "Health-pass suite (issue #317, AC1+AC2): the same health command over vault + git-repo providers yields schema-valid reports (AC1); a backlog pass over the work-item adapter driven by a seeded fixture flags a duplicate and a broken blocker link (AC2) — never the real board."
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"id": "knowledge.promote-suite",
|
|
204
|
+
"path": "promote/promote.test.js",
|
|
205
|
+
"description": "Eval cases for the knowledge.promote sub-flow (issue #313): AC1 (a real-modeled completed session dir yields >=1 schema-valid draft decision delta with PR/merge-SHA/session-archive provenance, validated against the real #310 registry validator); AC2 (a seeded-contradiction registry fixture yields a duplicate-detection report naming BOTH topics + a schema-valid merge-repair proposal naming a merge target; a resolved supersede/merge pair is not re-flagged); AC3 (filesystem diff proves zero writes outside the session/proposal dir); R1 (the FlowDefinition exports every gate claim so a parent uses_flow step resolves it)."
|
|
191
206
|
}
|
|
192
207
|
],
|
|
193
208
|
"skills": [
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step 2 — DISTILL.
|
|
3
|
+
*
|
|
4
|
+
* Turn the ingested residue into DRAFT deltas that validate BEFORE they are
|
|
5
|
+
* proposed (R2): decision-registry topic files (one per decision SUBJECT, the
|
|
6
|
+
* subject a CONTEXT.md vocabulary noun), CONTEXT.md vocabulary additions for any
|
|
7
|
+
* new subject noun, and docs/learnings entries. Nothing is written to disk here
|
|
8
|
+
* — distill returns draft objects; writing (only under <session>/proposals/)
|
|
9
|
+
* happens in the orchestrator.
|
|
10
|
+
*
|
|
11
|
+
* @module promote/distill
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { slugify, readGlossaryTerms, validateDecisionDelta } from "./lib.js";
|
|
15
|
+
|
|
16
|
+
function deriveSubject(decision) {
|
|
17
|
+
if (decision.subject) return decision.subject;
|
|
18
|
+
// Derive a rough noun-phrase subject from the outcome body (draft — the
|
|
19
|
+
// operator refines it at apply time). Take the leading clause, title-case.
|
|
20
|
+
const clause = String(decision.body || "").split(/[.;:]/)[0].trim().split(/\s+/).slice(0, 6).join(" ");
|
|
21
|
+
if (!clause) return "Delivered decision";
|
|
22
|
+
return clause.charAt(0).toUpperCase() + clause.slice(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} residue from ingestSession.
|
|
27
|
+
* @param {object} [options]
|
|
28
|
+
* @param {string} [options.repoRoot] repo whose CONTEXT.md is checked for vocabulary gaps.
|
|
29
|
+
* @param {string} [options.decided] ISO date to stamp on drafts (default: today).
|
|
30
|
+
* @returns {{ decisions: object[], vocabulary: object[], learnings: object[], warnings: string[] }}
|
|
31
|
+
*/
|
|
32
|
+
export function distill(residue, options = {}) {
|
|
33
|
+
const decided = options.decided || new Date().toISOString().slice(0, 10);
|
|
34
|
+
const glossary = options.repoRoot ? readGlossaryTerms(options.repoRoot) : new Set();
|
|
35
|
+
const warnings = [];
|
|
36
|
+
|
|
37
|
+
const decisions = [];
|
|
38
|
+
const vocabulary = [];
|
|
39
|
+
const seenSlugs = new Set();
|
|
40
|
+
const seenVocab = new Set();
|
|
41
|
+
|
|
42
|
+
for (const raw of residue.decisions || []) {
|
|
43
|
+
const subject = deriveSubject(raw);
|
|
44
|
+
const slug = slugify(subject);
|
|
45
|
+
if (!slug || seenSlugs.has(slug)) continue;
|
|
46
|
+
seenSlugs.add(slug);
|
|
47
|
+
|
|
48
|
+
// Base provenance so the draft is already schema-valid at distill time
|
|
49
|
+
// (R2: validate BEFORE proposing). link() enriches with PR + merge SHA.
|
|
50
|
+
const evidence = [
|
|
51
|
+
{ kind: "session-archive", ref: `${residue.sessionDir}/${residue.sessionMarkdown || "state.json"}` },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const newVocabulary = !glossary.has(subject.toLowerCase());
|
|
55
|
+
const delta = {
|
|
56
|
+
subject,
|
|
57
|
+
slug,
|
|
58
|
+
status: "current",
|
|
59
|
+
decided,
|
|
60
|
+
body: raw.body || subject,
|
|
61
|
+
evidence,
|
|
62
|
+
derived: !!raw.derived,
|
|
63
|
+
newVocabulary,
|
|
64
|
+
};
|
|
65
|
+
const errors = validateDecisionDelta(delta);
|
|
66
|
+
if (errors.length) {
|
|
67
|
+
warnings.push(`decision draft '${slug}' failed pre-proposal validation: ${errors.join("; ")}`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
decisions.push(delta);
|
|
71
|
+
|
|
72
|
+
if (newVocabulary && !seenVocab.has(slug)) {
|
|
73
|
+
seenVocab.add(slug);
|
|
74
|
+
vocabulary.push({
|
|
75
|
+
term: subject,
|
|
76
|
+
slug,
|
|
77
|
+
definition: (raw.body || subject).split(/[.;]/)[0].trim(),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const learnings = (residue.learnings || []).map((rec, i) => ({
|
|
83
|
+
id: rec.id || `learn-${residue.slug}-${i + 1}`,
|
|
84
|
+
title: rec.summary ? rec.summary.split(/[.;]/)[0].trim() : `Learning ${i + 1}`,
|
|
85
|
+
body: rec.summary || "",
|
|
86
|
+
outcome: rec.outcome || null,
|
|
87
|
+
source_refs: Array.isArray(rec.source_refs) ? rec.source_refs : [],
|
|
88
|
+
correction: rec.correction && rec.correction.needed ? {
|
|
89
|
+
type: rec.correction.type || null,
|
|
90
|
+
recurrence_key: rec.correction.recurrence_key || null,
|
|
91
|
+
prevention: rec.correction.prevention || null,
|
|
92
|
+
} : null,
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
return { decisions, vocabulary, learnings, warnings };
|
|
96
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: current
|
|
3
|
+
subject: Cache eviction policy
|
|
4
|
+
decided: 2026-06-10
|
|
5
|
+
evidence:
|
|
6
|
+
- kind: issue
|
|
7
|
+
ref: https://github.com/kontourai/flow-agents/issues/401
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Cache eviction policy
|
|
11
|
+
|
|
12
|
+
Entries are evicted using a least-recently-used (LRU) policy.
|
package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: current
|
|
3
|
+
subject: Cache eviction policy
|
|
4
|
+
decided: 2026-06-25
|
|
5
|
+
evidence:
|
|
6
|
+
- kind: issue
|
|
7
|
+
ref: https://github.com/kontourai/flow-agents/issues/415
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Cache eviction policy
|
|
11
|
+
|
|
12
|
+
Entries are evicted using a least-frequently-used (LFU) policy.
|