@stravica-ai/digital-operator-pa 0.1.59 → 0.1.61
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/README.md +27 -3
- package/dist/engineering-pa-cli/src/knowledge/cli.mjs +64 -6
- package/dist/engineering-pa-cli/src/knowledge/graph.mjs +29 -5
- package/dist/engineering-pa-cli/src/secrets/commands/backup.mjs +127 -14
- package/dist/engineering-pa-cli/src/secrets/vault.mjs +9 -5
- package/dist/engineering-pa-core/docs/tools/distill.md +7 -5
- package/dist/engineering-pa-core/knowledge/platform-tooling/pa-cli-reference.md +1 -1
- package/dist/engineering-pa-core/src/distill/prompt.mjs +7 -4
- package/dist/engineering-pa-core/src/knowledge/retrieval/backlink-indexer.mjs +8 -17
- package/dist/engineering-pa-core/src/knowledge/retrieval/graph-builder.mjs +16 -22
- package/dist/engineering-pa-core/src/knowledge/retrieval/links.mjs +161 -13
- package/dist/engineering-pa-core/src/knowledge/retrieval/mounts.mjs +184 -0
- package/package.json +1 -1
|
@@ -106,8 +106,8 @@ pa work list [--project <id>] [--status ...] [--category ...]
|
|
|
106
106
|
|
|
107
107
|
### `pa knowledge` — query the operator's knowledge graph
|
|
108
108
|
|
|
109
|
-
Reads `<repo-root>/operator/knowledge/.graph.json` and exposes five
|
|
110
|
-
for
|
|
109
|
+
Reads `<repo-root>/operator/knowledge/.graph.json` and exposes five query
|
|
110
|
+
verbs for the operator's knowledge system, plus `rebuild` for the graph itself. Feeds the third leg of the
|
|
111
111
|
PA operating model — knowledge retrieval discipline — so agents can
|
|
112
112
|
extract the right snippet at the right size instead of reading whole
|
|
113
113
|
files.
|
|
@@ -118,8 +118,31 @@ pa knowledge search <term> [--full] # find docs matching a term
|
|
|
118
118
|
pa knowledge domain <name> # all docs in a domain + internal edges
|
|
119
119
|
pa knowledge orphans [--exclude-stubs] # docs with no inbound edges
|
|
120
120
|
pa knowledge path <a> <b> # shortest path between two docs
|
|
121
|
+
pa knowledge rebuild [--format json] # delete and rebuild the graph; prints nodes, edges, ms
|
|
121
122
|
```
|
|
122
123
|
|
|
124
|
+
The graph is per-device and untracked. The first query builds it when it
|
|
125
|
+
is absent; nothing schedules a rebuild, so run `pa knowledge rebuild`
|
|
126
|
+
after the knowledge tree changes.
|
|
127
|
+
|
|
128
|
+
**Scan roots.** The home `operator/knowledge/` and `core/knowledge/`, then
|
|
129
|
+
each clone in `LIBRARY_KNOWLEDGE_EXTRA_ROOTS` (colon-separated clone
|
|
130
|
+
roots). A clone is matched to its registry entry by its `origin` remote
|
|
131
|
+
(normalised against `gitRemote`), else by its directory basename, using
|
|
132
|
+
the company clone's `registry/repos.json` when that clone is among the
|
|
133
|
+
roots, else the clone's own. For every mount whose `to` lies under
|
|
134
|
+
`operator/knowledge/`, `<clone>/<from>` is read as workspace ids under
|
|
135
|
+
`<to>`: hq and company `knowledge/` at `operator/knowledge/`, wsd
|
|
136
|
+
`knowledge/` at `operator/knowledge/wsd/`, operator `voice/` at
|
|
137
|
+
`operator/knowledge/writing/`. The legacy `<clone>/operator/knowledge/`
|
|
138
|
+
form is still read during the path migration. On a colliding id the home
|
|
139
|
+
copy wins, then the mapped copy (higher registry `precedence` first), then
|
|
140
|
+
the legacy copy. Within one clone that holds both forms of an id, the
|
|
141
|
+
legacy copy wins over the mapped one until the migration removes it: every
|
|
142
|
+
writer has written the legacy form since the 2026-08-21 cutover, so it is
|
|
143
|
+
the newer copy. A clone with no registry entry is read in the legacy form
|
|
144
|
+
only.
|
|
145
|
+
|
|
123
146
|
Global flag: `--format <table|json|mermaid>` (default: `table`). The
|
|
124
147
|
`json` shape is stable-by-convention — `{ "verb": "<name>", ... }` —
|
|
125
148
|
so downstream roles (e.g. `dispatch-briefer`) can pipe query output
|
|
@@ -136,7 +159,8 @@ ranking (title exact > title substring > body match, tie-break on
|
|
|
136
159
|
edge count). Zero-result queries exit `0` — empty is a valid answer.
|
|
137
160
|
|
|
138
161
|
**Exit codes**: `0` ok, `1` usage, `2` unknown/ambiguous slug or
|
|
139
|
-
unknown domain, `3` IO (graph missing / unreadable
|
|
162
|
+
unknown domain, `3` IO (graph missing / unreadable, or `rebuild` could
|
|
163
|
+
not write the artefacts).
|
|
140
164
|
|
|
141
165
|
### `pa staging-alias` — canonical staging.<site> alias for CF Workers sites
|
|
142
166
|
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// cli.mjs — argv router for `pa knowledge <verb>`.
|
|
2
2
|
//
|
|
3
|
-
// Verbs (
|
|
3
|
+
// Verbs (7):
|
|
4
4
|
// pa knowledge related <slug> [--in|--out]
|
|
5
5
|
// pa knowledge search <term> [--full] [--strict]
|
|
6
6
|
// pa knowledge domain <name>
|
|
7
7
|
// pa knowledge orphans [--exclude-stubs]
|
|
8
8
|
// pa knowledge path <a> <b>
|
|
9
9
|
// pa knowledge eval [--fixture PATH] [--out DIR] [--full] [--format ...]
|
|
10
|
+
// pa knowledge rebuild [--format table|json]
|
|
10
11
|
//
|
|
11
12
|
// Global flag:
|
|
12
13
|
// --format <table|json|mermaid> default: table
|
|
@@ -17,9 +18,12 @@
|
|
|
17
18
|
//
|
|
18
19
|
// Exit codes (tooling standard §6):
|
|
19
20
|
// 0 ok | 1 usage | 2 unknown-slug / ambiguous / unknown-domain
|
|
20
|
-
// 3 IO (graph missing or unreadable)
|
|
21
|
+
// 3 IO (graph missing or unreadable, or the rebuild failed)
|
|
21
22
|
|
|
22
|
-
import {
|
|
23
|
+
import { rm } from 'node:fs/promises';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
import { BACKLINKS_RELATIVE_PATH, GRAPH_RELATIVE_PATH, getRepoRoot, loadGraph, related, search, searchCascade, domain, orphans, path } from './graph.mjs';
|
|
23
27
|
import { formatRelated, formatSearch, formatDomain, formatOrphans, formatPath } from './formatters.mjs';
|
|
24
28
|
import { loadOrphanPolicy, applyOrphanPolicy } from './orphan-policy.mjs';
|
|
25
29
|
import { appendCallLog } from './call-log.mjs';
|
|
@@ -58,6 +62,10 @@ Usage:
|
|
|
58
62
|
[--out D] orphan gauge, delta vs last run);
|
|
59
63
|
[--full] persists to
|
|
60
64
|
[--format ...] operator/knowledge/_meta/retrieval-eval-runs/
|
|
65
|
+
pa knowledge rebuild Delete and rebuild the per-device graph
|
|
66
|
+
(.graph.json and .backlinks.json) over
|
|
67
|
+
the home tree and every extra scan root,
|
|
68
|
+
then print nodes, edges and milliseconds
|
|
61
69
|
pa knowledge -h, --help Show this help
|
|
62
70
|
|
|
63
71
|
Global flags:
|
|
@@ -73,9 +81,17 @@ Graph location: <repo-root>/operator/knowledge/.graph.json.
|
|
|
73
81
|
Repo-root resolution: \$DAVE_REPO_ROOT / \$PA_REPO_ROOT, else walk upward
|
|
74
82
|
from cwd looking for a .git entry.
|
|
75
83
|
|
|
76
|
-
The graph is a per-device artefact: untracked, and
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
The graph is a per-device artefact: untracked, and built locally. If it is
|
|
85
|
+
absent, the first query builds it on demand (one stderr line, then the
|
|
86
|
+
answer) rather than failing. Run \`pa knowledge rebuild\` to refresh it after
|
|
87
|
+
the knowledge tree changes; nothing schedules a rebuild.
|
|
88
|
+
|
|
89
|
+
Scan roots: the home operator/knowledge/ and core/knowledge/, then each
|
|
90
|
+
clone in \$LIBRARY_KNOWLEDGE_EXTRA_ROOTS (colon-separated). A clone with a
|
|
91
|
+
registry entry is read through its knowledge mounts (<clone>/<from> composed
|
|
92
|
+
at <to>, for every mount under operator/knowledge/), then in the legacy form
|
|
93
|
+
<clone>/operator/knowledge/. On a colliding id the home copy wins, then the
|
|
94
|
+
mapped copy, then the legacy copy.
|
|
79
95
|
|
|
80
96
|
JSON output shape (stable-by-convention — consumed by dispatch-briefer):
|
|
81
97
|
{ "verb": "<name>", ...verb-specific fields }
|
|
@@ -98,6 +114,7 @@ export async function run(argv) {
|
|
|
98
114
|
case 'domain': return await doDomain(rest);
|
|
99
115
|
case 'orphans': return await doOrphans(rest);
|
|
100
116
|
case 'path': return await doPath(rest);
|
|
117
|
+
case 'rebuild': return await doRebuild(rest);
|
|
101
118
|
case 'context': {
|
|
102
119
|
const { runContext } = await import('./context.mjs');
|
|
103
120
|
return await runContext(rest);
|
|
@@ -261,4 +278,45 @@ async function doPath(args) {
|
|
|
261
278
|
return 0;
|
|
262
279
|
}
|
|
263
280
|
|
|
281
|
+
async function doRebuild(args) {
|
|
282
|
+
const { positional, flags } = parseFlags(args);
|
|
283
|
+
const format = resolveFormat(flags);
|
|
284
|
+
if (positional.length > 0 || format === 'mermaid') {
|
|
285
|
+
process.stderr.write('usage: pa knowledge rebuild [--format table|json]\n');
|
|
286
|
+
return 1;
|
|
287
|
+
}
|
|
288
|
+
const repoRoot = await getRepoRoot();
|
|
289
|
+
for (const rel of [GRAPH_RELATIVE_PATH, BACKLINKS_RELATIVE_PATH]) {
|
|
290
|
+
try {
|
|
291
|
+
await rm(join(repoRoot, ...rel.split('/')), { force: true });
|
|
292
|
+
} catch (e) {
|
|
293
|
+
process.stderr.write(`[pa knowledge] could not delete ${rel}: ${e.message}\n`);
|
|
294
|
+
return 3;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// Same path as a first query on a missing graph: loadGraph builds it.
|
|
298
|
+
let built = null;
|
|
299
|
+
const silent = { write() { return true; } };
|
|
300
|
+
await loadGraph({ repoRoot, stderr: silent, onBuilt: (b) => { built = b; } });
|
|
301
|
+
if (!built) {
|
|
302
|
+
process.stderr.write('[pa knowledge] rebuild did not run: the graph was recreated by another process\n');
|
|
303
|
+
return 2;
|
|
304
|
+
}
|
|
305
|
+
if (format === 'json') {
|
|
306
|
+
process.stdout.write(JSON.stringify({
|
|
307
|
+
verb: 'rebuild',
|
|
308
|
+
graphPath: GRAPH_RELATIVE_PATH,
|
|
309
|
+
nodeCount: built.nodeCount,
|
|
310
|
+
edgeCount: built.edgeCount,
|
|
311
|
+
durationMs: built.durationMs,
|
|
312
|
+
}, null, 2) + '\n');
|
|
313
|
+
} else {
|
|
314
|
+
process.stdout.write(
|
|
315
|
+
`rebuilt ${GRAPH_RELATIVE_PATH}: ${built.nodeCount} nodes, ${built.edgeCount} edges, ${built.durationMs}ms\n`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
await logCall({ verb: 'rebuild', query: null, resultCount: built.nodeCount });
|
|
319
|
+
return 0;
|
|
320
|
+
}
|
|
321
|
+
|
|
264
322
|
export { USAGE };
|
|
@@ -26,9 +26,10 @@
|
|
|
26
26
|
import { readFile } from 'node:fs/promises';
|
|
27
27
|
import { existsSync } from 'node:fs';
|
|
28
28
|
import { join, resolve, dirname } from 'node:path';
|
|
29
|
-
import { resolveExtraScanRoots } from '../../../engineering-pa-core/src/knowledge/retrieval/links.mjs';
|
|
29
|
+
import { locateScannedFile, resolveExtraScanRoots, scanSourcesFor } from '../../../engineering-pa-core/src/knowledge/retrieval/links.mjs';
|
|
30
30
|
|
|
31
|
-
const GRAPH_RELATIVE_PATH = 'operator/knowledge/.graph.json';
|
|
31
|
+
export const GRAPH_RELATIVE_PATH = 'operator/knowledge/.graph.json';
|
|
32
|
+
export const BACKLINKS_RELATIVE_PATH = 'operator/knowledge/.backlinks.json';
|
|
32
33
|
|
|
33
34
|
// --- loader ---------------------------------------------------------------
|
|
34
35
|
|
|
@@ -55,8 +56,11 @@ const GRAPH_RELATIVE_PATH = 'operator/knowledge/.graph.json';
|
|
|
55
56
|
// else, so building would silently answer a different question than the one
|
|
56
57
|
// asked. Those callers keep the exit-3 refusal.
|
|
57
58
|
//
|
|
58
|
-
// Set `autoBuild: false` to opt out entirely.
|
|
59
|
-
|
|
59
|
+
// Set `autoBuild: false` to opt out entirely. `onBuilt` receives the build
|
|
60
|
+
// summary (counts and duration) when this call built the artefact;
|
|
61
|
+
// `pa knowledge rebuild` deletes the artefacts and reads its numbers here,
|
|
62
|
+
// so the explicit rebuild and the self-heal are one code path.
|
|
63
|
+
export async function loadGraph({ repoRoot, graphPath, autoBuild = true, stderr = process.stderr, onBuilt = null } = {}) {
|
|
60
64
|
const path = graphPath ?? join(repoRoot, GRAPH_RELATIVE_PATH);
|
|
61
65
|
if (!existsSync(path)) {
|
|
62
66
|
const canBuild = autoBuild && Boolean(repoRoot) && graphPath == null;
|
|
@@ -71,8 +75,9 @@ export async function loadGraph({ repoRoot, graphPath, autoBuild = true, stderr
|
|
|
71
75
|
stderr.write(
|
|
72
76
|
`[pa knowledge] no graph at ${path} -- built on demand ` +
|
|
73
77
|
`(${built.nodeCount} nodes, ${built.edgeCount} edges, ${built.durationMs}ms); ` +
|
|
74
|
-
'retrieval artefacts are per-device
|
|
78
|
+
'retrieval artefacts are per-device; `pa knowledge rebuild` refreshes them\n',
|
|
75
79
|
);
|
|
80
|
+
if (onBuilt) onBuilt(built);
|
|
76
81
|
} catch (e) {
|
|
77
82
|
// Both errors, in one message: "graph missing" alone sends the reader
|
|
78
83
|
// looking for the artefact, when what they need to fix is why it could
|
|
@@ -635,6 +640,15 @@ function guidedMissHint(graph, term) {
|
|
|
635
640
|
return { nearestDomains: ranked.slice(0, 3).map(([d]) => d) };
|
|
636
641
|
}
|
|
637
642
|
|
|
643
|
+
// `search --full` reads one body per node; resolving the scan list asks git
|
|
644
|
+
// for each clone's remote, so resolve it once per root and env value.
|
|
645
|
+
const scanSourcesCache = new Map();
|
|
646
|
+
function cachedScanSources(repoRoot) {
|
|
647
|
+
const key = `${repoRoot}\n${process.env.LIBRARY_KNOWLEDGE_EXTRA_ROOTS ?? ''}`;
|
|
648
|
+
if (!scanSourcesCache.has(key)) scanSourcesCache.set(key, scanSourcesFor(repoRoot));
|
|
649
|
+
return scanSourcesCache.get(key);
|
|
650
|
+
}
|
|
651
|
+
|
|
638
652
|
async function readBody(repoRoot, id) {
|
|
639
653
|
// The id is either a path relative to the repo root, or a bare slug.
|
|
640
654
|
// We try repoRoot/<id> first, then repoRoot/operator/knowledge/<id>.
|
|
@@ -645,6 +659,16 @@ async function readBody(repoRoot, id) {
|
|
|
645
659
|
// under the home repoRoot. The extra scan roots — same env var the
|
|
646
660
|
// builders read — are checked in declared order after the home root, so
|
|
647
661
|
// `--full` body search still finds the file.
|
|
662
|
+
//
|
|
663
|
+
// Registry mounts (w-2026-10-16-dave-019): the builders' own scan list is
|
|
664
|
+
// tried first, so a mapped id (`<clone>/knowledge/x.md` composed at
|
|
665
|
+
// `operator/knowledge/x.md`) opens the same copy the graph indexed.
|
|
666
|
+
const scanned = locateScannedFile(cachedScanSources(repoRoot), id);
|
|
667
|
+
if (scanned) {
|
|
668
|
+
try {
|
|
669
|
+
return await readFile(scanned, 'utf8');
|
|
670
|
+
} catch { return null; }
|
|
671
|
+
}
|
|
648
672
|
const roots = [repoRoot, ...resolveExtraScanRoots()];
|
|
649
673
|
for (const root of roots) {
|
|
650
674
|
const candidates = [
|
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
// backup — render an HTML file containing the master key as a QR code
|
|
2
|
-
// and open it in the default browser.
|
|
2
|
+
// and open it in the default browser.
|
|
3
3
|
//
|
|
4
4
|
// The QR-generation JS is the same minified generator used in
|
|
5
5
|
// onboarding.html, so the output looks identical. No browser automation,
|
|
6
6
|
// no npm deps.
|
|
7
|
+
//
|
|
8
|
+
// The page holds the age secret key in plaintext, so the file lives on
|
|
9
|
+
// disk only until the browser has read it (w-2026-10-17-dave-004). The
|
|
10
|
+
// old cleanup was an unref()ed 30s timer, which never fired because
|
|
11
|
+
// main() calls process.exit straight after the command returns: every
|
|
12
|
+
// run left a `dave-key-backup-*` directory with the key in the OS temp
|
|
13
|
+
// root. Now the command waits for the operator to confirm the page is
|
|
14
|
+
// showing (Enter on a TTY), capped at BACKUP_FILE_MAX_LIFETIME_MS, then
|
|
15
|
+
// wipes the file synchronously: overwrite with zeros of its own length,
|
|
16
|
+
// fsync, unlink, remove the directory. The same wipe runs from the
|
|
17
|
+
// finally block, a process 'exit' listener and SIGINT/SIGTERM/SIGHUP
|
|
18
|
+
// handlers, so a throw, an early Ctrl+C or a process.exit anywhere in
|
|
19
|
+
// the flow cannot leave the key behind. Only SIGKILL or a crash of the
|
|
20
|
+
// host can.
|
|
7
21
|
|
|
8
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
mkdtempSync, writeFileSync, existsSync, unlinkSync, readFileSync,
|
|
24
|
+
openSync, fstatSync, writeSync, fsyncSync, closeSync, rmSync,
|
|
25
|
+
} from 'node:fs';
|
|
9
26
|
import { join } from 'node:path';
|
|
10
|
-
import { tmpdir } from 'node:os';
|
|
27
|
+
import { tmpdir, constants as osConstants } from 'node:os';
|
|
11
28
|
import { spawn } from 'node:child_process';
|
|
12
29
|
import { defaultPaths, secretKeyFromKeyFile, VaultError } from '../vault.mjs';
|
|
13
30
|
import { HTML_HEAD, QR_JS, HTML_TAIL } from './backup-template.mjs';
|
|
@@ -25,10 +42,105 @@ export function buildBackupHtml(secretKey, publicKey) {
|
|
|
25
42
|
return HTML_HEAD + QR_JS + dataScript + HTML_TAIL;
|
|
26
43
|
}
|
|
27
44
|
|
|
45
|
+
// Hard cap on how long backup.html stays on disk when nobody confirms
|
|
46
|
+
// the page is showing. The browser needs the file only for its first
|
|
47
|
+
// read; the loaded page keeps working once the file is gone.
|
|
48
|
+
export const BACKUP_FILE_MAX_LIFETIME_MS = 60_000;
|
|
49
|
+
|
|
50
|
+
const WIPE_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Wipe the backup file and its directory, synchronously. Each step runs
|
|
54
|
+
* whatever the previous one did, so a missing file (a throw before the
|
|
55
|
+
* write) still removes the directory.
|
|
56
|
+
*/
|
|
57
|
+
export function wipeBackupDir(dir, htmlPath) {
|
|
58
|
+
try {
|
|
59
|
+
const fd = openSync(htmlPath, 'r+');
|
|
60
|
+
try {
|
|
61
|
+
const { size } = fstatSync(fd);
|
|
62
|
+
if (size > 0) {
|
|
63
|
+
const zeros = Buffer.alloc(size);
|
|
64
|
+
let written = 0;
|
|
65
|
+
while (written < size) written += writeSync(fd, zeros, written, size - written, written);
|
|
66
|
+
fsyncSync(fd);
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
closeSync(fd);
|
|
70
|
+
}
|
|
71
|
+
} catch { /* never written, or already gone */ }
|
|
72
|
+
try { unlinkSync(htmlPath); } catch { /* already gone */ }
|
|
73
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* already gone */ }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Register the wipe on every way out of the process and return it. The
|
|
78
|
+
* returned function is idempotent and deregisters the listeners, so the
|
|
79
|
+
* finally block, the 'exit' listener and a signal can all call it.
|
|
80
|
+
*/
|
|
81
|
+
function armWipe(dir, htmlPath) {
|
|
82
|
+
let wiped = false;
|
|
83
|
+
const signalHandlers = new Map();
|
|
84
|
+
const wipe = () => {
|
|
85
|
+
if (wiped) return;
|
|
86
|
+
wiped = true;
|
|
87
|
+
process.removeListener('exit', wipe);
|
|
88
|
+
for (const [sig, handler] of signalHandlers) process.removeListener(sig, handler);
|
|
89
|
+
wipeBackupDir(dir, htmlPath);
|
|
90
|
+
};
|
|
91
|
+
process.on('exit', wipe);
|
|
92
|
+
for (const sig of WIPE_SIGNALS) {
|
|
93
|
+
const handler = () => {
|
|
94
|
+
wipe();
|
|
95
|
+
process.exit(128 + (osConstants.signals[sig] || 0));
|
|
96
|
+
};
|
|
97
|
+
signalHandlers.set(sig, handler);
|
|
98
|
+
process.on(sig, handler);
|
|
99
|
+
}
|
|
100
|
+
return wipe;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Hold the file until the operator says the page is showing (Enter, or
|
|
105
|
+
* end of input, on a TTY) or the cap passes. Without a TTY there is no
|
|
106
|
+
* one to confirm, so the cap alone applies. The timer is deliberately
|
|
107
|
+
* NOT unref()ed: the process must stay alive to run the wipe.
|
|
108
|
+
*/
|
|
109
|
+
function defaultWaitForBrowser({ maxLifetimeMs, stdin = process.stdin }) {
|
|
110
|
+
const interactive = Boolean(stdin && stdin.isTTY);
|
|
111
|
+
const seconds = Math.ceil(maxLifetimeMs / 1000);
|
|
112
|
+
if (interactive) {
|
|
113
|
+
console.log(`Press Enter as soon as the page is showing. That wipes the file from disk; the open page keeps working (do not reload it). The file is wiped after ${seconds}s either way.`);
|
|
114
|
+
} else {
|
|
115
|
+
console.log(`The file is wiped from disk in ${seconds}s; the open page keeps working (do not reload it).`);
|
|
116
|
+
}
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
let timer = null;
|
|
119
|
+
const finish = () => {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
if (interactive) {
|
|
122
|
+
stdin.removeListener('data', finish);
|
|
123
|
+
stdin.removeListener('end', finish);
|
|
124
|
+
stdin.pause();
|
|
125
|
+
}
|
|
126
|
+
resolve();
|
|
127
|
+
};
|
|
128
|
+
timer = setTimeout(finish, maxLifetimeMs);
|
|
129
|
+
if (interactive) {
|
|
130
|
+
stdin.once('data', finish);
|
|
131
|
+
stdin.once('end', finish);
|
|
132
|
+
stdin.resume();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
28
137
|
export async function runBackup(overrides = {}) {
|
|
29
138
|
const paths = overrides.paths || defaultPaths(overrides);
|
|
30
139
|
const opener = overrides.opener || defaultOpener;
|
|
31
|
-
const
|
|
140
|
+
const waitForBrowser = overrides.waitForBrowser || defaultWaitForBrowser;
|
|
141
|
+
const maxLifetimeMs = overrides.maxLifetimeMs ?? BACKUP_FILE_MAX_LIFETIME_MS;
|
|
142
|
+
// process.stdin is only touched by the default waiter, and only then.
|
|
143
|
+
const stdin = overrides.stdin;
|
|
32
144
|
|
|
33
145
|
if (!existsSync(paths.keyFile)) {
|
|
34
146
|
// Missing key is an operational failure (exit 2), not an "uninitialised"
|
|
@@ -53,19 +165,20 @@ export async function runBackup(overrides = {}) {
|
|
|
53
165
|
const html = buildBackupHtml(secretKey, publicKey);
|
|
54
166
|
const dir = mkdtempSync(join(tmpdir(), 'dave-key-backup-'));
|
|
55
167
|
const htmlPath = join(dir, 'backup.html');
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
168
|
+
// Armed before the first byte of key material reaches disk.
|
|
169
|
+
const wipe = armWipe(dir, htmlPath);
|
|
170
|
+
try {
|
|
171
|
+
writeFileSync(htmlPath, html, { mode: 0o600 });
|
|
60
172
|
|
|
61
|
-
|
|
173
|
+
console.log('Opening master key backup in browser...');
|
|
174
|
+
console.log('IMPORTANT: Back up the key shown, then close the browser tab.');
|
|
62
175
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}, 30000).unref?.();
|
|
176
|
+
opener(htmlPath);
|
|
177
|
+
await waitForBrowser({ htmlPath, maxLifetimeMs, stdin });
|
|
178
|
+
} finally {
|
|
179
|
+
wipe();
|
|
68
180
|
}
|
|
181
|
+
console.log('Backup file wiped from disk.');
|
|
69
182
|
|
|
70
183
|
return { htmlPath, html };
|
|
71
184
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { spawn } from 'node:child_process';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync,
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
|
|
15
15
|
import { join, dirname, resolve, basename, sep } from 'node:path';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
17
|
import { mkdtempSync } from 'node:fs';
|
|
@@ -1155,8 +1155,8 @@ export async function sopsEncrypt(paths, pubKey, obj) {
|
|
|
1155
1155
|
// the vault's own .sops.yaml matches (path_regex: \.enc\.json$). If the
|
|
1156
1156
|
// suffix doesn't match, sops errors with "no matching creation rules".
|
|
1157
1157
|
const tmpFile = join(tmp, 'secrets.enc.json');
|
|
1158
|
-
writeFileSync(tmpFile, JSON.stringify(obj, null, 2) + '\n');
|
|
1159
1158
|
try {
|
|
1159
|
+
writeFileSync(tmpFile, JSON.stringify(obj, null, 2) + '\n');
|
|
1160
1160
|
// --config pins the sops config to the vault's own .sops.yaml so sops
|
|
1161
1161
|
// doesn't walk up from CWD and hit an unrelated ambient .sops.yaml
|
|
1162
1162
|
// (e.g. an older vault's config at a higher directory).
|
|
@@ -1168,7 +1168,10 @@ export async function sopsEncrypt(paths, pubKey, obj) {
|
|
|
1168
1168
|
}
|
|
1169
1169
|
writeFileSync(paths.secretsFile, stdout);
|
|
1170
1170
|
} finally {
|
|
1171
|
-
|
|
1171
|
+
// Remove the whole mkdtemp directory, not just the plaintext file:
|
|
1172
|
+
// unlinking the file alone left one empty vault-XXXXXX directory under
|
|
1173
|
+
// the OS temp root per encrypt (w-2026-10-15-dave-013).
|
|
1174
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
1172
1175
|
}
|
|
1173
1176
|
}
|
|
1174
1177
|
|
|
@@ -1224,8 +1227,8 @@ export async function decryptStoreForWrite(paths) {
|
|
|
1224
1227
|
export async function sopsEncryptScoped(paths, obj) {
|
|
1225
1228
|
const tmp = mkdtempSync(join(tmpdir(), 'vault-'));
|
|
1226
1229
|
const tmpFile = join(tmp, basename(paths.secretsFile));
|
|
1227
|
-
writeFileSync(tmpFile, JSON.stringify(obj, null, 2) + '\n');
|
|
1228
1230
|
try {
|
|
1231
|
+
writeFileSync(tmpFile, JSON.stringify(obj, null, 2) + '\n');
|
|
1229
1232
|
const { code, stdout, stderr } = await runCmd('sops', ['--config', paths.sopsConfig, '--encrypt', tmpFile], {
|
|
1230
1233
|
env: { SOPS_AGE_KEY_FILE: paths.keyFile },
|
|
1231
1234
|
});
|
|
@@ -1234,7 +1237,8 @@ export async function sopsEncryptScoped(paths, obj) {
|
|
|
1234
1237
|
}
|
|
1235
1238
|
writeFileSync(paths.secretsFile, stdout);
|
|
1236
1239
|
} finally {
|
|
1237
|
-
|
|
1240
|
+
// Same as sopsEncrypt: the directory goes too, on every path.
|
|
1241
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
1238
1242
|
}
|
|
1239
1243
|
}
|
|
1240
1244
|
|
|
@@ -56,14 +56,16 @@ any file changes distill produced.
|
|
|
56
56
|
|
|
57
57
|
### Model selection
|
|
58
58
|
|
|
59
|
-
The sweep passes `--model claude-opus-
|
|
60
|
-
2026-07-29). Before the pin, no `--model` was passed and the Librarian
|
|
59
|
+
The sweep passes `--model claude-opus-5-5` explicitly (operator ruling
|
|
60
|
+
2026-07-29 for the pin). Before the pin, no `--model` was passed and the Librarian
|
|
61
61
|
inherited whatever the host CLI defaulted to -- so the model running the
|
|
62
62
|
sweep was an accident of host configuration, and a host-side default bump
|
|
63
63
|
silently changed sweep behaviour across the estate. The pin makes the
|
|
64
|
-
choice deliberate. The
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
choice deliberate. The pinned model follows the estate model ladder:
|
|
65
|
+
`claude-opus-5-5` is the base model from the 2026-10-16 operator ruling.
|
|
66
|
+
It replaced `claude-opus-4-7` (now deprecated), which was first picked
|
|
67
|
+
because it followed briefs better than 4.8/5 in estate experience, and
|
|
68
|
+
brief-following matters more for a scripted sweep than raw capability.
|
|
67
69
|
|
|
68
70
|
Override per host or per run with the `DISTILL_MODEL` environment
|
|
69
71
|
variable:
|
|
@@ -17,7 +17,7 @@ The `pa` bin is the operator-facing top-level CLI. Every verb here is a subcomma
|
|
|
17
17
|
| `pa workspace <verb>` | Workspace server lifecycle (start / stop / status / restart / open). |
|
|
18
18
|
| `pa project <verb>` | Project state writes (create / status / doc add/rm/status / list). |
|
|
19
19
|
| `pa work <verb>` | Work-item state writes (create / status / list). |
|
|
20
|
-
| `pa knowledge <verb>` | Query the operator's knowledge graph (related / search / domain / orphans / path). |
|
|
20
|
+
| `pa knowledge <verb>` | Query the operator's knowledge graph (related / search / domain / orphans / path); `rebuild` deletes and rebuilds the per-device graph. |
|
|
21
21
|
| `pa autonomy <verb>` | Autonomy envelope get / set / list / init. |
|
|
22
22
|
| `pa environment [verb]` | Environment cache read / refresh / extended. |
|
|
23
23
|
| `pa lint <verb>` | Lint operator text against voice rules. |
|
|
@@ -81,13 +81,16 @@ export const DISTILL_PROMPT = buildDistillPrompt();
|
|
|
81
81
|
// 2026-07-29). Before this pin, claudeArgs passed no `--model`, so the
|
|
82
82
|
// Librarian inherited whatever the host CLI happened to default to -- the
|
|
83
83
|
// model choice was an accident of host config, and a host-side default bump
|
|
84
|
-
// silently changed sweep behaviour. The pin makes the choice deliberate
|
|
85
|
-
//
|
|
86
|
-
//
|
|
84
|
+
// silently changed sweep behaviour. The pin makes the choice deliberate.
|
|
85
|
+
// The pinned model follows the estate model ladder: claude-opus-5-5 is the
|
|
86
|
+
// base model from the 2026-10-16 operator ruling. It replaced claude-opus-4-7
|
|
87
|
+
// (now deprecated), which was first picked because it followed briefs better
|
|
88
|
+
// than 4.8/5, and brief-following matters more for a scripted sweep than raw
|
|
89
|
+
// capability.
|
|
87
90
|
//
|
|
88
91
|
// Override per host or per run via the DISTILL_MODEL environment variable
|
|
89
92
|
// (documented in docs/tools/distill.md).
|
|
90
|
-
export const DEFAULT_DISTILL_MODEL = 'claude-opus-
|
|
93
|
+
export const DEFAULT_DISTILL_MODEL = 'claude-opus-5-5';
|
|
91
94
|
|
|
92
95
|
export function resolveDistillModel(env = process.env) {
|
|
93
96
|
const override = env.DISTILL_MODEL;
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
|
|
26
26
|
import { readFile } from 'node:fs/promises';
|
|
27
27
|
import { realpathSync } from 'node:fs';
|
|
28
|
-
import { join, resolve, dirname
|
|
28
|
+
import { join, resolve, dirname } from 'node:path';
|
|
29
29
|
import { fileURLToPath } from 'node:url';
|
|
30
|
-
import { buildFileIndex, extractLinks, scanSourcesFor
|
|
30
|
+
import { buildFileIndex, collectScannedFiles, extractLinks, scanSourcesFor } from './links.mjs';
|
|
31
31
|
import { writeJsonAtomic } from './write-atomic.mjs';
|
|
32
32
|
import { findRepoRootSync } from '../../instance-root/find.mjs';
|
|
33
33
|
|
|
@@ -74,22 +74,13 @@ export async function buildBacklinkIndex({ root, coverage = null, extraRoots })
|
|
|
74
74
|
// files exist, so the whole tree is collected before any link is read.
|
|
75
75
|
//
|
|
76
76
|
// Sources: home root first, then env-supplied extra scan roots (Wave H,
|
|
77
|
-
// w-2026-08-24-dave-033)
|
|
78
|
-
//
|
|
79
|
-
//
|
|
77
|
+
// w-2026-08-24-dave-033), each in its registry-mapped form and then its
|
|
78
|
+
// legacy form (w-2026-10-16-dave-019). Each scan names the id prefix its
|
|
79
|
+
// files take, so ids are shape-compatible across the flat namespace. First
|
|
80
|
+
// in wins on collision: home, then mapped, then legacy, except that a
|
|
81
|
+
// clone's legacy copy beats its own mapped copy (`legacySupersedes`).
|
|
80
82
|
const sources = scanSourcesFor(root, { extraRoots });
|
|
81
|
-
const fileEntries =
|
|
82
|
-
const seenIds = new Set();
|
|
83
|
-
for (const { sourceRoot, scanDirs } of sources) {
|
|
84
|
-
for (const dir of scanDirs) {
|
|
85
|
-
for (const abs of await walkFiles(dir)) {
|
|
86
|
-
const id = toPosix(relative(sourceRoot, abs));
|
|
87
|
-
if (seenIds.has(id)) continue;
|
|
88
|
-
seenIds.add(id);
|
|
89
|
-
fileEntries.push({ abs, id });
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
83
|
+
const fileEntries = await collectScannedFiles(sources);
|
|
93
84
|
const fileIndex = buildFileIndex(fileEntries.map((e) => e.id));
|
|
94
85
|
|
|
95
86
|
// Pass 2 — build backlinks map: target -> [sources]
|
|
@@ -16,16 +16,17 @@
|
|
|
16
16
|
|
|
17
17
|
import { readFile } from 'node:fs/promises';
|
|
18
18
|
import { realpathSync } from 'node:fs';
|
|
19
|
-
import { join, resolve, dirname
|
|
19
|
+
import { join, resolve, dirname } from 'node:path';
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
21
|
import {
|
|
22
22
|
buildFileIndex,
|
|
23
23
|
buildIndexHubs,
|
|
24
|
+
collectScannedFiles,
|
|
24
25
|
deriveDomain,
|
|
25
26
|
extractLinks,
|
|
26
27
|
isIndexJson,
|
|
28
|
+
scanIdFor,
|
|
27
29
|
scanSourcesFor,
|
|
28
|
-
toPosix,
|
|
29
30
|
walkFiles,
|
|
30
31
|
} from './links.mjs';
|
|
31
32
|
import { writeJsonAtomic } from './write-atomic.mjs';
|
|
@@ -94,33 +95,26 @@ function reportUnresolved(label, entries) {
|
|
|
94
95
|
*/
|
|
95
96
|
export async function buildKnowledgeGraph({ root, coverage = null, extraRoots }) {
|
|
96
97
|
// Sources: home root first, then each extra scan root (env-supplied by
|
|
97
|
-
// default)
|
|
98
|
-
//
|
|
99
|
-
// same flat namespace as home.
|
|
98
|
+
// default) in its registry-mapped form, then in its legacy form. Each scan
|
|
99
|
+
// names the id prefix its files take, so ids are still shaped
|
|
100
|
+
// `operator/knowledge/...` and merge into the same flat namespace as home.
|
|
101
|
+
// First in wins on collision: home, then mapped, then legacy, except that a
|
|
102
|
+
// clone's legacy copy beats its own mapped copy (`legacySupersedes`).
|
|
100
103
|
const sources = scanSourcesFor(root, { extraRoots });
|
|
101
104
|
|
|
102
105
|
// ── Pass 1: enumerate the tree ────────────────────────────────────────
|
|
103
106
|
// Resolution needs the whole file set before any link is interpreted —
|
|
104
107
|
// `[[slug]]` cannot be resolved without knowing which files exist.
|
|
105
108
|
//
|
|
106
|
-
// Each entry pairs the on-disk path with
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
109
|
+
// Each entry pairs the on-disk path with its workspace id, so pass 2 can
|
|
110
|
+
// read the file from disk while emitting the id. Ids that collide keep the
|
|
111
|
+
// first occurrence (home, then mapped, then legacy), except that a clone's
|
|
112
|
+
// legacy copy replaces its own mapped copy (see collectScannedFiles).
|
|
113
|
+
const fileEntries = await collectScannedFiles(sources);
|
|
110
114
|
const indexEntries = [];
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
for (const abs of await walkFiles(dir)) {
|
|
115
|
-
const id = toPosix(relative(sourceRoot, abs));
|
|
116
|
-
if (seenIds.has(id)) continue;
|
|
117
|
-
seenIds.add(id);
|
|
118
|
-
fileEntries.push({ abs, id });
|
|
119
|
-
}
|
|
120
|
-
for (const abs of await walkFiles(dir, isIndexJson)) {
|
|
121
|
-
const id = toPosix(relative(sourceRoot, abs));
|
|
122
|
-
indexEntries.push({ abs, id });
|
|
123
|
-
}
|
|
115
|
+
for (const scan of sources) {
|
|
116
|
+
for (const abs of await walkFiles(scan.dir, isIndexJson)) {
|
|
117
|
+
indexEntries.push({ abs, id: scanIdFor(scan, abs) });
|
|
124
118
|
}
|
|
125
119
|
}
|
|
126
120
|
const fileIndex = buildFileIndex(fileEntries.map((e) => e.id));
|
|
@@ -33,8 +33,11 @@
|
|
|
33
33
|
// nowhere does not describe the estate — it inflates it, and every orphan
|
|
34
34
|
// statistic computed over it is wrong in both directions.
|
|
35
35
|
|
|
36
|
+
import { existsSync } from 'node:fs';
|
|
36
37
|
import { readdir } from 'node:fs/promises';
|
|
37
|
-
import { join, normalize, posix } from 'node:path';
|
|
38
|
+
import { join, normalize, posix, relative } from 'node:path';
|
|
39
|
+
|
|
40
|
+
import { resolveTierMounts } from './mounts.mjs';
|
|
38
41
|
|
|
39
42
|
// Knowledge trees the builders scan, repo-root-relative.
|
|
40
43
|
export const KNOWLEDGE_SCAN_DIRS = ['operator/knowledge', 'core/knowledge'];
|
|
@@ -57,6 +60,15 @@ export const KNOWLEDGE_SCAN_DIRS = ['operator/knowledge', 'core/knowledge'];
|
|
|
57
60
|
// matches the retirement's repatriation rule ("HOME-REPO copy wins for
|
|
58
61
|
// estate-native classes"); the intent is that canonical docs only ever live
|
|
59
62
|
// on ONE side, and any collision is a symptom that needs resolving upstream.
|
|
63
|
+
//
|
|
64
|
+
// Registry mounts (w-2026-10-16-dave-019): an extra root that is a tier clone
|
|
65
|
+
// is also read through its registry knowledge mounts, so `<clone>/knowledge/`
|
|
66
|
+
// composes at `operator/knowledge/` (or `operator/knowledge/wsd/`, and
|
|
67
|
+
// `<clone>/voice/` at `operator/knowledge/writing/`) exactly as the registry
|
|
68
|
+
// says. Across clones a mapped copy beats a legacy one; within one clone the
|
|
69
|
+
// legacy `<clone>/operator/knowledge/` copy beats the mapped copy during the
|
|
70
|
+
// migration bridge (`legacySupersedes`); home still beats both. See
|
|
71
|
+
// `scanSourcesFor` and mounts.mjs.
|
|
60
72
|
export const EXTRA_SCAN_ROOTS_ENV = 'LIBRARY_KNOWLEDGE_EXTRA_ROOTS';
|
|
61
73
|
|
|
62
74
|
/**
|
|
@@ -165,28 +177,164 @@ export function scanDirsFor(root) {
|
|
|
165
177
|
}
|
|
166
178
|
|
|
167
179
|
/**
|
|
168
|
-
* Full scan
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* disk the file lives).
|
|
180
|
+
* Full scan list, in collision-precedence order. Each entry names one
|
|
181
|
+
* directory to walk and the workspace-id prefix its files take, so every
|
|
182
|
+
* caller derives ids the same way (`scanIdFor`) and they stay shaped
|
|
183
|
+
* `operator/knowledge/...` regardless of where on disk the file lives.
|
|
173
184
|
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
185
|
+
* Order (first wins on a colliding id):
|
|
186
|
+
* 1. the home workspace root (`operator/knowledge/`, `core/knowledge/`);
|
|
187
|
+
* 2. the MAPPED form of every extra root that resolves to a registry entry:
|
|
188
|
+
* `<clone>/<from>` read as `<to>` for each mount whose `to` lies under
|
|
189
|
+
* `operator/knowledge/` (see mounts.mjs). Higher registry `precedence`
|
|
190
|
+
* first, declared order on a tie;
|
|
191
|
+
* 3. the LEGACY form of every extra root, `<clone>/operator/knowledge/` and
|
|
192
|
+
* `<clone>/core/knowledge/`, in declared order. Home and legacy ids are
|
|
193
|
+
* exactly the ids this function produced before mounts were read.
|
|
194
|
+
*
|
|
195
|
+
* So the home copy beats both forms, and a mapped copy beats another clone's
|
|
196
|
+
* legacy copy of the same id. Within ONE clone the legacy copy beats the
|
|
197
|
+
* mapped one; the order cannot say that, so the collision step applies it
|
|
198
|
+
* (`legacySupersedes`, `collectScannedFiles`). A clone with no registry entry
|
|
199
|
+
* contributes its legacy form only.
|
|
176
200
|
*
|
|
177
201
|
* @param {string} root the home workspace root
|
|
178
202
|
* @param {object} [opts]
|
|
179
203
|
* @param {NodeJS.ProcessEnv} [opts.env] defaults to process.env
|
|
180
204
|
* @param {string[]} [opts.extraRoots] override the env-resolved list (tests)
|
|
181
|
-
* @
|
|
205
|
+
* @param {(root: string) => string|null} [opts.remoteOf] override the git
|
|
206
|
+
* remote lookup (tests)
|
|
207
|
+
* @returns {{ sourceRoot: string, dir: string, idPrefix: string,
|
|
208
|
+
* form: 'home'|'mapped'|'legacy', repo: string|null }[]}
|
|
182
209
|
*/
|
|
183
210
|
export function scanSourcesFor(root, opts = {}) {
|
|
184
211
|
const extras = opts.extraRoots ?? resolveExtraScanRoots(opts.env);
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
212
|
+
const legacyScans = (sourceRoot, form, repo = null) => KNOWLEDGE_SCAN_DIRS.map((rel) => ({
|
|
213
|
+
sourceRoot,
|
|
214
|
+
dir: join(sourceRoot, ...rel.split('/')),
|
|
215
|
+
idPrefix: `${rel}/`,
|
|
216
|
+
form,
|
|
217
|
+
repo,
|
|
218
|
+
}));
|
|
219
|
+
|
|
220
|
+
const scans = legacyScans(root, 'home');
|
|
221
|
+
const tiers = extras.length ? resolveTierMounts(extras, { remoteOf: opts.remoteOf }) : [];
|
|
222
|
+
|
|
223
|
+
const mapped = tiers
|
|
224
|
+
.map((tier, order) => ({ tier, order }))
|
|
225
|
+
.filter(({ tier }) => tier.mounts.length > 0)
|
|
226
|
+
.sort((a, b) => (b.tier.precedence - a.tier.precedence) || (a.order - b.order));
|
|
227
|
+
for (const { tier } of mapped) {
|
|
228
|
+
for (const { from, to } of tier.mounts) {
|
|
229
|
+
scans.push({
|
|
230
|
+
sourceRoot: tier.root,
|
|
231
|
+
dir: join(tier.root, ...from.split('/').filter(Boolean)),
|
|
232
|
+
idPrefix: to,
|
|
233
|
+
form: 'mapped',
|
|
234
|
+
repo: tier.repo,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// slice 3 removes: the legacy `<clone>/operator/knowledge/` scan is the
|
|
240
|
+
// migration bridge for docs written at the workspace path (811 rows in the
|
|
241
|
+
// w-2026-10-16-dave-019 sizing). Drop it once those rows are re-put.
|
|
242
|
+
for (const tier of tiers) scans.push(...legacyScans(tier.root, 'legacy', tier.repo));
|
|
243
|
+
|
|
244
|
+
// An identity mount `operator/knowledge/ -> operator/knowledge/` would
|
|
245
|
+
// repeat its legacy scan; keep the first (mapped) occurrence only.
|
|
246
|
+
const seen = new Set();
|
|
247
|
+
return scans.filter((s) => {
|
|
248
|
+
const key = `${s.dir}\0${s.idPrefix}`;
|
|
249
|
+
if (seen.has(key)) return false;
|
|
250
|
+
seen.add(key);
|
|
251
|
+
return true;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Workspace id of a file found under `scan.dir`. */
|
|
256
|
+
export function scanIdFor(scan, abs) {
|
|
257
|
+
return scan.idPrefix + toPosix(relative(scan.dir, abs));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// slice 3 removes: the within-tier bridge rule. The scan order cannot carry
|
|
261
|
+
// it, because "legacy first within a clone" and "mapped first across clones"
|
|
262
|
+
// are not one order: with copies in A legacy, A mapped and B mapped (A of
|
|
263
|
+
// higher precedence) the two rules cycle. So the order picks the winning
|
|
264
|
+
// TIER (first occurrence: home, mapped by precedence, legacy), and this rule
|
|
265
|
+
// then swaps a mapped winner for the same clone's legacy copy. Every writer
|
|
266
|
+
// wrote the legacy form after the 2026-08-21 cutover, so where one clone
|
|
267
|
+
// holds both, the legacy copy is the newer (all 25 hq merge rows).
|
|
268
|
+
/**
|
|
269
|
+
* True when `candidate` (met later in scan order) should replace `winner`,
|
|
270
|
+
* the scan that first claimed the same id.
|
|
271
|
+
*
|
|
272
|
+
* @param {ReturnType<typeof scanSourcesFor>[number]} winner
|
|
273
|
+
* @param {ReturnType<typeof scanSourcesFor>[number]} candidate
|
|
274
|
+
*/
|
|
275
|
+
export function legacySupersedes(winner, candidate) {
|
|
276
|
+
return winner.form === 'mapped' && candidate.form === 'legacy'
|
|
277
|
+
&& winner.sourceRoot === candidate.sourceRoot;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Walk every scan and keep one file per workspace id: the first occurrence,
|
|
282
|
+
* except that a clone's legacy copy replaces that clone's mapped copy
|
|
283
|
+
* (`legacySupersedes`). A replaced entry keeps its position, so the entry
|
|
284
|
+
* order is still scan order of first occurrence.
|
|
285
|
+
*
|
|
286
|
+
* @param {ReturnType<typeof scanSourcesFor>} scans
|
|
287
|
+
* @returns {Promise<{ abs: string, id: string, scan: object }[]>}
|
|
288
|
+
*/
|
|
289
|
+
export async function collectScannedFiles(scans) {
|
|
290
|
+
const entries = [];
|
|
291
|
+
const byId = new Map();
|
|
292
|
+
for (const scan of scans) {
|
|
293
|
+
for (const abs of await walkFiles(scan.dir)) {
|
|
294
|
+
const id = scanIdFor(scan, abs);
|
|
295
|
+
const existing = byId.get(id);
|
|
296
|
+
if (!existing) {
|
|
297
|
+
const entry = { abs, id, scan };
|
|
298
|
+
byId.set(id, entry);
|
|
299
|
+
entries.push(entry);
|
|
300
|
+
} else if (legacySupersedes(existing.scan, scan)) {
|
|
301
|
+
existing.abs = abs;
|
|
302
|
+
existing.scan = scan;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return entries;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The on-disk file that backs a workspace id, under the same precedence the
|
|
311
|
+
* builders apply (including `legacySupersedes`), or null. Lets body readers (`pa knowledge search --full`)
|
|
312
|
+
* open the copy the graph actually indexed.
|
|
313
|
+
*
|
|
314
|
+
* @param {ReturnType<typeof scanSourcesFor>} scans
|
|
315
|
+
* @param {string} id
|
|
316
|
+
* @returns {string|null}
|
|
317
|
+
*/
|
|
318
|
+
export function locateScannedFile(scans, id) {
|
|
319
|
+
const posixId = toPosix(id);
|
|
320
|
+
const fileIn = (scan) => {
|
|
321
|
+
if (!posixId.startsWith(scan.idPrefix)) return null;
|
|
322
|
+
const rest = posixId.slice(scan.idPrefix.length);
|
|
323
|
+
if (!rest || escapesRoot(posix.normalize(rest))) return null;
|
|
324
|
+
const candidate = join(scan.dir, ...rest.split('/'));
|
|
325
|
+
return existsSync(candidate) ? candidate : null;
|
|
326
|
+
};
|
|
327
|
+
for (const [i, scan] of scans.entries()) {
|
|
328
|
+
const found = fileIn(scan);
|
|
329
|
+
if (!found) continue;
|
|
330
|
+
for (const later of scans.slice(i + 1)) {
|
|
331
|
+
if (!legacySupersedes(scan, later)) continue;
|
|
332
|
+
const legacy = fileIn(later);
|
|
333
|
+
if (legacy) return legacy;
|
|
334
|
+
}
|
|
335
|
+
return found;
|
|
188
336
|
}
|
|
189
|
-
return
|
|
337
|
+
return null;
|
|
190
338
|
}
|
|
191
339
|
|
|
192
340
|
// ── File index ──────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// mounts.mjs: registry knowledge mounts for the extra scan roots.
|
|
2
|
+
//
|
|
3
|
+
// Each tier clone named in LIBRARY_KNOWLEDGE_EXTRA_ROOTS is a library repo
|
|
4
|
+
// whose registry entry (`registry/repos.json`, `repos.<name>.mounts[]`) says
|
|
5
|
+
// which of its directories compose where in the workspace. The reader used to
|
|
6
|
+
// ignore that and scan only `<clone>/operator/knowledge/`, so the correctly
|
|
7
|
+
// mounted trees (`knowledge/ -> operator/knowledge/`,
|
|
8
|
+
// `knowledge/ -> operator/knowledge/wsd/`, `voice/ -> operator/knowledge/writing/`)
|
|
9
|
+
// were invisible while the mis-pathed legacy copies were visible
|
|
10
|
+
// (w-2026-10-16-dave-019 sizing).
|
|
11
|
+
//
|
|
12
|
+
// This module resolves each clone to its registry entry and returns the
|
|
13
|
+
// knowledge mounts the reader must apply: every mount whose `to` lies under
|
|
14
|
+
// `operator/knowledge/`. Mounts that target anything else are ignored.
|
|
15
|
+
//
|
|
16
|
+
// Resolution order for a clone:
|
|
17
|
+
// 1. its `origin` remote, normalised, against each entry's `gitRemote`;
|
|
18
|
+
// 2. else the clone directory basename against the repo key.
|
|
19
|
+
// Registry source order:
|
|
20
|
+
// 1. the company clone's `registry/repos.json`, when the company clone is
|
|
21
|
+
// among the extra roots (it is the clone whose own entry mounts
|
|
22
|
+
// `registry/`, the registry source of truth);
|
|
23
|
+
// 2. else the clone's own `registry/repos.json`;
|
|
24
|
+
// 3. else no registry: the clone is scanned in the legacy form only.
|
|
25
|
+
|
|
26
|
+
import { execFileSync } from 'node:child_process';
|
|
27
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
28
|
+
import { basename, join, resolve } from 'node:path';
|
|
29
|
+
|
|
30
|
+
export const KNOWLEDGE_MOUNT_PREFIX = 'operator/knowledge/';
|
|
31
|
+
export const REGISTRY_RELATIVE_PATH = 'registry/repos.json';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalise a git remote URL to `host/owner/repo` (lowercase, no `.git`), so
|
|
35
|
+
* `git@github.com:Stravica/x.git`, `ssh://git@github.com/Stravica/x` and
|
|
36
|
+
* `https://github.com/Stravica/x.git` compare equal.
|
|
37
|
+
*
|
|
38
|
+
* @param {string|null|undefined} url
|
|
39
|
+
* @returns {string|null}
|
|
40
|
+
*/
|
|
41
|
+
export function normaliseRemote(url) {
|
|
42
|
+
if (typeof url !== 'string') return null;
|
|
43
|
+
let s = url.trim();
|
|
44
|
+
if (!s) return null;
|
|
45
|
+
s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
|
|
46
|
+
s = s.replace(/^[^@/]+@/, '');
|
|
47
|
+
// scp-like `host:owner/repo`; a Windows drive letter is not a host.
|
|
48
|
+
s = s.replace(/^([^/:]{2,}):(?!\/)/, '$1/');
|
|
49
|
+
s = s.replace(/\/+$/, '').replace(/\.git$/i, '');
|
|
50
|
+
return s.toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Read the clone's `origin` URL, or null when it is not a git clone root. */
|
|
54
|
+
export function gitRemoteOf(root) {
|
|
55
|
+
// A directory below some other repo would report that repo's remote, so
|
|
56
|
+
// only ask git when the root itself carries the `.git` entry.
|
|
57
|
+
if (!existsSync(join(root, '.git'))) return null;
|
|
58
|
+
try {
|
|
59
|
+
return execFileSync('git', ['-C', root, 'remote', 'get-url', 'origin'], {
|
|
60
|
+
encoding: 'utf8',
|
|
61
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
62
|
+
timeout: 5000,
|
|
63
|
+
}).trim() || null;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Parse `<root>/registry/repos.json`; null when absent or unreadable. */
|
|
70
|
+
export function readRegistry(root) {
|
|
71
|
+
const path = join(root, ...REGISTRY_RELATIVE_PATH.split('/'));
|
|
72
|
+
if (!existsSync(path)) return null;
|
|
73
|
+
try {
|
|
74
|
+
const json = JSON.parse(readFileSync(path, 'utf8'));
|
|
75
|
+
if (!json || typeof json.repos !== 'object' || json.repos === null) return null;
|
|
76
|
+
return json;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Find the registry entry for a clone: remote first, basename second.
|
|
84
|
+
*
|
|
85
|
+
* @returns {{ name: string, entry: object, via: 'remote'|'basename' } | null}
|
|
86
|
+
*/
|
|
87
|
+
export function resolveRepoEntry(registry, root, { remoteOf = gitRemoteOf } = {}) {
|
|
88
|
+
if (!registry || !registry.repos) return null;
|
|
89
|
+
const remote = normaliseRemote(remoteOf(root));
|
|
90
|
+
if (remote) {
|
|
91
|
+
for (const [name, entry] of Object.entries(registry.repos)) {
|
|
92
|
+
if (normaliseRemote(entry?.gitRemote) === remote) return { name, entry, via: 'remote' };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const base = basename(resolve(root));
|
|
96
|
+
const entry = registry.repos[base];
|
|
97
|
+
if (entry && typeof entry === 'object') return { name: base, entry, via: 'basename' };
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const withSlash = (p) => (p.endsWith('/') ? p : `${p}/`);
|
|
102
|
+
|
|
103
|
+
function isSafeRelative(p) {
|
|
104
|
+
return !p.startsWith('/') && !p.split('/').includes('..');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The mounts the knowledge reader applies: `to` under `operator/knowledge/`,
|
|
109
|
+
* `from` a safe clone-relative directory. Both come back with a trailing `/`.
|
|
110
|
+
*
|
|
111
|
+
* @param {object} entry a `repos.<name>` registry entry
|
|
112
|
+
* @returns {{ from: string, to: string }[]}
|
|
113
|
+
*/
|
|
114
|
+
export function knowledgeMounts(entry) {
|
|
115
|
+
const out = [];
|
|
116
|
+
for (const m of Array.isArray(entry?.mounts) ? entry.mounts : []) {
|
|
117
|
+
if (typeof m?.from !== 'string' || typeof m?.to !== 'string') continue;
|
|
118
|
+
const from = withSlash(m.from.replace(/^\.\//, ''));
|
|
119
|
+
const to = withSlash(m.to.replace(/^\.\//, ''));
|
|
120
|
+
if (!to.startsWith(KNOWLEDGE_MOUNT_PREFIX)) continue;
|
|
121
|
+
if (!isSafeRelative(from) || !isSafeRelative(to)) continue;
|
|
122
|
+
out.push({ from, to });
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function mountsRegistry(entry) {
|
|
128
|
+
return (Array.isArray(entry?.mounts) ? entry.mounts : [])
|
|
129
|
+
.some((m) => typeof m?.from === 'string' && withSlash(m.from) === 'registry/');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolve every extra root to its tier and knowledge mounts.
|
|
134
|
+
*
|
|
135
|
+
* @param {string[]} extraRoots
|
|
136
|
+
* @param {object} [opts]
|
|
137
|
+
* @param {(root: string) => string|null} [opts.remoteOf] injectable for tests
|
|
138
|
+
* @returns {{
|
|
139
|
+
* root: string,
|
|
140
|
+
* repo: string|null,
|
|
141
|
+
* via: 'remote'|'basename'|null,
|
|
142
|
+
* registrySource: 'company'|'own'|null,
|
|
143
|
+
* precedence: number,
|
|
144
|
+
* mounts: { from: string, to: string }[],
|
|
145
|
+
* }[]}
|
|
146
|
+
*/
|
|
147
|
+
export function resolveTierMounts(extraRoots, { remoteOf = gitRemoteOf } = {}) {
|
|
148
|
+
const own = new Map();
|
|
149
|
+
for (const root of extraRoots) own.set(root, readRegistry(root));
|
|
150
|
+
|
|
151
|
+
// The company clone is the registry's own source: its entry mounts
|
|
152
|
+
// `registry/`. Prefer its copy over each clone's (possibly drifted) one.
|
|
153
|
+
let company = null;
|
|
154
|
+
for (const root of extraRoots) {
|
|
155
|
+
const registry = own.get(root);
|
|
156
|
+
const self = resolveRepoEntry(registry, root, { remoteOf });
|
|
157
|
+
if (self && mountsRegistry(self.entry)) { company = registry; break; }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return extraRoots.map((root) => {
|
|
161
|
+
let hit = null;
|
|
162
|
+
let registrySource = null;
|
|
163
|
+
if (company) {
|
|
164
|
+
hit = resolveRepoEntry(company, root, { remoteOf });
|
|
165
|
+
if (hit) registrySource = 'company';
|
|
166
|
+
}
|
|
167
|
+
if (!hit && own.get(root)) {
|
|
168
|
+
hit = resolveRepoEntry(own.get(root), root, { remoteOf });
|
|
169
|
+
if (hit) registrySource = 'own';
|
|
170
|
+
}
|
|
171
|
+
if (!hit) {
|
|
172
|
+
return { root, repo: null, via: null, registrySource: null, precedence: 0, mounts: [] };
|
|
173
|
+
}
|
|
174
|
+
const precedence = Number.isFinite(hit.entry.precedence) ? hit.entry.precedence : 0;
|
|
175
|
+
return {
|
|
176
|
+
root,
|
|
177
|
+
repo: hit.name,
|
|
178
|
+
via: hit.via,
|
|
179
|
+
registrySource,
|
|
180
|
+
precedence,
|
|
181
|
+
mounts: knowledgeMounts(hit.entry),
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
}
|
package/package.json
CHANGED