@stravica-ai/digital-operator-pa 0.1.11 → 0.1.12
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/dist/engineering-pa-cli/src/knowledge/cli.mjs +14 -5
- package/dist/engineering-pa-cli/src/knowledge/eval.mjs +37 -9
- package/dist/engineering-pa-cli/src/knowledge/formatters.mjs +32 -9
- package/dist/engineering-pa-cli/src/knowledge/graph.mjs +12 -1
- package/dist/engineering-pa-cli/src/knowledge/orphan-policy.mjs +173 -0
- package/dist/engineering-pa-core/docs/knowledge-scaffold.md +29 -0
- package/package.json +1 -1
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { getRepoRoot, loadGraph, related, search, searchCascade, domain, orphans, path } from './graph.mjs';
|
|
23
23
|
import { formatRelated, formatSearch, formatDomain, formatOrphans, formatPath } from './formatters.mjs';
|
|
24
|
+
import { loadOrphanPolicy, applyOrphanPolicy } from './orphan-policy.mjs';
|
|
24
25
|
import { appendCallLog } from './call-log.mjs';
|
|
25
26
|
|
|
26
27
|
const USAGE = `pa knowledge — query the operator's knowledge graph
|
|
@@ -41,7 +42,11 @@ Usage:
|
|
|
41
42
|
[--strict] Disable the cascade — restores the
|
|
42
43
|
AND-only behaviour for scripted callers
|
|
43
44
|
pa knowledge domain <name> All docs in a domain + internal edges
|
|
44
|
-
pa knowledge orphans Docs with no inbound edges
|
|
45
|
+
pa knowledge orphans Docs with no inbound edges. Path classes
|
|
46
|
+
listed in operator/knowledge/
|
|
47
|
+
orphan-policy.json (operator-local doc
|
|
48
|
+
classes) are excluded from the count,
|
|
49
|
+
and the excluded amount is shown.
|
|
45
50
|
[--exclude-stubs] Skip null-domain nodes
|
|
46
51
|
pa knowledge path <a> <b> Shortest path between two docs
|
|
47
52
|
pa knowledge eval [--fixture P] Score the retrieval eval set (hit@1/5,
|
|
@@ -218,10 +223,14 @@ async function doOrphans(args) {
|
|
|
218
223
|
const { flags } = parseFlags(args);
|
|
219
224
|
const format = resolveFormat(flags);
|
|
220
225
|
const includeStubs = !(flags['exclude-stubs'] === true);
|
|
221
|
-
const { graph } = await loadGraphOrExplain();
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
226
|
+
const { repoRoot, graph } = await loadGraphOrExplain();
|
|
227
|
+
// Policy exclusion (w-2026-07-29-003): operator-declared path classes at
|
|
228
|
+
// operator/knowledge/orphan-policy.json are subtracted from the orphan
|
|
229
|
+
// list, and the subtraction is rendered — the number never silently shrinks.
|
|
230
|
+
const policy = await loadOrphanPolicy({ repoRoot });
|
|
231
|
+
const { kept, exclusion } = applyOrphanPolicy(orphans(graph, { includeStubs }), policy);
|
|
232
|
+
process.stdout.write(formatOrphans(kept, format, { exclusion }));
|
|
233
|
+
await logCall({ verb: 'orphans', query: null, resultCount: kept.length });
|
|
225
234
|
return 0;
|
|
226
235
|
}
|
|
227
236
|
|
|
@@ -25,7 +25,10 @@
|
|
|
25
25
|
// Orphan gauge runs in the same pass — total + per-domain, delta vs last
|
|
26
26
|
// run, warnings when growth > 10 or any domain is 100% orphaned. The
|
|
27
27
|
// gauge is a load-bearing hygiene signal, but never fails the run — it
|
|
28
|
-
// tags the summary and boot-context renders the tag.
|
|
28
|
+
// tags the summary and boot-context renders the tag. Path classes listed
|
|
29
|
+
// in operator/knowledge/orphan-policy.json (operator-local doc classes,
|
|
30
|
+
// w-2026-07-29-003) are excluded from the gauge and its alarms; the
|
|
31
|
+
// excluded amount is carried on the gauge and rendered, never silent.
|
|
29
32
|
//
|
|
30
33
|
// Exit codes (tooling standard §6):
|
|
31
34
|
// 0 ok
|
|
@@ -38,6 +41,7 @@ import { existsSync } from 'node:fs';
|
|
|
38
41
|
import { join, resolve } from 'node:path';
|
|
39
42
|
|
|
40
43
|
import { getRepoRoot, loadGraph, searchCascade, orphans, uniqueDomains } from './graph.mjs';
|
|
44
|
+
import { loadOrphanPolicy, applyOrphanPolicy, renderExclusionSummary } from './orphan-policy.mjs';
|
|
41
45
|
|
|
42
46
|
const EVAL_SET_PATH = 'operator/knowledge/_meta/retrieval-evals.json';
|
|
43
47
|
const RUNS_DIR = 'operator/knowledge/_meta/retrieval-eval-runs';
|
|
@@ -154,7 +158,18 @@ export async function run(argv) {
|
|
|
154
158
|
}
|
|
155
159
|
|
|
156
160
|
const summary = summarise(cases);
|
|
157
|
-
|
|
161
|
+
// Same policy the `orphans` verb honours (w-2026-07-29-003). If the gauge
|
|
162
|
+
// ignored it, excluded trees would re-alarm forever ("100% orphaned").
|
|
163
|
+
// A malformed policy file throws here — see orphan-policy.mjs for why
|
|
164
|
+
// that must be loud.
|
|
165
|
+
let policy;
|
|
166
|
+
try {
|
|
167
|
+
policy = await loadOrphanPolicy({ repoRoot });
|
|
168
|
+
} catch (err) {
|
|
169
|
+
process.stderr.write(`pa knowledge eval: ${err.message}\n`);
|
|
170
|
+
return err.exitCode ?? 1;
|
|
171
|
+
}
|
|
172
|
+
const orphanGauge = computeOrphanGauge(graph, policy);
|
|
158
173
|
|
|
159
174
|
const runId = new Date().toISOString().replace(/[:.]/g, '-').replace(/Z$/, '');
|
|
160
175
|
const generatedAt = new Date().toISOString();
|
|
@@ -302,11 +317,17 @@ function pct(n, d) {
|
|
|
302
317
|
|
|
303
318
|
// --- orphan gauge ---------------------------------------------------------
|
|
304
319
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
320
|
+
// Policy-excluded path classes (operator/knowledge/orphan-policy.json,
|
|
321
|
+
// w-2026-07-29-003) are subtracted BEFORE totals and per-domain counts,
|
|
322
|
+
// so the growth alarm and the 100%-orphaned-domain alarm judge only the
|
|
323
|
+
// docs that policy says should be graph citizens. The excluded amount is
|
|
324
|
+
// carried on the gauge (`excludedByPolicy`) and rendered on every output
|
|
325
|
+
// surface — the number shrinks visibly, never silently.
|
|
326
|
+
function computeOrphanGauge(graph, policy = null) {
|
|
327
|
+
const { kept, exclusion } = applyOrphanPolicy(orphans(graph, { includeStubs: true }), policy);
|
|
328
|
+
const total = kept.length;
|
|
308
329
|
const byDomain = {};
|
|
309
|
-
for (const r of
|
|
330
|
+
for (const r of kept) {
|
|
310
331
|
const d = r.domain ?? '<none>';
|
|
311
332
|
byDomain[d] = (byDomain[d] ?? 0) + 1;
|
|
312
333
|
}
|
|
@@ -323,7 +344,9 @@ function computeOrphanGauge(graph) {
|
|
|
323
344
|
if (byDomain['<none>'] != null) {
|
|
324
345
|
perDomain['<none>'] = { orphans: byDomain['<none>'], total: domainTotals['<none>'] ?? 0 };
|
|
325
346
|
}
|
|
326
|
-
|
|
347
|
+
const gauge = { total, byDomain: perDomain };
|
|
348
|
+
if (exclusion != null) gauge.excludedByPolicy = exclusion;
|
|
349
|
+
return gauge;
|
|
327
350
|
}
|
|
328
351
|
|
|
329
352
|
// --- delta + alarms -------------------------------------------------------
|
|
@@ -373,8 +396,10 @@ function renderWorkStateLine(summary, orphanGauge, delta, alarms) {
|
|
|
373
396
|
const orphanTag = delta.firstRun
|
|
374
397
|
? ''
|
|
375
398
|
: ` (${delta.orphanCountDelta > 0 ? '+' : ''}${delta.orphanCountDelta})`;
|
|
399
|
+
const excluded = orphanGauge.excludedByPolicy?.total ?? 0;
|
|
400
|
+
const excludedTag = excluded > 0 ? ` [${excluded} excluded by policy]` : '';
|
|
376
401
|
const alarmTag = alarms.length > 0 ? ` ⚠ ${alarms.length} alarm${alarms.length === 1 ? '' : 's'}` : '';
|
|
377
|
-
return `retrievalEval: hit@5 ${hit5}% ${deltaTag} | orphans ${orphanGauge.total}${orphanTag}${alarmTag}`;
|
|
402
|
+
return `retrievalEval: hit@5 ${hit5}% ${deltaTag} | orphans ${orphanGauge.total}${orphanTag}${excludedTag}${alarmTag}`;
|
|
378
403
|
}
|
|
379
404
|
|
|
380
405
|
function emit(runFile, format) {
|
|
@@ -399,7 +424,10 @@ function emit(runFile, format) {
|
|
|
399
424
|
lines.push(`Δhit@5: ${sign}${runFile.delta.hit5PercentagePointsDelta}pp vs last run`);
|
|
400
425
|
}
|
|
401
426
|
lines.push('');
|
|
402
|
-
|
|
427
|
+
const exclusionSummary = renderExclusionSummary(runFile.orphans.excludedByPolicy);
|
|
428
|
+
lines.push(exclusionSummary
|
|
429
|
+
? `Orphans: ${runFile.orphans.total} (${exclusionSummary})`
|
|
430
|
+
: `Orphans: ${runFile.orphans.total}`);
|
|
403
431
|
if (runFile.delta && !runFile.delta.firstRun) {
|
|
404
432
|
const sign = runFile.delta.orphanCountDelta > 0 ? '+' : '';
|
|
405
433
|
lines.push(`Δorphans: ${sign}${runFile.delta.orphanCountDelta} vs last run`);
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// pass through the verb result — the CLI-layer wraps in `{verb, ...}`
|
|
13
13
|
// for stability.
|
|
14
14
|
|
|
15
|
+
import { renderExclusionSummary } from './orphan-policy.mjs';
|
|
16
|
+
|
|
15
17
|
// --- generic table primitives ---------------------------------------------
|
|
16
18
|
|
|
17
19
|
const TABLE_MAX_WIDTH = 120;
|
|
@@ -229,21 +231,39 @@ function renderDomainMermaid({ domain, nodes, edges }) {
|
|
|
229
231
|
|
|
230
232
|
// --- verb: orphans --------------------------------------------------------
|
|
231
233
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
234
|
+
// `exclusion` (optional) is the policy-visibility payload from
|
|
235
|
+
// applyOrphanPolicy — { total, byPrefix: [{ pathPrefix, reason, count }] }.
|
|
236
|
+
// When present it is rendered in EVERY format: the ratified design says the
|
|
237
|
+
// orphan number must never silently shrink, so the excluded amount is part
|
|
238
|
+
// of the output, not an implementation detail. When no policy exists
|
|
239
|
+
// (exclusion == null) all three formats are byte-identical to the
|
|
240
|
+
// pre-policy output.
|
|
241
|
+
export function formatOrphans(results, format, { exclusion = null } = {}) {
|
|
242
|
+
if (format === 'json') {
|
|
243
|
+
const envelope = { verb: 'orphans', results };
|
|
244
|
+
if (exclusion != null) envelope.excludedByPolicy = exclusion;
|
|
245
|
+
return JSON.stringify(envelope, null, 2) + '\n';
|
|
246
|
+
}
|
|
247
|
+
if (format === 'mermaid') return renderOrphansMermaid(results, exclusion);
|
|
248
|
+
return renderOrphansTable(results, exclusion);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function renderExclusionLine(results, exclusion) {
|
|
252
|
+
const summary = renderExclusionSummary(exclusion);
|
|
253
|
+
if (summary == null) return null;
|
|
254
|
+
return `orphans: ${results.length} (${summary})`;
|
|
236
255
|
}
|
|
237
256
|
|
|
238
|
-
function renderOrphansTable(results) {
|
|
239
|
-
|
|
257
|
+
function renderOrphansTable(results, exclusion = null) {
|
|
258
|
+
const footer = renderExclusionLine(results, exclusion);
|
|
259
|
+
if (results.length === 0) return footer ? `(no orphans)\n${footer}\n` : '(no orphans)\n';
|
|
240
260
|
const rows = results.map((r) => ({
|
|
241
261
|
id: r.id,
|
|
242
262
|
title: r.title ?? '',
|
|
243
263
|
domain: r.domain ?? '',
|
|
244
264
|
outDegree: r.outDegree,
|
|
245
265
|
}));
|
|
246
|
-
|
|
266
|
+
const table = renderTable(
|
|
247
267
|
[
|
|
248
268
|
{ key: 'id', label: 'id' },
|
|
249
269
|
{ key: 'title', label: 'title' },
|
|
@@ -252,13 +272,16 @@ function renderOrphansTable(results) {
|
|
|
252
272
|
],
|
|
253
273
|
rows,
|
|
254
274
|
);
|
|
275
|
+
return footer ? `${table}${footer}\n` : table;
|
|
255
276
|
}
|
|
256
277
|
|
|
257
|
-
function renderOrphansMermaid(results) {
|
|
258
|
-
|
|
278
|
+
function renderOrphansMermaid(results, exclusion = null) {
|
|
279
|
+
const footer = renderExclusionLine(results, exclusion);
|
|
280
|
+
if (results.length === 0) return footer ? `%% no orphans\n%% ${footer}\n` : '%% no orphans\n';
|
|
259
281
|
const lines = ['graph LR'];
|
|
260
282
|
const idFor = (s) => 'n' + hashSlug(s);
|
|
261
283
|
for (const r of results) lines.push(` ${idFor(r.id)}["${escapeMermaid(r.title ?? r.id)}"]`);
|
|
284
|
+
if (footer) lines.push(` %% ${footer}`);
|
|
262
285
|
return lines.join('\n') + '\n';
|
|
263
286
|
}
|
|
264
287
|
|
|
@@ -674,13 +674,24 @@ export function domain(graph, name) {
|
|
|
674
674
|
// Nodes with zero inbound edges. Excludes null-domain nodes (auto-generated
|
|
675
675
|
// stubs) if `includeStubs` is false. Default is to include everything so
|
|
676
676
|
// operators see the full picture.
|
|
677
|
+
//
|
|
678
|
+
// Index hubs (`type: 'index'`, minted per index.json since core 0.1.10) that
|
|
679
|
+
// link OUT to documents are connectors, not orphans: the root hub has no
|
|
680
|
+
// ancestor to point at it and no authored content can ever link to a .json
|
|
681
|
+
// target (extractLinks drops non-.md markdown links, wiki-links get .md
|
|
682
|
+
// appended, the backlink indexer scans only .md files), so zero inbound is
|
|
683
|
+
// guaranteed by construction — a structural artefact, not a filing gap. A
|
|
684
|
+
// hub with NO outbound links (empty or fully-unresolved index) is still
|
|
685
|
+
// reported, because that genuinely signals a broken hub.
|
|
677
686
|
export function orphans(graph, { includeStubs = true } = {}) {
|
|
678
687
|
const adj = buildAdjacency(graph);
|
|
679
688
|
const results = [];
|
|
680
689
|
for (const node of graph.nodes) {
|
|
681
690
|
if (!includeStubs && node.domain == null) continue;
|
|
682
691
|
if ((adj.in.get(node.id)?.length ?? 0) === 0) {
|
|
683
|
-
|
|
692
|
+
const outDegree = adj.out.get(node.id)?.length ?? 0;
|
|
693
|
+
if (node.type === 'index' && outDegree > 0) continue;
|
|
694
|
+
results.push({ id: node.id, title: node.title, domain: node.domain, outDegree });
|
|
684
695
|
}
|
|
685
696
|
}
|
|
686
697
|
results.sort((a, b) => (a.domain ?? '').localeCompare(b.domain ?? '') || a.id.localeCompare(b.id));
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// orphan-policy.mjs — config-driven path-class exclusion for orphan counting.
|
|
2
|
+
//
|
|
3
|
+
// Purpose (w-2026-07-29-003, Baz ruling 2026-07-29): some document classes
|
|
4
|
+
// are operator+agent tier by design — local TODO / memory / note documents
|
|
5
|
+
// that are relevant only to the operator and the agent. A linkless doc in
|
|
6
|
+
// one of those trees is correctly scoped, not an orphan to repair. This
|
|
7
|
+
// module lets the ESTATE declare those path classes in an explicit,
|
|
8
|
+
// human-edited policy file, and every orphan-counting surface (the
|
|
9
|
+
// `orphans` verb, the housekeeping orphanCount that shells it, and the
|
|
10
|
+
// retrieval-eval orphan gauge + alarms) subtracts them consistently.
|
|
11
|
+
//
|
|
12
|
+
// Policy file: `<operator-root>/operator/knowledge/orphan-policy.json` —
|
|
13
|
+
// the same estate tree the knowledge tooling already reads its config
|
|
14
|
+
// from (domains.json, _meta/retrieval-evals.json). Shape:
|
|
15
|
+
//
|
|
16
|
+
// {
|
|
17
|
+
// "version": 1,
|
|
18
|
+
// "excludedPathClasses": [
|
|
19
|
+
// { "pathPrefix": "operator/knowledge/memory/",
|
|
20
|
+
// "reason": "<one line: why this class is operator-local>" }
|
|
21
|
+
// ]
|
|
22
|
+
// }
|
|
23
|
+
//
|
|
24
|
+
// NAMED TRADE-OFF (ratified with the ruling; do not soften it here): a doc
|
|
25
|
+
// misfiled INTO an excluded tree stops appearing as an orphan. That is why
|
|
26
|
+
// this is a per-path-class config a human reads and edits — never a
|
|
27
|
+
// hardcoded list, never a wildcard/blanket ignore — and why every entry
|
|
28
|
+
// MUST carry a reason string (the file is the audit trail). Validation
|
|
29
|
+
// below enforces both. The exclusion applies to orphan COUNTING only; the
|
|
30
|
+
// librarian's sweep judgement is untouched and still routes genuine
|
|
31
|
+
// knowledge out of excluded trees.
|
|
32
|
+
//
|
|
33
|
+
// Failure posture: a MISSING policy file is normal (empty policy — counts
|
|
34
|
+
// are unfiltered, byte-compatible with the pre-policy world). A MALFORMED
|
|
35
|
+
// policy file throws loudly (exitCode 1): silently ignoring it would snap
|
|
36
|
+
// counts back by the excluded amount and re-fire alarms, and silently
|
|
37
|
+
// misreading it could hide real orphans.
|
|
38
|
+
|
|
39
|
+
import { readFile } from 'node:fs/promises';
|
|
40
|
+
import { existsSync } from 'node:fs';
|
|
41
|
+
import { join } from 'node:path';
|
|
42
|
+
|
|
43
|
+
export const ORPHAN_POLICY_RELATIVE_PATH = 'operator/knowledge/orphan-policy.json';
|
|
44
|
+
|
|
45
|
+
// Prefixes that would blanket-ignore the whole tree (or worse). An entry
|
|
46
|
+
// normalising to one of these is a config error, not a policy.
|
|
47
|
+
const BLANKET_PREFIXES = new Set(['/', 'operator/', 'operator/knowledge/']);
|
|
48
|
+
|
|
49
|
+
export function emptyPolicy() {
|
|
50
|
+
return { exists: false, path: null, entries: [] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Normalise a configured prefix to a plain directory prefix:
|
|
54
|
+
// - strip a trailing glob (`/**` or `**`) — entries may be written either
|
|
55
|
+
// as `operator/knowledge/memory/` or `operator/knowledge/memory/**`
|
|
56
|
+
// - strip a leading `./`
|
|
57
|
+
// - ensure exactly one trailing `/` so `memory/` never matches `memory-x/`
|
|
58
|
+
function normalisePrefix(raw) {
|
|
59
|
+
let p = String(raw).trim().replace(/^\.\//, '');
|
|
60
|
+
p = p.replace(/\*+$/, ''); // drop trailing glob stars
|
|
61
|
+
p = p.replace(/\/+$/, ''); // drop trailing slashes
|
|
62
|
+
if (p.includes('*')) return null; // interior wildcards are not supported — listed prefixes only
|
|
63
|
+
return p + '/';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validateEntry(entry, index) {
|
|
67
|
+
if (!entry || typeof entry !== 'object') {
|
|
68
|
+
return `excludedPathClasses[${index}] is not an object`;
|
|
69
|
+
}
|
|
70
|
+
if (typeof entry.pathPrefix !== 'string' || entry.pathPrefix.trim() === '') {
|
|
71
|
+
return `excludedPathClasses[${index}].pathPrefix missing or not a non-empty string`;
|
|
72
|
+
}
|
|
73
|
+
const prefix = normalisePrefix(entry.pathPrefix);
|
|
74
|
+
if (prefix == null) {
|
|
75
|
+
return `excludedPathClasses[${index}].pathPrefix "${entry.pathPrefix}" contains an interior wildcard — only plain path prefixes are allowed`;
|
|
76
|
+
}
|
|
77
|
+
if (BLANKET_PREFIXES.has(prefix)) {
|
|
78
|
+
return `excludedPathClasses[${index}].pathPrefix "${entry.pathPrefix}" is a blanket ignore — the policy requires specific path classes, each with its own reason`;
|
|
79
|
+
}
|
|
80
|
+
if (typeof entry.reason !== 'string' || entry.reason.trim() === '') {
|
|
81
|
+
return `excludedPathClasses[${index}] ("${entry.pathPrefix}") has no reason — every exclusion must carry a one-line reason; the policy file is the audit trail`;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Load + validate the estate policy. Returns
|
|
87
|
+
// { exists, path, entries: [{ pathPrefix, reason }] }
|
|
88
|
+
// with `pathPrefix` normalised to a trailing-slash directory prefix.
|
|
89
|
+
// Missing file => empty policy. Malformed file => throws (exitCode 1).
|
|
90
|
+
export async function loadOrphanPolicy({ repoRoot, policyPath } = {}) {
|
|
91
|
+
const path = policyPath ?? (repoRoot ? join(repoRoot, ORPHAN_POLICY_RELATIVE_PATH) : null);
|
|
92
|
+
if (path == null || !existsSync(path)) return emptyPolicy();
|
|
93
|
+
|
|
94
|
+
let json;
|
|
95
|
+
try {
|
|
96
|
+
json = JSON.parse(await readFile(path, 'utf8'));
|
|
97
|
+
} catch (e) {
|
|
98
|
+
const err = new Error(`orphan policy at ${path} is unreadable or not valid JSON: ${e.message}`);
|
|
99
|
+
err.exitCode = 1;
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
if (!json || typeof json !== 'object' || !Array.isArray(json.excludedPathClasses)) {
|
|
103
|
+
const err = new Error(`orphan policy at ${path} is malformed: expected { "excludedPathClasses": [...] }`);
|
|
104
|
+
err.exitCode = 1;
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
const entries = [];
|
|
108
|
+
for (let i = 0; i < json.excludedPathClasses.length; i++) {
|
|
109
|
+
const problem = validateEntry(json.excludedPathClasses[i], i);
|
|
110
|
+
if (problem) {
|
|
111
|
+
const err = new Error(`orphan policy at ${path} is malformed: ${problem}`);
|
|
112
|
+
err.exitCode = 1;
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
entries.push({
|
|
116
|
+
pathPrefix: normalisePrefix(json.excludedPathClasses[i].pathPrefix),
|
|
117
|
+
reason: json.excludedPathClasses[i].reason.trim(),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return { exists: true, path, entries };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Partition an orphan result list against the policy. Returns
|
|
124
|
+
// {
|
|
125
|
+
// kept: [...], // orphans that still count
|
|
126
|
+
// excluded: [...], // policy-excluded results
|
|
127
|
+
// exclusion: { // visibility payload — every
|
|
128
|
+
// total, // output surface renders this
|
|
129
|
+
// byPrefix: [{ pathPrefix, reason, count }] // (zero-count entries
|
|
130
|
+
// } // included: config drift stays visible)
|
|
131
|
+
// }
|
|
132
|
+
// A null/empty policy keeps everything and returns exclusion: null so
|
|
133
|
+
// callers can preserve the pre-policy output shape exactly.
|
|
134
|
+
export function applyOrphanPolicy(results, policy) {
|
|
135
|
+
if (!policy || !policy.exists || policy.entries.length === 0) {
|
|
136
|
+
return { kept: results, excluded: [], exclusion: null };
|
|
137
|
+
}
|
|
138
|
+
const counts = new Map(policy.entries.map((e) => [e.pathPrefix, 0]));
|
|
139
|
+
const kept = [];
|
|
140
|
+
const excluded = [];
|
|
141
|
+
for (const r of results) {
|
|
142
|
+
const match = policy.entries.find((e) => r.id.startsWith(e.pathPrefix));
|
|
143
|
+
if (match) {
|
|
144
|
+
counts.set(match.pathPrefix, counts.get(match.pathPrefix) + 1);
|
|
145
|
+
excluded.push({ ...r, excludedBy: match.pathPrefix });
|
|
146
|
+
} else {
|
|
147
|
+
kept.push(r);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
kept,
|
|
152
|
+
excluded,
|
|
153
|
+
exclusion: {
|
|
154
|
+
total: excluded.length,
|
|
155
|
+
byPrefix: policy.entries.map((e) => ({
|
|
156
|
+
pathPrefix: e.pathPrefix,
|
|
157
|
+
reason: e.reason,
|
|
158
|
+
count: counts.get(e.pathPrefix),
|
|
159
|
+
})),
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// One-line rendering shared by the orphans table footer, the eval table
|
|
165
|
+
// and the eval workState line, so the excluded amount reads identically
|
|
166
|
+
// everywhere: `87 excluded by policy: operator/knowledge/memory/ 44, ...`.
|
|
167
|
+
export function renderExclusionSummary(exclusion) {
|
|
168
|
+
if (!exclusion || exclusion.total === 0) return null;
|
|
169
|
+
const parts = exclusion.byPrefix
|
|
170
|
+
.filter((p) => p.count > 0)
|
|
171
|
+
.map((p) => `${p.pathPrefix} ${p.count}`);
|
|
172
|
+
return `${exclusion.total} excluded by policy: ${parts.join(', ')}`;
|
|
173
|
+
}
|
|
@@ -127,6 +127,35 @@ npm run reindex
|
|
|
127
127
|
```
|
|
128
128
|
|
|
129
129
|
|
|
130
|
+
## Orphan policy (path-class exclusions)
|
|
131
|
+
|
|
132
|
+
`pa knowledge orphans` lists documents with no inbound edges. Some document classes are operator+agent tier by design (local TODO, memory and note documents); a linkless document in one of those trees is correctly scoped, not an orphan to repair. The estate can declare those classes in an explicit policy file:
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
operator/knowledge/orphan-policy.json
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"version": 1,
|
|
141
|
+
"excludedPathClasses": [
|
|
142
|
+
{
|
|
143
|
+
"pathPrefix": "operator/knowledge/memory/",
|
|
144
|
+
"reason": "One line explaining why this class is operator-local."
|
|
145
|
+
}
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Every orphan-counting surface honours the same policy: the `orphans` verb, anything that shells it for a count (nightly housekeeping), and the retrieval-eval orphan gauge and its alarms. The rules, all deliberate:
|
|
151
|
+
|
|
152
|
+
- **Explicit prefixes only.** Each entry is a listed path prefix with a mandatory one-line `reason`. Blanket ignores (the whole knowledge tree, wildcards) are rejected as malformed. The file is the audit trail for the exclusion.
|
|
153
|
+
- **The excluded amount is always rendered** (for example `orphans: 139 (87 excluded by policy: operator/knowledge/memory/ 44, ...)`). The number never silently shrinks.
|
|
154
|
+
- **Named trade-off:** a document misfiled INTO an excluded tree stops appearing as an orphan. That is the price of the exclusion, and it is why the policy is a human-edited config rather than a hardcoded ignore.
|
|
155
|
+
- **Counting only.** The policy does not touch librarian sweep judgement; genuine knowledge is still routed out of excluded trees.
|
|
156
|
+
- A missing policy file means no exclusions. A malformed one fails loudly rather than being ignored, because silently dropping the policy would snap counts back up and re-fire alarms.
|
|
157
|
+
|
|
158
|
+
|
|
130
159
|
## Adding new knowledge
|
|
131
160
|
|
|
132
161
|
1. Create a domain directory under `operator/knowledge/` if it does not exist.
|
package/package.json
CHANGED