@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.
@@ -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
- const results = orphans(graph, { includeStubs });
223
- process.stdout.write(formatOrphans(results, format));
224
- await logCall({ verb: 'orphans', query: null, resultCount: results.length });
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
- const orphanGauge = computeOrphanGauge(graph);
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
- function computeOrphanGauge(graph) {
306
- const results = orphans(graph, { includeStubs: true });
307
- const total = results.length;
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 results) {
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
- return { total, byDomain: perDomain };
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
- lines.push(`Orphans: ${runFile.orphans.total}`);
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
- export function formatOrphans(results, format) {
233
- if (format === 'json') return JSON.stringify({ verb: 'orphans', results }, null, 2) + '\n';
234
- if (format === 'mermaid') return renderOrphansMermaid(results);
235
- return renderOrphansTable(results);
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
- if (results.length === 0) return '(no orphans)\n';
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
- return renderTable(
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
- if (results.length === 0) return '%% no orphans\n';
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
- results.push({ id: node.id, title: node.title, domain: node.domain, outDegree: adj.out.get(node.id)?.length ?? 0 });
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 in step 2)
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 = autoResolvedPaths.length > 0
369
- ? { autoResolved: autoResolvedPaths.length, autoResolvedPaths }
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 = autoResolvedPaths.length > 0
464
- ? { autoResolved: autoResolvedPaths.length, autoResolvedPaths }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Digital Operator PA — single-bundled public package surface (bins + library) for the platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",