@kontourai/flow-agents 0.3.0 → 0.4.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/release-please.yml +13 -1
- package/AGENTS.md +8 -1
- package/CHANGELOG.md +18 -0
- package/evals/static/test_universal_bundles.sh +10 -0
- package/kits/knowledge/adapters/default-store/index.js +93 -12
- package/kits/knowledge/adapters/flow-runner/index.js +290 -0
- package/kits/knowledge/adapters/similarity-vector/index.js +284 -0
- package/kits/knowledge/docs/README.md +193 -0
- package/kits/knowledge/docs/store-contract.md +124 -0
- package/kits/knowledge/evals/contract-suite/suite.test.js +10 -5
- package/kits/knowledge/evals/retirement/suite.test.js +1173 -0
- package/kits/knowledge/evals/similarity-vector/suite.test.js +685 -0
- package/kits/knowledge/evals/synthesis/suite.test.js +10 -3
- package/kits/knowledge/flows/retire.flow.json +77 -0
- package/kits/knowledge/kit.json +21 -1
- package/package.json +1 -1
|
@@ -15,10 +15,22 @@ jobs:
|
|
|
15
15
|
release-please:
|
|
16
16
|
runs-on: ubuntu-latest
|
|
17
17
|
steps:
|
|
18
|
+
# Release PRs authored by the default GITHUB_TOKEN (github-actions[bot])
|
|
19
|
+
# get their CI runs held as action_required on every refresh. Minting a
|
|
20
|
+
# token from the org's kontour-release-bot app makes the app the PR
|
|
21
|
+
# author, so CI runs unassisted. See kontourai/flow-agents#38.
|
|
22
|
+
- name: Mint app token
|
|
23
|
+
id: app-token
|
|
24
|
+
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
|
|
25
|
+
with:
|
|
26
|
+
app-id: ${{ vars.RELEASE_APP_ID }}
|
|
27
|
+
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
|
28
|
+
|
|
18
29
|
- name: Run release-please
|
|
19
30
|
id: release
|
|
20
|
-
uses: googleapis/release-please-action@
|
|
31
|
+
uses: googleapis/release-please-action@v5
|
|
21
32
|
with:
|
|
33
|
+
token: ${{ steps.app-token.outputs.token }}
|
|
22
34
|
config-file: release-please-config.json
|
|
23
35
|
manifest-file: .release-please-manifest.json
|
|
24
36
|
|
package/AGENTS.md
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
# Universal Agent Bundle (Claude Code)
|
|
2
2
|
|
|
3
|
-
This bundle was generated from the canonical source in this repo. Treat the repo root as the source of truth and regenerate the bundle instead of editing exported agent files by hand.
|
|
3
|
+
This bundle was generated from the canonical source in this repo. Treat the repo root as the source of truth and regenerate the bundle instead of editing exported agent files by hand. (Exception: the "Repository Conventions" section below is source-repo-specific, maintained by hand, and intentionally absent from generated bundles.)
|
|
4
|
+
|
|
5
|
+
## Repository Conventions (source repo only)
|
|
6
|
+
|
|
7
|
+
- **Commit messages drive releases.** Releases are automated with release-please: `feat:` bumps minor, `fix:` bumps patch, `feat!:`/`BREAKING CHANGE` bumps major; `docs:`/`chore:`/`test:`/`refactor:` don't bump. Commits without a conventional prefix are invisible to version inference — use one. Details: CONTRIBUTING.md ("Releases").
|
|
8
|
+
- **Never hand-edit release PRs** (`release-please--branches--*`); they are regenerated on every push to main.
|
|
9
|
+
- **Evidence hygiene:** issue/PR permalinks must pin a real commit SHA (`git rev-parse`, never typed by hand); claims about behavior need command/test evidence.
|
|
10
|
+
- `.flow-agents/` runtime artifacts stay untracked; durable records belong in docs/, issues, or tracked source.
|
|
4
11
|
|
|
5
12
|
## Shared Conventions
|
|
6
13
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.0](https://github.com/kontourai/flow-agents/compare/v0.3.0...v0.4.0) (2026-06-12)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **knowledge-kit:** gated decision retirement + working-set exclusion (S7, [#37](https://github.com/kontourai/flow-agents/issues/37)) ([40ae3fb](https://github.com/kontourai/flow-agents/commit/40ae3fb483205da6cf349265a63245bd1bb4006b))
|
|
9
|
+
* **knowledge-kit:** vector similarity detector — first drop-in (I10 unparked) ([a63c6d4](https://github.com/kontourai/flow-agents/commit/a63c6d4eb122d86cf14f018dc23651043be6449a))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Fixes
|
|
13
|
+
|
|
14
|
+
* **ci:** author release PRs via kontour-release-bot app token ([#38](https://github.com/kontourai/flow-agents/issues/38)) ([6a2c937](https://github.com/kontourai/flow-agents/commit/6a2c9376df0c07458e54066108ec17e3c9548841))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* repo commit/release conventions in AGENTS.md, pinned by static check ([aecd896](https://github.com/kontourai/flow-agents/commit/aecd896fb2c4a86ebe51749c2257e7b41b9cbc21))
|
|
20
|
+
|
|
3
21
|
## [0.3.0](https://github.com/kontourai/flow-agents/compare/v0.2.0...v0.3.0) (2026-06-12)
|
|
4
22
|
|
|
5
23
|
|
|
@@ -286,6 +286,16 @@ else
|
|
|
286
286
|
_fail "opencode bundle missing opencode.json"
|
|
287
287
|
fi
|
|
288
288
|
|
|
289
|
+
# Root AGENTS.md carries a hand-maintained "Repository Conventions" section
|
|
290
|
+
# (commit/release rules for agents working in THIS repo). The rest of the
|
|
291
|
+
# file mirrors generated bundle output; this pin prevents a regeneration
|
|
292
|
+
# sync from silently dropping the repo-specific section.
|
|
293
|
+
if grep -q "## Repository Conventions (source repo only)" "$ROOT_DIR/AGENTS.md" 2>/dev/null && grep -q "release-please" "$ROOT_DIR/AGENTS.md" 2>/dev/null; then
|
|
294
|
+
_pass "root AGENTS.md retains the Repository Conventions section"
|
|
295
|
+
else
|
|
296
|
+
_fail "root AGENTS.md is missing the Repository Conventions section (regeneration clobbered it?)"
|
|
297
|
+
fi
|
|
298
|
+
|
|
289
299
|
# Generated hook artifacts must PARSE in their host language. The pi live
|
|
290
300
|
# smoke (2026-06-11) caught the generator emitting an unterminated string
|
|
291
301
|
# (template-literal escaping) that pi's loader rejected at startup.
|
|
@@ -167,9 +167,19 @@ function serializeYaml(obj, indent = 0) {
|
|
|
167
167
|
const entries = Object.entries(item).filter(([, v]) => v !== undefined && v !== null);
|
|
168
168
|
if (entries.length === 0) { lines.push(`${pad} - {}`); continue; }
|
|
169
169
|
const [firstKey, firstVal] = entries[0];
|
|
170
|
-
|
|
170
|
+
if (typeof firstVal === "object" && firstVal !== null && !Array.isArray(firstVal)) {
|
|
171
|
+
lines.push(`${pad} - ${firstKey}:`);
|
|
172
|
+
lines.push(serializeYaml(firstVal, indent + 6));
|
|
173
|
+
} else {
|
|
174
|
+
lines.push(`${pad} - ${firstKey}: ${yamlScalar(firstVal)}`);
|
|
175
|
+
}
|
|
171
176
|
for (const [k, v] of entries.slice(1)) {
|
|
172
|
-
|
|
177
|
+
if (typeof v === "object" && v !== null && !Array.isArray(v)) {
|
|
178
|
+
lines.push(`${pad} ${k}:`);
|
|
179
|
+
lines.push(serializeYaml(v, indent + 6));
|
|
180
|
+
} else {
|
|
181
|
+
lines.push(`${pad} ${k}: ${yamlScalar(v)}`);
|
|
182
|
+
}
|
|
173
183
|
}
|
|
174
184
|
} else {
|
|
175
185
|
lines.push(`${pad} - ${yamlScalar(item)}`);
|
|
@@ -292,8 +302,16 @@ function removeLinksFromGraph(graph, sourceId) {
|
|
|
292
302
|
// ---------------------------------------------------------------------------
|
|
293
303
|
|
|
294
304
|
const VALID_TYPES = new Set(["raw", "compiled", "concept", "snapshot"]);
|
|
305
|
+
const VALID_STATUSES = new Set(["active", "implemented", "retired"]);
|
|
295
306
|
const CATEGORY_SEGMENT_RE = /^[a-z0-9_-]+$/;
|
|
296
307
|
|
|
308
|
+
// Status transition table: from → allowed targets
|
|
309
|
+
const VALID_STATUS_TRANSITIONS = {
|
|
310
|
+
active: new Set(["implemented", "retired"]),
|
|
311
|
+
implemented: new Set(["retired"]),
|
|
312
|
+
retired: new Set(), // terminal — no further transitions
|
|
313
|
+
};
|
|
314
|
+
|
|
297
315
|
function validateCategory(cat) {
|
|
298
316
|
if (!cat || typeof cat !== "string") return false;
|
|
299
317
|
return cat.split(".").every((seg) => CATEGORY_SEGMENT_RE.test(seg));
|
|
@@ -376,6 +394,7 @@ export class DefaultKnowledgeStore {
|
|
|
376
394
|
title: input.title,
|
|
377
395
|
category: input.category,
|
|
378
396
|
tags: input.tags || [],
|
|
397
|
+
status: "active",
|
|
379
398
|
created_at: now,
|
|
380
399
|
updated_at: now,
|
|
381
400
|
provenance: {
|
|
@@ -526,8 +545,7 @@ export class DefaultKnowledgeStore {
|
|
|
526
545
|
|
|
527
546
|
const concept = this._readRecord(conceptId);
|
|
528
547
|
if (!concept) throw notFoundError(conceptId);
|
|
529
|
-
|
|
530
|
-
throw missingEvidenceError(`propose: concept_id must reference a concept or snapshot record; got type: ${concept.type}`);
|
|
548
|
+
// Any record type may receive a proposal (retire flow uses this for all types)
|
|
531
549
|
|
|
532
550
|
const proposer = this._readRecord(proposerId);
|
|
533
551
|
if (!proposer) throw notFoundError(proposerId);
|
|
@@ -594,8 +612,7 @@ export class DefaultKnowledgeStore {
|
|
|
594
612
|
|
|
595
613
|
const concept = this._readRecord(conceptId);
|
|
596
614
|
if (!concept) throw notFoundError(conceptId);
|
|
597
|
-
|
|
598
|
-
throw missingEvidenceError(`apply: concept_id must reference a concept or snapshot record; got type: ${concept.type}`);
|
|
615
|
+
// Any record type may be the apply target
|
|
599
616
|
|
|
600
617
|
const proposer = this._readRecord(proposerId);
|
|
601
618
|
if (!proposer) throw notFoundError(proposerId);
|
|
@@ -637,8 +654,7 @@ export class DefaultKnowledgeStore {
|
|
|
637
654
|
|
|
638
655
|
const concept = this._readRecord(conceptId);
|
|
639
656
|
if (!concept) throw notFoundError(conceptId);
|
|
640
|
-
|
|
641
|
-
throw missingEvidenceError(`reject: concept_id must reference a concept or snapshot record; got type: ${concept.type}`);
|
|
657
|
+
// Any record type may be the reject target
|
|
642
658
|
|
|
643
659
|
const proposer = this._readRecord(proposerId);
|
|
644
660
|
if (!proposer) throw notFoundError(proposerId);
|
|
@@ -759,6 +775,59 @@ export class DefaultKnowledgeStore {
|
|
|
759
775
|
}
|
|
760
776
|
|
|
761
777
|
|
|
778
|
+
// -------------------------------------------------------------------------
|
|
779
|
+
// retire (Addendum B — S7)
|
|
780
|
+
// -------------------------------------------------------------------------
|
|
781
|
+
|
|
782
|
+
async retire(id, targetStatus, evidence) {
|
|
783
|
+
if (!evidence?.agent)
|
|
784
|
+
throw missingEvidenceError("retire: missing required evidence field: agent");
|
|
785
|
+
if (!evidence?.rationale || !evidence.rationale.trim())
|
|
786
|
+
throw missingEvidenceError("retire: missing required evidence field: rationale");
|
|
787
|
+
if (targetStatus !== "implemented" && targetStatus !== "retired")
|
|
788
|
+
throw missingEvidenceError(
|
|
789
|
+
`retire: targetStatus must be "implemented" or "retired"; got: ${targetStatus}`
|
|
790
|
+
);
|
|
791
|
+
if (targetStatus === "implemented" && (!evidence.implementedByRef || !evidence.implementedByRef.trim()))
|
|
792
|
+
throw missingEvidenceError(
|
|
793
|
+
'retire: implementedByRef is required when targetStatus is "implemented"'
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
const record = this._readRecord(id);
|
|
797
|
+
if (!record) throw notFoundError(id);
|
|
798
|
+
|
|
799
|
+
const currentStatus = record.status || "active";
|
|
800
|
+
const allowed = VALID_STATUS_TRANSITIONS[currentStatus];
|
|
801
|
+
if (!allowed || !allowed.has(targetStatus)) {
|
|
802
|
+
throw missingEvidenceError(
|
|
803
|
+
`retire: invalid transition from "${currentStatus}" to "${targetStatus}"`
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const now = this._now();
|
|
808
|
+
const updated = {
|
|
809
|
+
...record,
|
|
810
|
+
status: targetStatus,
|
|
811
|
+
updated_at: now,
|
|
812
|
+
mutation_log: [
|
|
813
|
+
...(record.mutation_log || []),
|
|
814
|
+
{
|
|
815
|
+
op: "retire",
|
|
816
|
+
at: now,
|
|
817
|
+
agent: evidence.agent,
|
|
818
|
+
...(evidence.note ? { note: evidence.note } : {}),
|
|
819
|
+
evidence: {
|
|
820
|
+
targetStatus,
|
|
821
|
+
rationale: evidence.rationale,
|
|
822
|
+
...(evidence.implementedByRef ? { implementedByRef: evidence.implementedByRef } : {}),
|
|
823
|
+
...(evidence.supersededByRef ? { supersededByRef: evidence.supersededByRef } : {}),
|
|
824
|
+
},
|
|
825
|
+
},
|
|
826
|
+
],
|
|
827
|
+
};
|
|
828
|
+
this._writeRecord(updated);
|
|
829
|
+
}
|
|
830
|
+
|
|
762
831
|
// -------------------------------------------------------------------------
|
|
763
832
|
// get
|
|
764
833
|
// -------------------------------------------------------------------------
|
|
@@ -785,20 +854,32 @@ export class DefaultKnowledgeStore {
|
|
|
785
854
|
|
|
786
855
|
async listByCategory(category, options = {}) {
|
|
787
856
|
const records = this._allRecords();
|
|
857
|
+
const includeRetired = options.includeRetired === true;
|
|
788
858
|
if (options.prefix) {
|
|
789
859
|
return records.filter(
|
|
790
|
-
(r) =>
|
|
860
|
+
(r) =>
|
|
861
|
+
(r.category === category || r.category.startsWith(`${category}.`)) &&
|
|
862
|
+
(includeRetired || (r.status || "active") !== "retired")
|
|
791
863
|
);
|
|
792
864
|
}
|
|
793
|
-
return records.filter(
|
|
865
|
+
return records.filter(
|
|
866
|
+
(r) =>
|
|
867
|
+
r.category === category &&
|
|
868
|
+
(includeRetired || (r.status || "active") !== "retired")
|
|
869
|
+
);
|
|
794
870
|
}
|
|
795
871
|
|
|
796
872
|
// -------------------------------------------------------------------------
|
|
797
873
|
// listByType
|
|
798
874
|
// -------------------------------------------------------------------------
|
|
799
875
|
|
|
800
|
-
async listByType(type) {
|
|
801
|
-
|
|
876
|
+
async listByType(type, options = {}) {
|
|
877
|
+
const includeRetired = options.includeRetired === true;
|
|
878
|
+
return this._allRecords().filter(
|
|
879
|
+
(r) =>
|
|
880
|
+
r.type === type &&
|
|
881
|
+
(includeRetired || (r.status || "active") !== "retired")
|
|
882
|
+
);
|
|
802
883
|
}
|
|
803
884
|
|
|
804
885
|
// -------------------------------------------------------------------------
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* - compile(rawIds[], options) — compile flow: select → compile → link with provenance
|
|
14
14
|
* - synthesize(conceptId | topicSelector, options) — synthesize flow:
|
|
15
15
|
* detect-cluster → propose → evidence-gate → apply-or-reject
|
|
16
|
+
* - retire(recordId, options) — retire flow: identify → propose → evidence-gate → apply-or-reject
|
|
16
17
|
* - defaultSimilarityDetector — pluggable similarity interface default (R3)
|
|
17
18
|
*
|
|
18
19
|
* Telemetry:
|
|
@@ -112,6 +113,9 @@ export async function defaultSimilarityDetector(concept, candidates, store) {
|
|
|
112
113
|
const similar = [];
|
|
113
114
|
|
|
114
115
|
for (const candidate of candidates) {
|
|
116
|
+
// Exclude retired records from the working set (Addendum B — R3)
|
|
117
|
+
if ((candidate.status || "active") === "retired") continue;
|
|
118
|
+
|
|
115
119
|
// Check 1: category overlap (prefix match in either direction)
|
|
116
120
|
const catMatch =
|
|
117
121
|
candidate.category === concept.category ||
|
|
@@ -1107,6 +1111,278 @@ export class KnowledgeFlowRunner {
|
|
|
1107
1111
|
};
|
|
1108
1112
|
}
|
|
1109
1113
|
|
|
1114
|
+
// -------------------------------------------------------------------------
|
|
1115
|
+
// knowledge.retire flow (Addendum B — S7)
|
|
1116
|
+
// Steps: identify → propose-retirement → evidence-gate → apply-or-reject → done
|
|
1117
|
+
// Gate: evidence-gate — proposal carries rationale/ref; no direct mutation (AC1).
|
|
1118
|
+
// apply-gate — apply or reject via store retire op.
|
|
1119
|
+
// rejection leaves record status byte-identical (AC2).
|
|
1120
|
+
//
|
|
1121
|
+
// Machinery reuse: retire shares the same propose→evidence-gate→apply-or-reject
|
|
1122
|
+
// pattern as synthesize/consolidate. The store's retire op enforces the transition
|
|
1123
|
+
// table; rejection leaves the record unchanged.
|
|
1124
|
+
// -------------------------------------------------------------------------
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Execute the retire flow: identify the target record, create a retirement
|
|
1128
|
+
* proposal (never a direct mutation), gate the evidence, then apply or reject.
|
|
1129
|
+
*
|
|
1130
|
+
* On apply:
|
|
1131
|
+
* The store retire op updates the record status to targetStatus and appends
|
|
1132
|
+
* a mutation log entry with the full evidence. The record is excluded from
|
|
1133
|
+
* default working-set queries (listByType, listByCategory, similarity
|
|
1134
|
+
* detection) unless includeRetired is true.
|
|
1135
|
+
*
|
|
1136
|
+
* On reject:
|
|
1137
|
+
* The record status is byte-identical to its pre-proposal state.
|
|
1138
|
+
*
|
|
1139
|
+
* @param {string} recordId
|
|
1140
|
+
* ID of the record to retire.
|
|
1141
|
+
* @param {object} [options]
|
|
1142
|
+
* - targetStatus: "implemented"|"retired" — target status (required)
|
|
1143
|
+
* - rationale: string — why retiring (required)
|
|
1144
|
+
* - implementedByRef: string — ref when targetStatus="implemented" (required)
|
|
1145
|
+
* - supersededByRef: string — optional ref to superseding artifact
|
|
1146
|
+
* - decision: "apply"|"reject" — gate decision (default "apply")
|
|
1147
|
+
* - rejectReason: string — reason for rejection (required when decision="reject")
|
|
1148
|
+
* - agent: string — override agent name
|
|
1149
|
+
* - session_id: string — session id
|
|
1150
|
+
* - note: string — provenance note
|
|
1151
|
+
* @returns {Promise<{
|
|
1152
|
+
* recordId: string,
|
|
1153
|
+
* targetStatus: string,
|
|
1154
|
+
* decision: "apply"|"reject",
|
|
1155
|
+
* previousStatus: string,
|
|
1156
|
+
* proposerId: string,
|
|
1157
|
+
* telemetryEvents: object[]
|
|
1158
|
+
* }>}
|
|
1159
|
+
*/
|
|
1160
|
+
async retire(recordId, options = {}) {
|
|
1161
|
+
const events = [];
|
|
1162
|
+
const agent = options.agent || this._agent;
|
|
1163
|
+
|
|
1164
|
+
// ── Step: identify ─────────────────────────────────────────────────────
|
|
1165
|
+
if (!recordId || typeof recordId !== "string") {
|
|
1166
|
+
throw missingEvidenceError("retire: recordId must be a non-empty string");
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const targetStatus = options.targetStatus;
|
|
1170
|
+
if (targetStatus !== "implemented" && targetStatus !== "retired") {
|
|
1171
|
+
throw missingEvidenceError(
|
|
1172
|
+
'retire: options.targetStatus must be "implemented" or "retired"'
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if (!options.rationale || !options.rationale.trim()) {
|
|
1177
|
+
throw missingEvidenceError("retire: options.rationale is required");
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
if (targetStatus === "implemented" && (!options.implementedByRef || !options.implementedByRef.trim())) {
|
|
1181
|
+
throw missingEvidenceError(
|
|
1182
|
+
'retire: options.implementedByRef is required when targetStatus is "implemented"'
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const record = await this._store.get(recordId);
|
|
1187
|
+
if (!record) {
|
|
1188
|
+
throw missingEvidenceError(`retire: record not found: ${recordId}`);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const previousStatus = record.status || "active";
|
|
1192
|
+
|
|
1193
|
+
// Validate transition early (surface errors at identify-gate, not at apply-gate)
|
|
1194
|
+
const VALID_TRANSITIONS = {
|
|
1195
|
+
active: new Set(["implemented", "retired"]),
|
|
1196
|
+
implemented: new Set(["retired"]),
|
|
1197
|
+
retired: new Set(),
|
|
1198
|
+
};
|
|
1199
|
+
const allowed = VALID_TRANSITIONS[previousStatus] || new Set();
|
|
1200
|
+
if (!allowed.has(targetStatus)) {
|
|
1201
|
+
throw missingEvidenceError(
|
|
1202
|
+
`retire: invalid transition from "${previousStatus}" to "${targetStatus}"`
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// Emit identify gate entry
|
|
1207
|
+
const identifyGateIn = this._telemetry.emitGate("knowledge.retire", "identify-gate", {
|
|
1208
|
+
flow: "knowledge.retire",
|
|
1209
|
+
gate: "identify-gate",
|
|
1210
|
+
record_id: recordId,
|
|
1211
|
+
record_type: record.type,
|
|
1212
|
+
current_status: previousStatus,
|
|
1213
|
+
target_status: targetStatus,
|
|
1214
|
+
});
|
|
1215
|
+
events.push(identifyGateIn);
|
|
1216
|
+
|
|
1217
|
+
const identifyGateOut = this._telemetry.emitGateResult("knowledge.retire", "identify-gate", {
|
|
1218
|
+
record_id: recordId,
|
|
1219
|
+
record_type: record.type,
|
|
1220
|
+
current_status: previousStatus,
|
|
1221
|
+
target_status: targetStatus,
|
|
1222
|
+
transition_valid: true,
|
|
1223
|
+
});
|
|
1224
|
+
events.push(identifyGateOut);
|
|
1225
|
+
|
|
1226
|
+
// ── Step: propose-retirement ───────────────────────────────────────────
|
|
1227
|
+
// We reuse the store's propose op against the record itself.
|
|
1228
|
+
// The record acts as the "concept" target; a transient proposer raw record
|
|
1229
|
+
// carries the retirement proposal and proposes link.
|
|
1230
|
+
|
|
1231
|
+
const proposeGateIn = this._telemetry.emitGate(
|
|
1232
|
+
"knowledge.retire",
|
|
1233
|
+
"propose-retirement-gate",
|
|
1234
|
+
{
|
|
1235
|
+
flow: "knowledge.retire",
|
|
1236
|
+
gate: "propose-retirement-gate",
|
|
1237
|
+
record_id: recordId,
|
|
1238
|
+
target_status: targetStatus,
|
|
1239
|
+
rationale: options.rationale,
|
|
1240
|
+
}
|
|
1241
|
+
);
|
|
1242
|
+
events.push(proposeGateIn);
|
|
1243
|
+
|
|
1244
|
+
// Create a transient proposer record to hold the retirement proposal
|
|
1245
|
+
const proposerBody =
|
|
1246
|
+
`Retirement proposal for record ${recordId}.
|
|
1247
|
+
` +
|
|
1248
|
+
`Target status: ${targetStatus}
|
|
1249
|
+
` +
|
|
1250
|
+
`Rationale: ${options.rationale}
|
|
1251
|
+
` +
|
|
1252
|
+
(options.implementedByRef ? `Implemented-by: ${options.implementedByRef}
|
|
1253
|
+
` : "") +
|
|
1254
|
+
(options.supersededByRef ? `Superseded-by: ${options.supersededByRef}
|
|
1255
|
+
` : "");
|
|
1256
|
+
|
|
1257
|
+
const proposerId = await this._store.create({
|
|
1258
|
+
type: "raw",
|
|
1259
|
+
title: `Retirement proposal: ${record.title}`,
|
|
1260
|
+
body: proposerBody,
|
|
1261
|
+
category: record.category,
|
|
1262
|
+
provenance: {
|
|
1263
|
+
agent,
|
|
1264
|
+
note: `Retirement proposal for ${recordId}`,
|
|
1265
|
+
...(options.session_id ? { session_id: options.session_id } : {}),
|
|
1266
|
+
},
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
// Attach the proposal via the store's propose op (not direct mutation — AC1)
|
|
1270
|
+
await this._store.propose(recordId, proposerId, {
|
|
1271
|
+
agent,
|
|
1272
|
+
proposal: options.rationale,
|
|
1273
|
+
...(options.note ? { note: options.note } : {}),
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
const proposeGateOut = this._telemetry.emitGateResult(
|
|
1277
|
+
"knowledge.retire",
|
|
1278
|
+
"propose-retirement-gate",
|
|
1279
|
+
{
|
|
1280
|
+
record_id: recordId,
|
|
1281
|
+
proposer_id: proposerId,
|
|
1282
|
+
target_status: targetStatus,
|
|
1283
|
+
proposal_recorded: true,
|
|
1284
|
+
}
|
|
1285
|
+
);
|
|
1286
|
+
events.push(proposeGateOut);
|
|
1287
|
+
|
|
1288
|
+
// ── Step: evidence-gate ────────────────────────────────────────────────
|
|
1289
|
+
// Verify the proposal carries required evidence and the transition is valid.
|
|
1290
|
+
|
|
1291
|
+
const evidenceGateIn = this._telemetry.emitGate("knowledge.retire", "evidence-gate", {
|
|
1292
|
+
flow: "knowledge.retire",
|
|
1293
|
+
gate: "evidence-gate",
|
|
1294
|
+
record_id: recordId,
|
|
1295
|
+
proposer_id: proposerId,
|
|
1296
|
+
target_status: targetStatus,
|
|
1297
|
+
});
|
|
1298
|
+
events.push(evidenceGateIn);
|
|
1299
|
+
|
|
1300
|
+
// Enforce: proposer must have a "proposes" link to the record
|
|
1301
|
+
const { forward } = await this._store.getLinks(proposerId);
|
|
1302
|
+
const hasProposesLink = forward.some(
|
|
1303
|
+
(l) => l.target_id === recordId && l.kind === "proposes"
|
|
1304
|
+
);
|
|
1305
|
+
if (!hasProposesLink) {
|
|
1306
|
+
throw missingEvidenceError(
|
|
1307
|
+
`evidence-gate: proposer ${proposerId} must have a "proposes" link to record ${recordId}`
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Enforce: target record still exists
|
|
1312
|
+
const targetRecord = await this._store.get(recordId);
|
|
1313
|
+
if (!targetRecord) {
|
|
1314
|
+
throw missingEvidenceError(
|
|
1315
|
+
`evidence-gate: target record ${recordId} does not exist`
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const evidenceGateOut = this._telemetry.emitGateResult("knowledge.retire", "evidence-gate", {
|
|
1320
|
+
record_id: recordId,
|
|
1321
|
+
proposer_id: proposerId,
|
|
1322
|
+
target_status: targetStatus,
|
|
1323
|
+
proposes_link_verified: true,
|
|
1324
|
+
target_record_verified: true,
|
|
1325
|
+
});
|
|
1326
|
+
events.push(evidenceGateOut);
|
|
1327
|
+
|
|
1328
|
+
// ── Step: apply-or-reject ──────────────────────────────────────────────
|
|
1329
|
+
const decision = options.decision || "apply";
|
|
1330
|
+
|
|
1331
|
+
const applyGateIn = this._telemetry.emitGate("knowledge.retire", "apply-gate", {
|
|
1332
|
+
flow: "knowledge.retire",
|
|
1333
|
+
gate: "apply-gate",
|
|
1334
|
+
record_id: recordId,
|
|
1335
|
+
proposer_id: proposerId,
|
|
1336
|
+
target_status: targetStatus,
|
|
1337
|
+
decision,
|
|
1338
|
+
});
|
|
1339
|
+
events.push(applyGateIn);
|
|
1340
|
+
|
|
1341
|
+
if (decision === "apply") {
|
|
1342
|
+
// Apply via store retire op — transitions status, appends mutation log (AC1)
|
|
1343
|
+
await this._store.retire(recordId, targetStatus, {
|
|
1344
|
+
agent,
|
|
1345
|
+
rationale: options.rationale,
|
|
1346
|
+
...(options.implementedByRef ? { implementedByRef: options.implementedByRef } : {}),
|
|
1347
|
+
...(options.supersededByRef ? { supersededByRef: options.supersededByRef } : {}),
|
|
1348
|
+
...(options.note ? { note: options.note } : {}),
|
|
1349
|
+
});
|
|
1350
|
+
} else if (decision === "reject") {
|
|
1351
|
+
if (!options.rejectReason || !options.rejectReason.trim()) {
|
|
1352
|
+
throw missingEvidenceError(
|
|
1353
|
+
"apply-gate: options.rejectReason is required when decision=reject"
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
// Reject via store reject op — record status remains untouched (AC2)
|
|
1357
|
+
await this._store.reject(recordId, proposerId, {
|
|
1358
|
+
agent,
|
|
1359
|
+
reason: options.rejectReason,
|
|
1360
|
+
});
|
|
1361
|
+
} else {
|
|
1362
|
+
throw missingEvidenceError(
|
|
1363
|
+
`apply-gate: decision must be "apply" or "reject"; got: ${decision}`
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
const applyGateOut = this._telemetry.emitGateResult("knowledge.retire", "apply-gate", {
|
|
1368
|
+
record_id: recordId,
|
|
1369
|
+
proposer_id: proposerId,
|
|
1370
|
+
target_status: targetStatus,
|
|
1371
|
+
decision,
|
|
1372
|
+
previous_status: previousStatus,
|
|
1373
|
+
});
|
|
1374
|
+
events.push(applyGateOut);
|
|
1375
|
+
|
|
1376
|
+
return {
|
|
1377
|
+
recordId,
|
|
1378
|
+
targetStatus,
|
|
1379
|
+
decision,
|
|
1380
|
+
previousStatus,
|
|
1381
|
+
proposerId,
|
|
1382
|
+
telemetryEvents: events,
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1110
1386
|
}
|
|
1111
1387
|
|
|
1112
1388
|
// ---------------------------------------------------------------------------
|
|
@@ -1176,4 +1452,18 @@ export async function consolidate(
|
|
|
1176
1452
|
return runner.consolidate(snapshotIdOrTopic, consolidateOpts);
|
|
1177
1453
|
}
|
|
1178
1454
|
|
|
1455
|
+
/**
|
|
1456
|
+
* Module-level retire: creates an ephemeral runner using the provided store.
|
|
1457
|
+
*
|
|
1458
|
+
* @param {string} recordId
|
|
1459
|
+
* @param {object} options (merged into retire options + runner options)
|
|
1460
|
+
*/
|
|
1461
|
+
export async function retire(
|
|
1462
|
+
recordId,
|
|
1463
|
+
{ store, workspace, agent, sessionId, ...retireOpts } = {}
|
|
1464
|
+
) {
|
|
1465
|
+
const runner = new KnowledgeFlowRunner({ store, workspace, agent, sessionId });
|
|
1466
|
+
return runner.retire(recordId, retireOpts);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1179
1469
|
export default KnowledgeFlowRunner;
|