@kontourai/flow-agents 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +4 -0
- package/CHANGELOG.md +17 -0
- package/build/src/cli/assignment-provider.d.ts +45 -0
- package/build/src/cli/assignment-provider.js +97 -12
- package/build/src/cli/workflow-sidecar.d.ts +14 -4
- package/build/src/cli/workflow-sidecar.js +100 -10
- package/context/contracts/assignment-provider-contract.md +1 -1
- package/context/contracts/execution-contract.md +78 -0
- package/context/scripts/hooks/config-protection.js +11 -4
- package/context/scripts/hooks/stop-goal-fit.js +259 -4
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
- package/evals/ci/run-baseline.sh +2 -0
- package/evals/integration/test_checkpoint_signing.sh +4 -3
- package/evals/integration/test_gate_lockdown.sh +36 -0
- package/evals/integration/test_model_routing_escalation.sh +145 -0
- package/evals/integration/test_publish_delivery.sh +14 -6
- package/evals/integration/test_stop_hook_release.sh +552 -0
- package/evals/integration/test_trust_reconcile_negatives.sh +170 -0
- package/evals/run.sh +6 -0
- package/evals/static/test_model_routing_hints.sh +107 -0
- package/kits/builder/skills/builder-shape/SKILL.md +10 -0
- package/kits/builder/skills/deliver/SKILL.md +52 -11
- package/kits/builder/skills/design-probe/SKILL.md +10 -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/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 +56 -15
- package/kits/knowledge/adapters/flow-runner/index.js +912 -16
- package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
- package/kits/knowledge/adapters/shared/codec.js +124 -0
- package/kits/knowledge/docs/store-contract.md +405 -3
- 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 +36 -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/package.json +1 -1
- package/schemas/workflow-handoff.schema.json +6 -0
- package/scripts/ci/mint-attestation.js +33 -6
- package/scripts/ci/trust-reconcile.js +144 -26
- package/scripts/hooks/config-protection.js +11 -4
- package/scripts/hooks/stop-goal-fit.js +259 -4
- package/src/cli/assignment-provider.ts +110 -12
- package/src/cli/workflow-sidecar.ts +99 -10
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
* @module adapters/flow-runner
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
+
import * as fs from "node:fs";
|
|
35
36
|
import * as path from "node:path";
|
|
36
37
|
import { fileURLToPath } from "node:url";
|
|
37
38
|
import { KnowledgeTelemetry } from "./telemetry.js";
|
|
@@ -41,6 +42,8 @@ import {
|
|
|
41
42
|
isExactMatch,
|
|
42
43
|
isPossibleDuplicate,
|
|
43
44
|
} from "./entity-extractor.js";
|
|
45
|
+
// Record-carried effective expiry (issue #341, store-contract Addendum J).
|
|
46
|
+
import { effectiveExpiryMs } from "../shared/codec.js";
|
|
44
47
|
|
|
45
48
|
// ---------------------------------------------------------------------------
|
|
46
49
|
// Error helpers
|
|
@@ -456,6 +459,281 @@ export function defaultContradictionDetector(recordA, recordB) {
|
|
|
456
459
|
return null;
|
|
457
460
|
}
|
|
458
461
|
|
|
462
|
+
// ===========================================================================
|
|
463
|
+
// Inbound-reference integrity — doc→store citation extraction (#340)
|
|
464
|
+
//
|
|
465
|
+
// The hygiene audits (Addenda D–G) survey *records*; nothing resolves the
|
|
466
|
+
// references a human reads — the ids curated docs (NOW.md, strategy/*.md) cite
|
|
467
|
+
// *into* the store. When a store restructure severs those citations, every gate
|
|
468
|
+
// still reports PASS. This scan closes that: it extracts record citations from
|
|
469
|
+
// caller-configured doc globs and resolves each via the #339 identity path
|
|
470
|
+
// (exact id → slug alias → unambiguous ≥8-char prefix), failing closed on any
|
|
471
|
+
// unresolvable *definite* citation.
|
|
472
|
+
//
|
|
473
|
+
// Extraction precision — the commit-SHA problem
|
|
474
|
+
// ---------------------------------------------
|
|
475
|
+
// A bare 8-hex token is ambiguous: `12cc5573` is equally a record short-id and
|
|
476
|
+
// an abbreviated git commit SHA. Failing on every non-resolving 8-hex token
|
|
477
|
+
// would flag every SHA in prose (the gate cries wolf and gets disabled); never
|
|
478
|
+
// failing on them re-opens the exact rot this issue closes. We resolve the
|
|
479
|
+
// tension by recognizing two tiers (store-contract.md Addendum I):
|
|
480
|
+
//
|
|
481
|
+
// DEFINITE citation forms — a token is unmistakably a citation, so it is
|
|
482
|
+
// resolved regardless of whether it currently resolves, and a miss FAILS:
|
|
483
|
+
// • full UUID (8-4-4-4-12 hex) — the hyphenated shape never collides with a
|
|
484
|
+
// git SHA (40/abbrev hex, no hyphens) or prose, so a bare UUID qualifies;
|
|
485
|
+
// • a configured citation marker (default `rec:` / `record:`) wrapping a
|
|
486
|
+
// short-id or slug, e.g. `rec:12cc5573`, `record:decision.strategy/gtm`;
|
|
487
|
+
// • a wikilink `[[token]]` (the kit's own link syntax).
|
|
488
|
+
//
|
|
489
|
+
// CANDIDATE bare short-ids — a standalone ≥8-hex token with no citation form
|
|
490
|
+
// is included in the index ONLY when it already resolves to a record. A
|
|
491
|
+
// non-resolving bare hex is indistinguishable from a commit SHA, so it is
|
|
492
|
+
// IGNORED (never failed). This is the deliberate, documented miss: a *broken*
|
|
493
|
+
// bare-hex short-id (no marker/wikilink) is invisible — cite short-ids in a
|
|
494
|
+
// marker/wikilink form to get fail-closed protection. Full-UUID citations are
|
|
495
|
+
// protected with zero configuration.
|
|
496
|
+
//
|
|
497
|
+
// This is a precision-over-recall choice: zero false positives on commit
|
|
498
|
+
// hashes, at the cost of not catching bare-hex short-id rot until the doc
|
|
499
|
+
// adopts a citation form (an ops-repo house-style change — a non-goal here).
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
// Default marker prefixes that opt a following short-id/slug into DEFINITE
|
|
503
|
+
// citation status. Caller-overridable via `options.markers`.
|
|
504
|
+
export const DEFAULT_CITATION_MARKERS = ["rec:", "record:"];
|
|
505
|
+
|
|
506
|
+
// Canonical UUID (8-4-4-4-12 hex). Self-identifying — always a definite citation.
|
|
507
|
+
const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
|
|
508
|
+
|
|
509
|
+
// Wikilink target: [[token]] or [[token|label]] (label discarded).
|
|
510
|
+
const WIKILINK_CITE_RE = /\[\[([^\]|\n]+?)(?:\|[^\]\n]*)?\]\]/g;
|
|
511
|
+
|
|
512
|
+
// A standalone hex run 8..40 chars long — a bare short-id / SHA candidate.
|
|
513
|
+
const BARE_HEX_RE = /(?<![0-9a-z-])[0-9a-f]{8,40}(?![0-9a-z-])/gi;
|
|
514
|
+
|
|
515
|
+
// The token a marker may wrap: a slug/short-id character run (hex, slug chars).
|
|
516
|
+
// Kept permissive — #339 resolution semantics decide what actually resolves.
|
|
517
|
+
const MARKER_TOKEN_CHARS = "[A-Za-z0-9][A-Za-z0-9._/-]*";
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Translate a caller glob (`NOW.md`, `strategy/*.md`, `docs/**\/*.md`) into an
|
|
521
|
+
* anchored RegExp over POSIX-style relative paths. `*` matches within a path
|
|
522
|
+
* segment; `**` spans segments; `?` matches one non-slash char. Zero-dep.
|
|
523
|
+
*/
|
|
524
|
+
function globToRegExp(glob) {
|
|
525
|
+
let re = "";
|
|
526
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
527
|
+
const c = glob[i];
|
|
528
|
+
if (c === "*") {
|
|
529
|
+
if (glob[i + 1] === "*") {
|
|
530
|
+
// `**` — span zero or more path segments.
|
|
531
|
+
if (glob[i + 2] === "/") { re += "(?:.*/)?"; i += 2; }
|
|
532
|
+
else { re += ".*"; i += 1; }
|
|
533
|
+
} else {
|
|
534
|
+
re += "[^/]*";
|
|
535
|
+
}
|
|
536
|
+
} else if (c === "?") {
|
|
537
|
+
re += "[^/]";
|
|
538
|
+
} else if ("\\^$.|+()[]{}".includes(c)) {
|
|
539
|
+
re += `\\${c}`;
|
|
540
|
+
} else {
|
|
541
|
+
re += c;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return new RegExp(`^${re}$`);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Collect files under `rootDir` matching any of `globs`, returned as POSIX-style
|
|
549
|
+
* paths relative to `rootDir`, sorted for determinism. Walks the tree once,
|
|
550
|
+
* skipping VCS/dependency noise (`.git`, `node_modules`). A glob with no
|
|
551
|
+
* wildcard is honoured even if the walk would skip its directory.
|
|
552
|
+
*/
|
|
553
|
+
function collectDocs(rootDir, globs) {
|
|
554
|
+
const matchers = globs.map(globToRegExp);
|
|
555
|
+
const out = new Set();
|
|
556
|
+
|
|
557
|
+
const SKIP = new Set([".git", "node_modules"]);
|
|
558
|
+
const walk = (absDir, relDir) => {
|
|
559
|
+
let entries;
|
|
560
|
+
try {
|
|
561
|
+
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
562
|
+
} catch {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
for (const entry of entries) {
|
|
566
|
+
const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
567
|
+
if (entry.isDirectory()) {
|
|
568
|
+
if (SKIP.has(entry.name)) continue;
|
|
569
|
+
walk(path.join(absDir, entry.name), rel);
|
|
570
|
+
} else if (entry.isFile()) {
|
|
571
|
+
if (matchers.some((m) => m.test(rel))) out.add(rel);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
walk(rootDir, "");
|
|
576
|
+
|
|
577
|
+
// Also honour literal (wildcard-free) globs directly — covers a file the walk
|
|
578
|
+
// might not reach and keeps single-file configs O(1).
|
|
579
|
+
for (const glob of globs) {
|
|
580
|
+
if (!/[*?]/.test(glob)) {
|
|
581
|
+
const abs = path.join(rootDir, glob);
|
|
582
|
+
try {
|
|
583
|
+
if (fs.statSync(abs).isFile()) out.add(glob.split(path.sep).join("/"));
|
|
584
|
+
} catch { /* not present — reported as an empty scan for that glob */ }
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return [...out].sort();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Extract DEFINITE citations (uuid | marker | wikilink) plus CANDIDATE bare
|
|
592
|
+
* short-ids from one line. Returns citation descriptors with 1-based line/column
|
|
593
|
+
* so failures can name the exact location. Overlapping spans are claimed by the
|
|
594
|
+
* higher-priority form first (marker/wikilink/uuid before bare) so a token is
|
|
595
|
+
* never double-counted.
|
|
596
|
+
*
|
|
597
|
+
* @param {string} line
|
|
598
|
+
* @param {number} lineNo 1-based line number
|
|
599
|
+
* @param {string[]} markers
|
|
600
|
+
* @returns {Array<{ line:number, column:number, token:string, form:string }>}
|
|
601
|
+
*/
|
|
602
|
+
function extractLineCitations(line, lineNo, markers) {
|
|
603
|
+
const found = [];
|
|
604
|
+
const claimed = []; // [start,end) offsets already consumed by a higher-priority form
|
|
605
|
+
|
|
606
|
+
const overlaps = (start, end) =>
|
|
607
|
+
claimed.some(([s, e]) => start < e && end > s);
|
|
608
|
+
const claim = (start, end, token, form) => {
|
|
609
|
+
if (overlaps(start, end)) return;
|
|
610
|
+
claimed.push([start, end]);
|
|
611
|
+
found.push({ line: lineNo, column: start + 1, token: token.trim(), form });
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
// 1. Wikilinks (highest priority — explicit kit link syntax).
|
|
615
|
+
for (const m of line.matchAll(WIKILINK_CITE_RE)) {
|
|
616
|
+
const token = m[1];
|
|
617
|
+
if (token && token.trim()) claim(m.index, m.index + m[0].length, token, "wikilink");
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// 2. Markers (rec: / record: …) wrapping a slug/short-id.
|
|
621
|
+
for (const marker of markers) {
|
|
622
|
+
const markerRe = new RegExp(
|
|
623
|
+
`${marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(${MARKER_TOKEN_CHARS})`,
|
|
624
|
+
"g"
|
|
625
|
+
);
|
|
626
|
+
for (const m of line.matchAll(markerRe)) {
|
|
627
|
+
const token = m[1];
|
|
628
|
+
const tokenStart = m.index + m[0].length - token.length;
|
|
629
|
+
claim(tokenStart, m.index + m[0].length, token, "marker");
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// 3. Full UUIDs (self-identifying; also captures a bare UUID not in a form).
|
|
634
|
+
for (const m of line.matchAll(UUID_RE)) {
|
|
635
|
+
claim(m.index, m.index + m[0].length, m[0], "uuid");
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// 4. Bare hex short-id CANDIDATES — resolution decides inclusion later.
|
|
639
|
+
for (const m of line.matchAll(BARE_HEX_RE)) {
|
|
640
|
+
claim(m.index, m.index + m[0].length, m[0], "bare");
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return found;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Resolve a citation token against the store via the #339 identity path.
|
|
648
|
+
* `store.get(token)` accepts an exact id, slug alias, or unambiguous ≥8-char
|
|
649
|
+
* prefix; returns `null` when it resolves to nothing and throws `AMBIGUOUS_ID`
|
|
650
|
+
* when a prefix collides. Both non-resolution modes are reported (never thrown
|
|
651
|
+
* out of the scan) so one bad token cannot abort the whole check.
|
|
652
|
+
*
|
|
653
|
+
* @returns {Promise<{ resolved:boolean, recordId:string|null, reason?:string, matches?:string[] }>}
|
|
654
|
+
*/
|
|
655
|
+
async function resolveCitation(store, token) {
|
|
656
|
+
let record;
|
|
657
|
+
try {
|
|
658
|
+
record = await store.get(token);
|
|
659
|
+
} catch (err) {
|
|
660
|
+
if (err && err.code === "AMBIGUOUS_ID") {
|
|
661
|
+
return { resolved: false, recordId: null, reason: "ambiguous", matches: err.matches };
|
|
662
|
+
}
|
|
663
|
+
throw err;
|
|
664
|
+
}
|
|
665
|
+
if (record && record.id) return { resolved: true, recordId: record.id };
|
|
666
|
+
return { resolved: false, recordId: null, reason: "not-found" };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ---------------------------------------------------------------------------
|
|
670
|
+
// Incremental consolidation helpers (knowledge.consolidate append-mode — #343)
|
|
671
|
+
//
|
|
672
|
+
// Store-contract Addendum L. The whole-body consolidate contract makes the
|
|
673
|
+
// caller author the ENTIRE updated snapshot body just to add one decision —
|
|
674
|
+
// heavy enough that real sessions skip it and the living decision snapshot
|
|
675
|
+
// stops living (ops field report record 0e439c57). Append-mode lets the caller
|
|
676
|
+
// supply only the new compiled contribution (the "appended entry"); the runner
|
|
677
|
+
// regenerates the snapshot body from the snapshot's contributing records
|
|
678
|
+
// (Addendum A.3 — provenance.source_ids / kind:"source" links) plus the new
|
|
679
|
+
// entry. Because the body is regenerated from the CURRENT live snapshot's
|
|
680
|
+
// records every time, two sequential consolidations from different sessions
|
|
681
|
+
// never lose an entry (R4): each session re-reads the live snapshot's source
|
|
682
|
+
// set and appends to it, rather than overwriting a stale whole body.
|
|
683
|
+
//
|
|
684
|
+
// The whole-body path (options.proposedBody) is unchanged and remains valid.
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Normalize the append-mode input from consolidate options. Returns
|
|
689
|
+
* `{ recordId }` when append-mode is requested, or `null` for whole-body mode.
|
|
690
|
+
* Accepts either `appendEntryRecordId: "<id>"` or `appendEntry: { recordId }`.
|
|
691
|
+
*
|
|
692
|
+
* @param {object} [options]
|
|
693
|
+
* @returns {{ recordId: string } | null}
|
|
694
|
+
*/
|
|
695
|
+
export function normalizeConsolidateAppend(options = {}) {
|
|
696
|
+
if (typeof options.appendEntryRecordId === "string" && options.appendEntryRecordId.trim()) {
|
|
697
|
+
return { recordId: options.appendEntryRecordId.trim() };
|
|
698
|
+
}
|
|
699
|
+
const ae = options.appendEntry;
|
|
700
|
+
if (ae && typeof ae === "object" && typeof ae.recordId === "string" && ae.recordId.trim()) {
|
|
701
|
+
return { recordId: ae.recordId.trim() };
|
|
702
|
+
}
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Default entry renderer for the decision-log snapshot shape: one section per
|
|
708
|
+
* contributing compiled record — a level-2 heading (the record title) followed
|
|
709
|
+
* by the record body. Pluggable via `options.entryRenderer`.
|
|
710
|
+
*
|
|
711
|
+
* @param {object} record a compiled record ({ id, title, body, ... })
|
|
712
|
+
* @returns {string}
|
|
713
|
+
*/
|
|
714
|
+
export function defaultConsolidateEntryRenderer(record) {
|
|
715
|
+
const title = (record.title || record.id || "").trim();
|
|
716
|
+
const body = String(record.body || "").trim();
|
|
717
|
+
return `## ${title}\n\n${body}`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Regenerate a snapshot body from its contributing records (in order), each
|
|
722
|
+
* rendered as one entry and joined by a horizontal rule. An optional `header`
|
|
723
|
+
* is prepended. This IS the append-mode body: prior entries (from the prior
|
|
724
|
+
* snapshot's source records) followed by the newly appended entry.
|
|
725
|
+
*
|
|
726
|
+
* @param {object[]} records
|
|
727
|
+
* @param {{ header?: string, entryRenderer?: (r: object) => string }} [options]
|
|
728
|
+
* @returns {string}
|
|
729
|
+
*/
|
|
730
|
+
export function regenerateSnapshotBodyFromRecords(records, options = {}) {
|
|
731
|
+
const render = options.entryRenderer || defaultConsolidateEntryRenderer;
|
|
732
|
+
const joined = records.map((r) => render(r)).join("\n\n---\n\n");
|
|
733
|
+
const header = options.header && String(options.header).trim();
|
|
734
|
+
return header ? `${header}\n\n${joined}` : joined;
|
|
735
|
+
}
|
|
736
|
+
|
|
459
737
|
// ---------------------------------------------------------------------------
|
|
460
738
|
// KnowledgeFlowRunner
|
|
461
739
|
// ---------------------------------------------------------------------------
|
|
@@ -1046,7 +1324,21 @@ export class KnowledgeFlowRunner {
|
|
|
1046
1324
|
* - object topicSelector: { topic } — snapshot located by topic tag.
|
|
1047
1325
|
* If no snapshot exists for the topic yet, a new one will be created on apply.
|
|
1048
1326
|
* @param {object} [options]
|
|
1049
|
-
*
|
|
1327
|
+
* Body input — supply EXACTLY ONE of these two (mutually exclusive):
|
|
1328
|
+
* - proposedBody: string — whole-body mode (unchanged): the full
|
|
1329
|
+
* replacement snapshot body.
|
|
1330
|
+
* - appendEntryRecordId: string — append mode (#343 — Addendum L): the id of
|
|
1331
|
+
* the new compiled record to append. The body
|
|
1332
|
+
* is regenerated from the current snapshot's
|
|
1333
|
+
* contributing records (provenance.source_ids
|
|
1334
|
+
* / kind:"source" links) plus this new entry,
|
|
1335
|
+
* so the caller does NOT author the full body.
|
|
1336
|
+
* `appendEntry: { recordId }` is also accepted.
|
|
1337
|
+
* - header: string — (append mode) optional body header prepended
|
|
1338
|
+
* before the rendered entries.
|
|
1339
|
+
* - entryRenderer: fn — (append mode) pluggable `(record) => string`
|
|
1340
|
+
* to render one entry; defaults to a decision-
|
|
1341
|
+
* log section (`## <title>\n\n<body>`).
|
|
1050
1342
|
* - rationale: string — reason for the consolidation (required for apply)
|
|
1051
1343
|
* - decision: "apply"|"reject" — gate decision (default "apply")
|
|
1052
1344
|
* - rejectReason: string — reason for rejection (required when decision="reject")
|
|
@@ -1128,6 +1420,88 @@ export class KnowledgeFlowRunner {
|
|
|
1128
1420
|
);
|
|
1129
1421
|
}
|
|
1130
1422
|
|
|
1423
|
+
// ── Mode: whole-body vs. incremental append (#343 — Addendum L) ─────────
|
|
1424
|
+
// Whole-body mode (unchanged): caller supplies options.proposedBody, the
|
|
1425
|
+
// full replacement snapshot body. Append mode: caller supplies the new
|
|
1426
|
+
// compiled contribution (appendEntryRecordId / appendEntry.recordId) and the
|
|
1427
|
+
// runner regenerates the body from the CURRENT snapshot's contributing
|
|
1428
|
+
// records plus the new entry. The two are mutually exclusive.
|
|
1429
|
+
const append = normalizeConsolidateAppend(options);
|
|
1430
|
+
const isIncremental = append !== null;
|
|
1431
|
+
|
|
1432
|
+
if (isIncremental && typeof options.proposedBody === "string" && options.proposedBody.trim()) {
|
|
1433
|
+
throw missingEvidenceError(
|
|
1434
|
+
"consolidate: options.proposedBody and append-mode (appendEntryRecordId/appendEntry) " +
|
|
1435
|
+
"are mutually exclusive — supply one or the other, not both"
|
|
1436
|
+
);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// Computed in append mode; drive cluster + body below.
|
|
1440
|
+
let incrementalCluster = null;
|
|
1441
|
+
let incrementalBody = null;
|
|
1442
|
+
|
|
1443
|
+
if (isIncremental) {
|
|
1444
|
+
// The appended entry MUST be an existing compiled record — it is the new
|
|
1445
|
+
// contribution whose provenance the regenerated snapshot links (R3).
|
|
1446
|
+
const newRecord = await this._store.get(append.recordId);
|
|
1447
|
+
if (!newRecord) {
|
|
1448
|
+
throw missingEvidenceError(
|
|
1449
|
+
`consolidate: appended entry record not found: ${append.recordId}`
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1452
|
+
if (newRecord.type !== "compiled") {
|
|
1453
|
+
throw missingEvidenceError(
|
|
1454
|
+
`consolidate: appended entry record ${append.recordId} is type="${newRecord.type}", ` +
|
|
1455
|
+
`expected "compiled"`
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// Prior contributing records come from the CURRENT snapshot's provenance
|
|
1460
|
+
// (Addendum A.3): source_ids first, falling back to kind:"source" links.
|
|
1461
|
+
// Resolving the live snapshot per-call is what makes sequential
|
|
1462
|
+
// cross-session appends lossless (R4).
|
|
1463
|
+
let priorSourceIds = [];
|
|
1464
|
+
if (existingSnapshot) {
|
|
1465
|
+
const provIds = Array.isArray(existingSnapshot.provenance?.source_ids)
|
|
1466
|
+
? existingSnapshot.provenance.source_ids
|
|
1467
|
+
: [];
|
|
1468
|
+
if (provIds.length > 0) {
|
|
1469
|
+
priorSourceIds = provIds;
|
|
1470
|
+
} else {
|
|
1471
|
+
const { forward } = await this._store.getLinks(existingSnapshot.id);
|
|
1472
|
+
priorSourceIds = (forward || [])
|
|
1473
|
+
.filter((l) => l.kind === "source")
|
|
1474
|
+
.map((l) => l.target_id);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// New source set = prior sources ++ new entry, de-duplicated with order
|
|
1479
|
+
// preserved (idempotent if the same entry is appended twice).
|
|
1480
|
+
const orderedIds = [];
|
|
1481
|
+
const seen = new Set();
|
|
1482
|
+
for (const sid of [...priorSourceIds, append.recordId]) {
|
|
1483
|
+
if (!seen.has(sid)) {
|
|
1484
|
+
seen.add(sid);
|
|
1485
|
+
orderedIds.push(sid);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// Regenerate the body from the resolved records, skipping any prior
|
|
1490
|
+
// source id that no longer resolves (deleted upstream is impossible under
|
|
1491
|
+
// supersede-not-delete, but be defensive). The new entry always resolves.
|
|
1492
|
+
const entryRecords = [];
|
|
1493
|
+
for (const sid of orderedIds) {
|
|
1494
|
+
const rec = await this._store.get(sid);
|
|
1495
|
+
if (rec) entryRecords.push(rec);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
incrementalCluster = entryRecords.map((r) => r.id);
|
|
1499
|
+
incrementalBody = regenerateSnapshotBodyFromRecords(entryRecords, {
|
|
1500
|
+
header: options.header,
|
|
1501
|
+
entryRenderer: options.entryRenderer,
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1131
1505
|
// ── Gate: related-event-gate ───────────────────────────────────────────
|
|
1132
1506
|
// Run similarity detection to find compiled records related to the topic.
|
|
1133
1507
|
// We use a concept-like proxy to run the similarity detector: a synthetic
|
|
@@ -1154,9 +1528,19 @@ export class KnowledgeFlowRunner {
|
|
|
1154
1528
|
);
|
|
1155
1529
|
events.push(relatedGateIn);
|
|
1156
1530
|
|
|
1157
|
-
//
|
|
1158
|
-
|
|
1159
|
-
|
|
1531
|
+
// Determine the cluster of contributing compiled records. In append mode
|
|
1532
|
+
// (#343) the cluster is the regenerated source set (prior sources ++ the new
|
|
1533
|
+
// entry) — the similarity detector is bypassed because the source set is
|
|
1534
|
+
// authoritative and grows by exactly the appended entry, which is precisely
|
|
1535
|
+
// what makes sequential appends lossless. In whole-body mode the detector
|
|
1536
|
+
// discovers the related compiled records as before.
|
|
1537
|
+
let cluster;
|
|
1538
|
+
if (isIncremental) {
|
|
1539
|
+
cluster = incrementalCluster;
|
|
1540
|
+
} else {
|
|
1541
|
+
const allCompiled = await this._store.listByType("compiled");
|
|
1542
|
+
cluster = await detector(snapshotProxy, allCompiled, this._store);
|
|
1543
|
+
}
|
|
1160
1544
|
|
|
1161
1545
|
if (!Array.isArray(cluster) || cluster.length === 0) {
|
|
1162
1546
|
throw missingEvidenceError(
|
|
@@ -1184,9 +1568,12 @@ export class KnowledgeFlowRunner {
|
|
|
1184
1568
|
// When the snapshot does not exist yet (first consolidation for the topic),
|
|
1185
1569
|
// we create a placeholder snapshot record to attach the proposal to.
|
|
1186
1570
|
|
|
1187
|
-
|
|
1571
|
+
// The effective body applied to the snapshot: the regenerated body in
|
|
1572
|
+
// append mode, or the caller-supplied full body in whole-body mode.
|
|
1573
|
+
if (!isIncremental && (!options.proposedBody || !options.proposedBody.trim())) {
|
|
1188
1574
|
throw missingEvidenceError("consolidate: options.proposedBody is required");
|
|
1189
1575
|
}
|
|
1576
|
+
const effectiveBody = isIncremental ? incrementalBody : options.proposedBody;
|
|
1190
1577
|
|
|
1191
1578
|
// Ensure a snapshot record exists to propose against
|
|
1192
1579
|
if (!snapshotId) {
|
|
@@ -1229,7 +1616,7 @@ export class KnowledgeFlowRunner {
|
|
|
1229
1616
|
// the store's propose method with the snapshot's id.
|
|
1230
1617
|
await this._store.propose(snapshotId, proposerId, {
|
|
1231
1618
|
agent,
|
|
1232
|
-
proposal:
|
|
1619
|
+
proposal: effectiveBody,
|
|
1233
1620
|
...(options.note ? { note: options.note } : {}),
|
|
1234
1621
|
});
|
|
1235
1622
|
|
|
@@ -1355,7 +1742,7 @@ export class KnowledgeFlowRunner {
|
|
|
1355
1742
|
newSnapshotId = await this._store.create({
|
|
1356
1743
|
type: "snapshot",
|
|
1357
1744
|
title: `Snapshot: ${topic}`,
|
|
1358
|
-
body:
|
|
1745
|
+
body: effectiveBody,
|
|
1359
1746
|
category: existingSnapshot?.category || category || "general",
|
|
1360
1747
|
tags: [topicTag],
|
|
1361
1748
|
links: sourceLinks,
|
|
@@ -1756,6 +2143,449 @@ export class KnowledgeFlowRunner {
|
|
|
1756
2143
|
}
|
|
1757
2144
|
|
|
1758
2145
|
|
|
2146
|
+
// -------------------------------------------------------------------------
|
|
2147
|
+
// knowledge.check-inbound-references flow (#340)
|
|
2148
|
+
// Steps: scan-gate → resolve-gate → done
|
|
2149
|
+
// Gate: resolve-gate — every DEFINITE doc→store citation resolves via the
|
|
2150
|
+
// #339 identity path. Any unresolvable definite citation flips ok to
|
|
2151
|
+
// false (fail closed) and is reported with doc path + line/column +
|
|
2152
|
+
// token. Bare hex candidates are included only when they resolve
|
|
2153
|
+
// (commit-SHA safety), never contributing a failure.
|
|
2154
|
+
//
|
|
2155
|
+
// READ-ONLY: reads doc files + the store's query surface only; mutates no
|
|
2156
|
+
// record and appends no mutation-log entry (same invariant as the Addenda D–G
|
|
2157
|
+
// audits). OPT-IN: zero configured globs is a no-op pass.
|
|
2158
|
+
// -------------------------------------------------------------------------
|
|
2159
|
+
|
|
2160
|
+
/**
|
|
2161
|
+
* Scan caller-configured doc globs for record citations and resolve each
|
|
2162
|
+
* against the store, failing closed on any unresolvable definite citation.
|
|
2163
|
+
*
|
|
2164
|
+
* Citation forms (see the module header + store-contract.md Addendum I):
|
|
2165
|
+
* - full UUID (bare or wrapped) → definite; miss FAILS
|
|
2166
|
+
* - `<marker>token` (default `rec:`/`record:`) → definite; miss FAILS
|
|
2167
|
+
* - `[[token]]` wikilink → definite; miss FAILS
|
|
2168
|
+
* - bare ≥8-hex short-id → candidate; included only if it
|
|
2169
|
+
* currently resolves (a non-resolving bare hex is treated as prose/commit
|
|
2170
|
+
* SHA and ignored — the documented miss).
|
|
2171
|
+
*
|
|
2172
|
+
* @param {object} [options]
|
|
2173
|
+
* - docGlobs: string[] — globs (relative to docsRoot) to scan; [] = no-op pass
|
|
2174
|
+
* - docsRoot: string — root the globs resolve against (default: workspace, then cwd)
|
|
2175
|
+
* - markers: string[] — citation marker prefixes (default DEFAULT_CITATION_MARKERS)
|
|
2176
|
+
* - agent: string — override agent name
|
|
2177
|
+
* @returns {Promise<{
|
|
2178
|
+
* ok: boolean,
|
|
2179
|
+
* scanned: string[],
|
|
2180
|
+
* citations: Array<{ doc:string, line:number, column:number, token:string, form:string, resolved:boolean, recordId:string|null, reason?:string }>,
|
|
2181
|
+
* unresolved: Array<{ doc:string, line:number, column:number, token:string, form:string, reason:string }>,
|
|
2182
|
+
* byDoc: Record<string, Array<{ token:string, form:string, resolved:boolean, recordId:string|null, line:number, column:number }>>,
|
|
2183
|
+
* byRecord: Record<string, Array<{ doc:string, line:number, column:number, token:string, form:string }>>,
|
|
2184
|
+
* telemetryEvents: object[]
|
|
2185
|
+
* }>}
|
|
2186
|
+
*/
|
|
2187
|
+
async checkInboundReferences(options = {}) {
|
|
2188
|
+
const events = [];
|
|
2189
|
+
const docGlobs = Array.isArray(options.docGlobs) ? options.docGlobs : [];
|
|
2190
|
+
const docsRoot = path.resolve(options.docsRoot || options.workspace || process.cwd());
|
|
2191
|
+
const markers = Array.isArray(options.markers) && options.markers.length
|
|
2192
|
+
? options.markers
|
|
2193
|
+
: DEFAULT_CITATION_MARKERS;
|
|
2194
|
+
|
|
2195
|
+
// ── Step: scan-gate ────────────────────────────────────────────────────
|
|
2196
|
+
const scanGateIn = this._telemetry.emitGate(
|
|
2197
|
+
"knowledge.check-inbound-references",
|
|
2198
|
+
"scan-gate",
|
|
2199
|
+
{
|
|
2200
|
+
flow: "knowledge.check-inbound-references",
|
|
2201
|
+
gate: "scan-gate",
|
|
2202
|
+
docs_root: docsRoot,
|
|
2203
|
+
doc_globs: docGlobs,
|
|
2204
|
+
markers,
|
|
2205
|
+
}
|
|
2206
|
+
);
|
|
2207
|
+
events.push(scanGateIn);
|
|
2208
|
+
|
|
2209
|
+
// OPT-IN no-op: no globs configured → nothing to check (consistent with the
|
|
2210
|
+
// Addenda D–G opt-in convention). Returns an empty, PASSING index — never an
|
|
2211
|
+
// empty success that masks an unconfigured gate for a definite citation.
|
|
2212
|
+
if (docGlobs.length === 0) {
|
|
2213
|
+
const scanGateOutEmpty = this._telemetry.emitGateResult(
|
|
2214
|
+
"knowledge.check-inbound-references",
|
|
2215
|
+
"scan-gate",
|
|
2216
|
+
{ scanned: 0, opt_out: true }
|
|
2217
|
+
);
|
|
2218
|
+
events.push(scanGateOutEmpty);
|
|
2219
|
+
return {
|
|
2220
|
+
ok: true,
|
|
2221
|
+
scanned: [],
|
|
2222
|
+
citations: [],
|
|
2223
|
+
unresolved: [],
|
|
2224
|
+
byDoc: {},
|
|
2225
|
+
byRecord: {},
|
|
2226
|
+
telemetryEvents: events,
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
const scanned = collectDocs(docsRoot, docGlobs);
|
|
2231
|
+
|
|
2232
|
+
// Extract every candidate citation from every scanned doc.
|
|
2233
|
+
const rawCitations = [];
|
|
2234
|
+
for (const doc of scanned) {
|
|
2235
|
+
const text = fs.readFileSync(path.join(docsRoot, doc), "utf8");
|
|
2236
|
+
const lines = text.split("\n");
|
|
2237
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
2238
|
+
for (const c of extractLineCitations(lines[i], i + 1, markers)) {
|
|
2239
|
+
rawCitations.push({ doc, ...c });
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
const scanGateOut = this._telemetry.emitGateResult(
|
|
2245
|
+
"knowledge.check-inbound-references",
|
|
2246
|
+
"scan-gate",
|
|
2247
|
+
{ scanned: scanned.length, candidates: rawCitations.length }
|
|
2248
|
+
);
|
|
2249
|
+
events.push(scanGateOut);
|
|
2250
|
+
|
|
2251
|
+
// ── Step: resolve-gate ─────────────────────────────────────────────────
|
|
2252
|
+
const resolveGateIn = this._telemetry.emitGate(
|
|
2253
|
+
"knowledge.check-inbound-references",
|
|
2254
|
+
"resolve-gate",
|
|
2255
|
+
{
|
|
2256
|
+
flow: "knowledge.check-inbound-references",
|
|
2257
|
+
gate: "resolve-gate",
|
|
2258
|
+
candidates: rawCitations.length,
|
|
2259
|
+
}
|
|
2260
|
+
);
|
|
2261
|
+
events.push(resolveGateIn);
|
|
2262
|
+
|
|
2263
|
+
const citations = [];
|
|
2264
|
+
const unresolved = [];
|
|
2265
|
+
const byDoc = {};
|
|
2266
|
+
const byRecord = {};
|
|
2267
|
+
|
|
2268
|
+
for (const c of rawCitations) {
|
|
2269
|
+
const { resolved, recordId, reason, matches } = await resolveCitation(this._store, c.token);
|
|
2270
|
+
|
|
2271
|
+
// A bare candidate that does NOT resolve is prose / a commit SHA — drop it
|
|
2272
|
+
// entirely (never indexed, never failed). Every other case is recorded.
|
|
2273
|
+
if (c.form === "bare" && !resolved) continue;
|
|
2274
|
+
|
|
2275
|
+
const entry = {
|
|
2276
|
+
doc: c.doc,
|
|
2277
|
+
line: c.line,
|
|
2278
|
+
column: c.column,
|
|
2279
|
+
token: c.token,
|
|
2280
|
+
form: c.form,
|
|
2281
|
+
resolved,
|
|
2282
|
+
recordId,
|
|
2283
|
+
...(reason ? { reason } : {}),
|
|
2284
|
+
...(matches ? { matches } : {}),
|
|
2285
|
+
};
|
|
2286
|
+
citations.push(entry);
|
|
2287
|
+
|
|
2288
|
+
(byDoc[c.doc] ||= []).push({
|
|
2289
|
+
token: c.token,
|
|
2290
|
+
form: c.form,
|
|
2291
|
+
resolved,
|
|
2292
|
+
recordId,
|
|
2293
|
+
line: c.line,
|
|
2294
|
+
column: c.column,
|
|
2295
|
+
});
|
|
2296
|
+
|
|
2297
|
+
if (resolved) {
|
|
2298
|
+
(byRecord[recordId] ||= []).push({
|
|
2299
|
+
doc: c.doc,
|
|
2300
|
+
line: c.line,
|
|
2301
|
+
column: c.column,
|
|
2302
|
+
token: c.token,
|
|
2303
|
+
form: c.form,
|
|
2304
|
+
});
|
|
2305
|
+
} else {
|
|
2306
|
+
// Definite citation (uuid/marker/wikilink) that does not resolve → FAIL.
|
|
2307
|
+
unresolved.push({
|
|
2308
|
+
doc: c.doc,
|
|
2309
|
+
line: c.line,
|
|
2310
|
+
column: c.column,
|
|
2311
|
+
token: c.token,
|
|
2312
|
+
form: c.form,
|
|
2313
|
+
reason: reason || "not-found",
|
|
2314
|
+
...(matches ? { matches } : {}),
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
// Ensure every scanned doc appears in byDoc even when it cites nothing, so a
|
|
2320
|
+
// downstream consumer can distinguish "scanned, no citations" from "not scanned".
|
|
2321
|
+
for (const doc of scanned) byDoc[doc] ||= [];
|
|
2322
|
+
|
|
2323
|
+
const ok = unresolved.length === 0;
|
|
2324
|
+
|
|
2325
|
+
const resolveGateOut = this._telemetry.emitGateResult(
|
|
2326
|
+
"knowledge.check-inbound-references",
|
|
2327
|
+
"resolve-gate",
|
|
2328
|
+
{
|
|
2329
|
+
ok,
|
|
2330
|
+
citations: citations.length,
|
|
2331
|
+
resolved: citations.length - unresolved.length,
|
|
2332
|
+
unresolved: unresolved.length,
|
|
2333
|
+
}
|
|
2334
|
+
);
|
|
2335
|
+
events.push(resolveGateOut);
|
|
2336
|
+
|
|
2337
|
+
return { ok, scanned, citations, unresolved, byDoc, byRecord, telemetryEvents: events };
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
|
|
2341
|
+
// -------------------------------------------------------------------------
|
|
2342
|
+
// knowledge.flag-superseded-citers flow (supersede/retire propagation — #342)
|
|
2343
|
+
// Steps: collect-gate → flag-gate → done
|
|
2344
|
+
// Gate: flag-gate — every flag carries the superseding context that produced
|
|
2345
|
+
// it (the superseding record id(s) from the supersede op, and/or the
|
|
2346
|
+
// retire supersededByRef). A flag can NEVER be emitted without this
|
|
2347
|
+
// evidence: when a record shows no supersession evidence, zero flags
|
|
2348
|
+
// are emitted (fail closed — the "no false flags" guarantee).
|
|
2349
|
+
//
|
|
2350
|
+
// READ-ONLY: reads the store's query surface (get / getLinks) and — when doc
|
|
2351
|
+
// globs are configured — the #340 inbound-reference citation index. Mutates no
|
|
2352
|
+
// record and appends no mutation-log entry (same invariant as the Addenda D–G
|
|
2353
|
+
// audits and the #340 check). The existing gated `supersede` (Addendum A.5) and
|
|
2354
|
+
// `retire` (Addendum B.4) ops are untouched: this forks no mutation path.
|
|
2355
|
+
//
|
|
2356
|
+
// Motivation (field report, ops record 0e439c57): a decision record became
|
|
2357
|
+
// unresolvable after a store restructure and NOTHING flagged the four docs
|
|
2358
|
+
// citing it — they kept presenting dead authority for weeks. Supersession
|
|
2359
|
+
// preserves the record (supersede-not-delete) and makes store-internal citers
|
|
2360
|
+
// discoverable via reverse links, but doc citers stayed invisible. This
|
|
2361
|
+
// operation closes that loop: on a superseded/retired record it enumerates
|
|
2362
|
+
// first-degree inbound citers — store records (reverse-link index, §5) AND docs
|
|
2363
|
+
// (the #340 citation index) — and emits one supersession-aware flag per citer.
|
|
2364
|
+
// -------------------------------------------------------------------------
|
|
2365
|
+
|
|
2366
|
+
/**
|
|
2367
|
+
* Enumerate first-degree inbound citers of a superseded / retired record and
|
|
2368
|
+
* emit one flag per citer, each carrying the superseding context. Read-only.
|
|
2369
|
+
*
|
|
2370
|
+
* Supersession evidence (the flag-gate requires at least one):
|
|
2371
|
+
* - `supersede` op (Addendum A.5): a reverse link of kind `"supersedes"` from
|
|
2372
|
+
* the superseding record, mirrored by a `"superseded-by"` mutation-log entry
|
|
2373
|
+
* (`new_id`). Both are consulted; the union is `supersededByIds`.
|
|
2374
|
+
* - `retire` op with `supersededByRef` (Addendum B.4): a `"retire"` mutation-log
|
|
2375
|
+
* entry whose `evidence.supersededByRef` names the superseding artifact.
|
|
2376
|
+
*
|
|
2377
|
+
* Citer enumeration (first-degree only — no citers-of-citers):
|
|
2378
|
+
* - store records: `getLinks(id).reverse`, EXCLUDING the `"supersedes"` link and
|
|
2379
|
+
* any link from a superseding record itself (the new authority is not a stale
|
|
2380
|
+
* citer). Every other inbound link is a citer, tagged with its `linkKind`.
|
|
2381
|
+
* - docs: `checkInboundReferences({ docGlobs, ... }).byRecord[id]` when doc globs
|
|
2382
|
+
* are configured (opt-in; zero globs = store citers only). The superseded
|
|
2383
|
+
* record is preserved (supersede-not-delete), so its citations still resolve.
|
|
2384
|
+
*
|
|
2385
|
+
* Flag-gating: flags are emitted ONLY when supersession evidence exists. A
|
|
2386
|
+
* record with no such evidence yields `superseded:false` and an empty flag list
|
|
2387
|
+
* (no false flags); a superseded record with no citers likewise yields an empty
|
|
2388
|
+
* flag list. The enumerated citer counts (`storeCiters` / `docCiters`) are
|
|
2389
|
+
* always reported so a caller can see the inbound edges independent of gating.
|
|
2390
|
+
*
|
|
2391
|
+
* @param {string} recordId Id (or #339 prefix/slug handle) of the record.
|
|
2392
|
+
* @param {object} [options]
|
|
2393
|
+
* - docGlobs: string[] — globs to scan for doc citers (opt-in; [] = store citers only)
|
|
2394
|
+
* - docsRoot: string — root the globs resolve against (default: workspace, then cwd)
|
|
2395
|
+
* - markers: string[] — citation marker prefixes (default DEFAULT_CITATION_MARKERS)
|
|
2396
|
+
* - agent: string — override agent name
|
|
2397
|
+
* @returns {Promise<{
|
|
2398
|
+
* recordId: string,
|
|
2399
|
+
* superseded: boolean,
|
|
2400
|
+
* supersededByIds: string[],
|
|
2401
|
+
* supersededByRef: string|null,
|
|
2402
|
+
* retiredStatus: string|null,
|
|
2403
|
+
* storeCiters: number,
|
|
2404
|
+
* docCiters: number,
|
|
2405
|
+
* flags: Array<{
|
|
2406
|
+
* citerRef: string, citerKind: "record"|"doc", citedId: string,
|
|
2407
|
+
* supersededByIds: string[], supersededByRef: string|null,
|
|
2408
|
+
* linkKind?: string,
|
|
2409
|
+
* doc?: string, line?: number, column?: number, token?: string, form?: string
|
|
2410
|
+
* }>,
|
|
2411
|
+
* telemetryEvents: object[]
|
|
2412
|
+
* }>}
|
|
2413
|
+
*/
|
|
2414
|
+
async flagSupersededCiters(recordId, options = {}) {
|
|
2415
|
+
const events = [];
|
|
2416
|
+
const agent = options.agent || this._agent;
|
|
2417
|
+
|
|
2418
|
+
if (!recordId || typeof recordId !== "string") {
|
|
2419
|
+
throw missingEvidenceError(
|
|
2420
|
+
"flag-superseded-citers: recordId must be a non-empty string"
|
|
2421
|
+
);
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
const record = await this._store.get(recordId);
|
|
2425
|
+
if (!record) {
|
|
2426
|
+
throw missingEvidenceError(`flag-superseded-citers: record not found: ${recordId}`);
|
|
2427
|
+
}
|
|
2428
|
+
const citedId = record.id;
|
|
2429
|
+
const docGlobs = Array.isArray(options.docGlobs) ? options.docGlobs : [];
|
|
2430
|
+
|
|
2431
|
+
// ── Step: collect-gate ─────────────────────────────────────────────────
|
|
2432
|
+
const collectGateIn = this._telemetry.emitGate(
|
|
2433
|
+
"knowledge.flag-superseded-citers",
|
|
2434
|
+
"collect-gate",
|
|
2435
|
+
{
|
|
2436
|
+
flow: "knowledge.flag-superseded-citers",
|
|
2437
|
+
gate: "collect-gate",
|
|
2438
|
+
record_id: citedId,
|
|
2439
|
+
doc_globs: docGlobs,
|
|
2440
|
+
}
|
|
2441
|
+
);
|
|
2442
|
+
events.push(collectGateIn);
|
|
2443
|
+
|
|
2444
|
+
// Superseding context — the evidence every flag must carry.
|
|
2445
|
+
const links = await this._store.getLinks(citedId);
|
|
2446
|
+
const reverse = Array.isArray(links.reverse) ? links.reverse : [];
|
|
2447
|
+
|
|
2448
|
+
const supersededByIds = [
|
|
2449
|
+
...new Set(
|
|
2450
|
+
reverse
|
|
2451
|
+
.filter((l) => l.kind === "supersedes")
|
|
2452
|
+
.map((l) => l.source_id)
|
|
2453
|
+
.filter(Boolean)
|
|
2454
|
+
),
|
|
2455
|
+
];
|
|
2456
|
+
// The mutation log independently records the superseding record(s); fold in
|
|
2457
|
+
// any not already surfaced by the reverse index (belt-and-suspenders).
|
|
2458
|
+
for (const e of record.mutation_log || []) {
|
|
2459
|
+
if (e.op === "superseded-by" && e.new_id && !supersededByIds.includes(e.new_id)) {
|
|
2460
|
+
supersededByIds.push(e.new_id);
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
// retire(…, { supersededByRef }) — Addendum B.4.
|
|
2465
|
+
let supersededByRef = null;
|
|
2466
|
+
for (const e of record.mutation_log || []) {
|
|
2467
|
+
if (e.op === "retire" && e.evidence && e.evidence.supersededByRef) {
|
|
2468
|
+
supersededByRef = e.evidence.supersededByRef;
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
const retiredStatus = (record.status || "active") === "retired" ? "retired" : null;
|
|
2472
|
+
|
|
2473
|
+
const superseded = supersededByIds.length > 0 || Boolean(supersededByRef);
|
|
2474
|
+
|
|
2475
|
+
// Store-record citers: inbound links minus the supersession relationship
|
|
2476
|
+
// itself and any link originating from a superseding record (the replacement
|
|
2477
|
+
// authority is not a silent dead citer).
|
|
2478
|
+
const storeCiterLinks = reverse.filter(
|
|
2479
|
+
(l) => l.kind !== "supersedes" && !supersededByIds.includes(l.source_id)
|
|
2480
|
+
);
|
|
2481
|
+
|
|
2482
|
+
// Doc citers: the #340 citation index, keyed by the record's full id. Opt-in.
|
|
2483
|
+
let docCiterEntries = [];
|
|
2484
|
+
if (docGlobs.length > 0) {
|
|
2485
|
+
const inbound = await this.checkInboundReferences({
|
|
2486
|
+
docGlobs,
|
|
2487
|
+
docsRoot: options.docsRoot,
|
|
2488
|
+
markers: options.markers,
|
|
2489
|
+
agent,
|
|
2490
|
+
});
|
|
2491
|
+
// Fold the inbound-check gate telemetry into this flow's event trail.
|
|
2492
|
+
if (Array.isArray(inbound.telemetryEvents)) {
|
|
2493
|
+
for (const ev of inbound.telemetryEvents) events.push(ev);
|
|
2494
|
+
}
|
|
2495
|
+
docCiterEntries = inbound.byRecord[citedId] || [];
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
const collectGateOut = this._telemetry.emitGateResult(
|
|
2499
|
+
"knowledge.flag-superseded-citers",
|
|
2500
|
+
"collect-gate",
|
|
2501
|
+
{
|
|
2502
|
+
record_id: citedId,
|
|
2503
|
+
superseded,
|
|
2504
|
+
superseded_by_count: supersededByIds.length,
|
|
2505
|
+
superseded_by_ref: Boolean(supersededByRef),
|
|
2506
|
+
store_citers: storeCiterLinks.length,
|
|
2507
|
+
doc_citers: docCiterEntries.length,
|
|
2508
|
+
}
|
|
2509
|
+
);
|
|
2510
|
+
events.push(collectGateOut);
|
|
2511
|
+
|
|
2512
|
+
// ── Step: flag-gate ────────────────────────────────────────────────────
|
|
2513
|
+
const flagGateIn = this._telemetry.emitGate(
|
|
2514
|
+
"knowledge.flag-superseded-citers",
|
|
2515
|
+
"flag-gate",
|
|
2516
|
+
{
|
|
2517
|
+
flow: "knowledge.flag-superseded-citers",
|
|
2518
|
+
gate: "flag-gate",
|
|
2519
|
+
record_id: citedId,
|
|
2520
|
+
superseded,
|
|
2521
|
+
candidate_citers: storeCiterLinks.length + docCiterEntries.length,
|
|
2522
|
+
}
|
|
2523
|
+
);
|
|
2524
|
+
events.push(flagGateIn);
|
|
2525
|
+
|
|
2526
|
+
const flags = [];
|
|
2527
|
+
if (superseded) {
|
|
2528
|
+
const context = { supersededByIds, supersededByRef };
|
|
2529
|
+
for (const l of storeCiterLinks) {
|
|
2530
|
+
flags.push({
|
|
2531
|
+
citerRef: l.source_id,
|
|
2532
|
+
citerKind: "record",
|
|
2533
|
+
linkKind: l.kind,
|
|
2534
|
+
citedId,
|
|
2535
|
+
...context,
|
|
2536
|
+
});
|
|
2537
|
+
}
|
|
2538
|
+
for (const d of docCiterEntries) {
|
|
2539
|
+
flags.push({
|
|
2540
|
+
citerRef: `${d.doc}:${d.line}:${d.column}`,
|
|
2541
|
+
citerKind: "doc",
|
|
2542
|
+
doc: d.doc,
|
|
2543
|
+
line: d.line,
|
|
2544
|
+
column: d.column,
|
|
2545
|
+
token: d.token,
|
|
2546
|
+
form: d.form,
|
|
2547
|
+
citedId,
|
|
2548
|
+
...context,
|
|
2549
|
+
});
|
|
2550
|
+
}
|
|
2551
|
+
// Evidence guarantee (defensive): the flag-gate refuses any flag that does
|
|
2552
|
+
// not carry superseding context. `superseded` already implies context, so
|
|
2553
|
+
// this can only fire on a future regression — fail closed if it ever does.
|
|
2554
|
+
for (const f of flags) {
|
|
2555
|
+
if (f.supersededByIds.length === 0 && !f.supersededByRef) {
|
|
2556
|
+
throw missingEvidenceError(
|
|
2557
|
+
"flag-superseded-citers: refusing to emit a flag without superseding context"
|
|
2558
|
+
);
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
const flagGateOut = this._telemetry.emitGateResult(
|
|
2564
|
+
"knowledge.flag-superseded-citers",
|
|
2565
|
+
"flag-gate",
|
|
2566
|
+
{
|
|
2567
|
+
record_id: citedId,
|
|
2568
|
+
superseded,
|
|
2569
|
+
flagged: flags.length,
|
|
2570
|
+
agent,
|
|
2571
|
+
}
|
|
2572
|
+
);
|
|
2573
|
+
events.push(flagGateOut);
|
|
2574
|
+
|
|
2575
|
+
return {
|
|
2576
|
+
recordId: citedId,
|
|
2577
|
+
superseded,
|
|
2578
|
+
supersededByIds,
|
|
2579
|
+
supersededByRef,
|
|
2580
|
+
retiredStatus,
|
|
2581
|
+
storeCiters: storeCiterLinks.length,
|
|
2582
|
+
docCiters: docCiterEntries.length,
|
|
2583
|
+
flags,
|
|
2584
|
+
telemetryEvents: events,
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
|
|
1759
2589
|
// -------------------------------------------------------------------------
|
|
1760
2590
|
// knowledge.audit-freshness flow (hygiene #1 — #106)
|
|
1761
2591
|
// Steps: collect → measure → flag-gate → done
|
|
@@ -1800,7 +2630,9 @@ export class KnowledgeFlowRunner {
|
|
|
1800
2630
|
* flags: Array<{
|
|
1801
2631
|
* recordId: string, title: string, type: string, category: string,
|
|
1802
2632
|
* status: string, lastMutationAt: string, ageDays: number,
|
|
1803
|
-
* thresholdDays: number, matchedThresholdKey: string,
|
|
2633
|
+
* thresholdDays: number|null, matchedThresholdKey: string,
|
|
2634
|
+
* reason: "threshold"|"expiry", // which path fired (#341)
|
|
2635
|
+
* expiresAt?: string, // present on expiry flags: the cited expiry
|
|
1804
2636
|
* proposedAction: "archive"|"refresh"
|
|
1805
2637
|
* }>,
|
|
1806
2638
|
* telemetryEvents: object[]
|
|
@@ -1872,9 +2704,18 @@ export class KnowledgeFlowRunner {
|
|
|
1872
2704
|
let audited = 0;
|
|
1873
2705
|
|
|
1874
2706
|
for (const record of records) {
|
|
2707
|
+
// Record-carried expiry (#341): a record's own `expires_at` / `ttl_seconds`
|
|
2708
|
+
// is the strongest freshness signal and fires WITHOUT any caller-supplied
|
|
2709
|
+
// threshold (Addendum J). Effective expiry: explicit expires_at, else
|
|
2710
|
+
// created_at + ttl_seconds; null when the record carries no freshness field.
|
|
2711
|
+
const expiryMs = effectiveExpiryMs(record);
|
|
2712
|
+
const hasExpiry = expiryMs !== null;
|
|
1875
2713
|
const resolved = resolveThreshold(record.category, thresholds, defaultThresholdDays);
|
|
1876
|
-
|
|
1877
|
-
|
|
2714
|
+
|
|
2715
|
+
// A record is opt-out only when it has NEITHER a record-carried expiry NOR
|
|
2716
|
+
// a resolvable per-category threshold. This preserves the pre-#341 skip
|
|
2717
|
+
// semantics for records without freshness fields.
|
|
2718
|
+
if (!hasExpiry && resolved === null) {
|
|
1878
2719
|
skipped += 1;
|
|
1879
2720
|
continue;
|
|
1880
2721
|
}
|
|
@@ -1886,13 +2727,35 @@ export class KnowledgeFlowRunner {
|
|
|
1886
2727
|
const ageDays = Number.isNaN(lastMs)
|
|
1887
2728
|
? 0
|
|
1888
2729
|
: Math.floor((nowMs - lastMs) / 86_400_000);
|
|
2730
|
+
const proposedAction = resolveAction(record.category, actions, defaultAction);
|
|
2731
|
+
|
|
2732
|
+
// Expiry path takes precedence: when a record is past its OWN expiry, flag
|
|
2733
|
+
// it citing that expiry (`expiresAt`) as the threshold that fired. Boundary
|
|
2734
|
+
// is inclusive (nowMs >= expiry), matching isRecordStale (Addendum J).
|
|
2735
|
+
if (hasExpiry && nowMs >= expiryMs) {
|
|
2736
|
+
flags.push({
|
|
2737
|
+
recordId: record.id,
|
|
2738
|
+
title: record.title,
|
|
2739
|
+
type: record.type,
|
|
2740
|
+
category: record.category,
|
|
2741
|
+
status: record.status || "active",
|
|
2742
|
+
lastMutationAt,
|
|
2743
|
+
ageDays,
|
|
2744
|
+
// Expiry flags cite `expiresAt` rather than a day-count threshold, so
|
|
2745
|
+
// `thresholdDays` is null and `matchedThresholdKey` names the field.
|
|
2746
|
+
thresholdDays: null,
|
|
2747
|
+
matchedThresholdKey: "expires_at",
|
|
2748
|
+
expiresAt: new Date(expiryMs).toISOString(),
|
|
2749
|
+
reason: "expiry",
|
|
2750
|
+
proposedAction,
|
|
2751
|
+
});
|
|
2752
|
+
continue;
|
|
2753
|
+
}
|
|
1889
2754
|
|
|
1890
|
-
//
|
|
1891
|
-
//
|
|
1892
|
-
//
|
|
1893
|
-
if (ageDays > resolved.thresholdDays) {
|
|
1894
|
-
const proposedAction =
|
|
1895
|
-
resolveAction(record.category, actions, defaultAction);
|
|
2755
|
+
// Threshold path (unchanged, #106): flag only when STRICTLY past the
|
|
2756
|
+
// resolved per-category threshold. Evidence (last-mutation + the threshold
|
|
2757
|
+
// key/value that fired) is carried on every flag.
|
|
2758
|
+
if (resolved !== null && ageDays > resolved.thresholdDays) {
|
|
1896
2759
|
flags.push({
|
|
1897
2760
|
recordId: record.id,
|
|
1898
2761
|
title: record.title,
|
|
@@ -1903,6 +2766,7 @@ export class KnowledgeFlowRunner {
|
|
|
1903
2766
|
ageDays,
|
|
1904
2767
|
thresholdDays: resolved.thresholdDays,
|
|
1905
2768
|
matchedThresholdKey: resolved.matchedKey,
|
|
2769
|
+
reason: "threshold",
|
|
1906
2770
|
proposedAction,
|
|
1907
2771
|
});
|
|
1908
2772
|
}
|
|
@@ -3350,6 +4214,38 @@ export async function auditFreshness(
|
|
|
3350
4214
|
return runner.auditFreshness(auditOpts);
|
|
3351
4215
|
}
|
|
3352
4216
|
|
|
4217
|
+
/**
|
|
4218
|
+
* Module-level inbound-reference integrity check: creates an ephemeral runner
|
|
4219
|
+
* using the provided store. Read-only — mutates nothing. Fails closed on any
|
|
4220
|
+
* unresolvable definite doc→store citation. (#340)
|
|
4221
|
+
*
|
|
4222
|
+
* @param {object} options (merged into checkInboundReferences options + runner options)
|
|
4223
|
+
*/
|
|
4224
|
+
export async function checkInboundReferences(
|
|
4225
|
+
{ store, workspace, agent, sessionId, ...checkOpts } = {}
|
|
4226
|
+
) {
|
|
4227
|
+
const runner = new KnowledgeFlowRunner({ store, workspace, agent, sessionId });
|
|
4228
|
+
return runner.checkInboundReferences(checkOpts);
|
|
4229
|
+
}
|
|
4230
|
+
|
|
4231
|
+
/**
|
|
4232
|
+
* Module-level supersede/retire citer-propagation flag: creates an ephemeral
|
|
4233
|
+
* runner using the provided store. Read-only — mutates nothing. Enumerates
|
|
4234
|
+
* first-degree inbound citers (store records + docs) of a superseded/retired
|
|
4235
|
+
* record and emits one flag per citer, each carrying the superseding context.
|
|
4236
|
+
* Fails closed: no flag without that evidence. (#342)
|
|
4237
|
+
*
|
|
4238
|
+
* @param {string} recordId
|
|
4239
|
+
* @param {object} options (merged into flagSupersededCiters options + runner options)
|
|
4240
|
+
*/
|
|
4241
|
+
export async function flagSupersededCiters(
|
|
4242
|
+
recordId,
|
|
4243
|
+
{ store, workspace, agent, sessionId, ...flagOpts } = {}
|
|
4244
|
+
) {
|
|
4245
|
+
const runner = new KnowledgeFlowRunner({ store, workspace, agent, sessionId });
|
|
4246
|
+
return runner.flagSupersededCiters(recordId, flagOpts);
|
|
4247
|
+
}
|
|
4248
|
+
|
|
3353
4249
|
/**
|
|
3354
4250
|
* Module-level category-canonicalization audit: creates an ephemeral runner
|
|
3355
4251
|
* using the provided store. Read-only — mutates nothing. (#106 hygiene #4)
|