@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.
@@ -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@v4
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
- lines.push(`${pad} - ${firstKey}: ${yamlScalar(firstVal)}`);
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
- lines.push(`${pad} ${k}: ${yamlScalar(v)}`);
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
- if (concept.type !== "concept" && concept.type !== "snapshot")
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
- if (concept.type !== "concept" && concept.type !== "snapshot")
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
- if (concept.type !== "concept" && concept.type !== "snapshot")
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) => r.category === category || r.category.startsWith(`${category}.`)
860
+ (r) =>
861
+ (r.category === category || r.category.startsWith(`${category}.`)) &&
862
+ (includeRetired || (r.status || "active") !== "retired")
791
863
  );
792
864
  }
793
- return records.filter((r) => r.category === category);
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
- return this._allRecords().filter((r) => r.type === type);
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;