@stravica-ai/digital-operator-pa 0.1.11 → 0.1.13
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-cli/src/sync/git.mjs +24 -1
- package/dist/engineering-pa-cli/src/sync/run.mjs +45 -7
- 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
|
+
}
|
|
@@ -69,8 +69,31 @@ export async function fetchOrigin(runner, { cwd }) {
|
|
|
69
69
|
return runner.spawn('git', ['fetch', 'origin'], { cwd });
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// `--autostash` is load-bearing, not convenience. estate-sync commits
|
|
73
|
+
// FIRST (see run.mjs step 6), but only the registry-scoped IN-SCOPE dirty
|
|
74
|
+
// paths are ever staged — dirty paths under an excluded mount subtree
|
|
75
|
+
// (down-path deliveries of canonical-owned content, materialised into the
|
|
76
|
+
// workspace by library-sync on hosts that run no wrap-pr leg) legitimately
|
|
77
|
+
// remain unstaged at rebase time. A plain `git pull --rebase` refuses to
|
|
78
|
+
// start over those unstaged changes ("cannot pull with rebase: You have
|
|
79
|
+
// unstaged changes"), which wedged ops-01 twice on 2026-07-29
|
|
80
|
+
// (w-2026-07-29-019): exit 2 every tick while the sync's own
|
|
81
|
+
// push-race-lost commits piled up ahead and the workspace diverged
|
|
82
|
+
// (ahead-2/behind-5), taking every librarian sweep down with
|
|
83
|
+
// fail-clone-behind/dirty until a human ran stash -> rebase -> push ->
|
|
84
|
+
// pop by hand. `--autostash` is exactly that manual recovery, done by
|
|
85
|
+
// git itself: stash the unstaged (excluded-scope) dirt, rebase, pop.
|
|
86
|
+
// carry-home and housekeeping already pull with --autostash for the same
|
|
87
|
+
// reason.
|
|
88
|
+
//
|
|
89
|
+
// If popping the autostash conflicts (upstream changed the same excluded
|
|
90
|
+
// file differently), git completes the rebase, KEEPS the changes in the
|
|
91
|
+
// stash, and prints "applying autostash resulted in conflicts" — the
|
|
92
|
+
// working tree is left clean, the push still proceeds, and run.mjs
|
|
93
|
+
// detects that message and surfaces it in the audit record (see
|
|
94
|
+
// AUTOSTASH_CONFLICT_RE) rather than letting parked dirt go silent.
|
|
72
95
|
export async function pullRebase(runner, { cwd, branch }) {
|
|
73
|
-
return runner.spawn('git', ['pull', '--rebase', '--quiet', 'origin', branch], { cwd });
|
|
96
|
+
return runner.spawn('git', ['pull', '--rebase', '--autostash', '--quiet', 'origin', branch], { cwd });
|
|
74
97
|
}
|
|
75
98
|
|
|
76
99
|
export async function rebaseAbort(runner, { cwd }) {
|
|
@@ -25,8 +25,18 @@
|
|
|
25
25
|
// `git commit -m "estate-sync <ISO ts>" -m "<body>"`.
|
|
26
26
|
// (this happens BEFORE the pull — git pull --rebase refuses to run
|
|
27
27
|
// with uncommitted changes in the index or working tree)
|
|
28
|
-
// 7. git pull --rebase origin <branch> (fetch already done
|
|
28
|
+
// 7. git pull --rebase --autostash origin <branch> (fetch already done
|
|
29
|
+
// in step 2). --autostash matters: step 6 stages only IN-SCOPE dirty
|
|
30
|
+
// paths, so registry-excluded dirt (down-path deliveries of
|
|
31
|
+
// mount-owned content on hosts with no wrap-pr leg) legitimately
|
|
32
|
+
// remains unstaged here — without autostash the rebase refuses to
|
|
33
|
+
// start over it and the sync wedges with exit 2 on every tick
|
|
34
|
+
// (observed live on ops-01, 2026-07-29, w-2026-07-29-019).
|
|
29
35
|
// on rebase conflict: git rebase --abort + fail-rebase audit + exit 2
|
|
36
|
+
// (abort also restores the autostash). If the rebase succeeds but
|
|
37
|
+
// POPPING the autostash conflicts, git keeps the dirt in the stash
|
|
38
|
+
// and completes — we detect the message and record
|
|
39
|
+
// autostashConflict:true in the audit rather than going silent.
|
|
30
40
|
// 8. git push. on rejection: fetch + pull --rebase + push retry. one
|
|
31
41
|
// retry only.
|
|
32
42
|
// 9. append success audit to logs/runs.jsonl
|
|
@@ -89,6 +99,25 @@ function log(line, { stderr = process.stderr } = {}) {
|
|
|
89
99
|
stderr.write(`[estate-sync] ${line}\n`);
|
|
90
100
|
}
|
|
91
101
|
|
|
102
|
+
// Git's exact wording when a rebase completes but re-applying the
|
|
103
|
+
// autostash conflicts: "Applying autostash resulted in conflicts. Your
|
|
104
|
+
// changes are safe in the stash. ..." (sequencer.c, stable across
|
|
105
|
+
// versions). The rebase itself SUCCEEDS in this case (exit 0) — the
|
|
106
|
+
// excluded-scope dirt is parked in `git stash` and the working tree is
|
|
107
|
+
// left clean. That is a safe state (the next down-path re-materialises
|
|
108
|
+
// the same content), but it must never be silent: the audit record
|
|
109
|
+
// carries autostashConflict:true so a human can `git stash pop` / `drop`
|
|
110
|
+
// deliberately.
|
|
111
|
+
const AUTOSTASH_CONFLICT_RE = /applying autostash resulted in conflicts/i;
|
|
112
|
+
|
|
113
|
+
function detectAutostashConflict(res, stderr) {
|
|
114
|
+
const matched = AUTOSTASH_CONFLICT_RE.test(`${res.stdout || ''}\n${res.stderr || ''}`);
|
|
115
|
+
if (matched) {
|
|
116
|
+
log('rebase succeeded but applying the autostash conflicted — excluded-scope dirt is parked in `git stash` (run `git stash pop` or `git stash drop` deliberately)', { stderr });
|
|
117
|
+
}
|
|
118
|
+
return matched;
|
|
119
|
+
}
|
|
120
|
+
|
|
92
121
|
async function writeAudit(runsLog, record, stderr) {
|
|
93
122
|
try {
|
|
94
123
|
await appendFile(runsLog, JSON.stringify(record) + '\n');
|
|
@@ -317,8 +346,10 @@ export async function runSync({
|
|
|
317
346
|
// If fetch failed earlier, we'd be rebasing onto stale refs which
|
|
318
347
|
// either no-ops (if local refs already match) or fails spuriously.
|
|
319
348
|
let autoResolvedPaths = [];
|
|
349
|
+
let autostashConflict = false;
|
|
320
350
|
if (upfrontFetch.code === 0) {
|
|
321
351
|
const prRes = await pullRebase(runner, { cwd: repoRoot, branch });
|
|
352
|
+
autostashConflict = detectAutostashConflict(prRes, stderr) || autostashConflict;
|
|
322
353
|
if (prRes.code !== 0) {
|
|
323
354
|
log(`git pull --rebase failed: ${prRes.stderr.trim()}`, { stderr });
|
|
324
355
|
// Before aborting, see if the conflict is limited to files that
|
|
@@ -365,9 +396,12 @@ export async function runSync({
|
|
|
365
396
|
// pointing at remote refs (nothing to push). If we committed, we have
|
|
366
397
|
// at least one local-only commit. If we were just behind, pull
|
|
367
398
|
// brought us up to date — nothing to push.
|
|
368
|
-
const autoResolveFields =
|
|
369
|
-
|
|
370
|
-
|
|
399
|
+
const autoResolveFields = {
|
|
400
|
+
...(autoResolvedPaths.length > 0
|
|
401
|
+
? { autoResolved: autoResolvedPaths.length, autoResolvedPaths }
|
|
402
|
+
: {}),
|
|
403
|
+
...(autostashConflict ? { autostashConflict: true } : {}),
|
|
404
|
+
};
|
|
371
405
|
|
|
372
406
|
const { ahead: aheadAfter } = await aheadBehind(runner, { cwd: repoRoot });
|
|
373
407
|
const needsPush = Number.isFinite(aheadAfter) && aheadAfter > 0;
|
|
@@ -416,6 +450,7 @@ export async function runSync({
|
|
|
416
450
|
return 2;
|
|
417
451
|
}
|
|
418
452
|
const pr2 = await pullRebase(runner, { cwd: repoRoot, branch });
|
|
453
|
+
autostashConflict = detectAutostashConflict(pr2, stderr) || autostashConflict;
|
|
419
454
|
if (pr2.code !== 0) {
|
|
420
455
|
log(`git pull --rebase (for push-retry) failed: ${pr2.stderr.trim()}`, { stderr });
|
|
421
456
|
// Symmetric with step 7: if the push-retry rebase conflicts only
|
|
@@ -460,9 +495,12 @@ export async function runSync({
|
|
|
460
495
|
}
|
|
461
496
|
log('pushed after rebase', { stderr });
|
|
462
497
|
// Recompute so any auto-resolve during the push-retry is captured.
|
|
463
|
-
const autoResolveFieldsFinal =
|
|
464
|
-
|
|
465
|
-
|
|
498
|
+
const autoResolveFieldsFinal = {
|
|
499
|
+
...(autoResolvedPaths.length > 0
|
|
500
|
+
? { autoResolved: autoResolvedPaths.length, autoResolvedPaths }
|
|
501
|
+
: {}),
|
|
502
|
+
...(autostashConflict ? { autostashConflict: true } : {}),
|
|
503
|
+
};
|
|
466
504
|
await writeAudit(runsLog, {
|
|
467
505
|
ts: tsIso,
|
|
468
506
|
status: 'ok',
|
|
@@ -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