@stravica-ai/digital-operator-pa 0.1.31 → 0.1.32

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.
@@ -26,6 +26,7 @@
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
30
 
30
31
  const GRAPH_RELATIVE_PATH = 'operator/knowledge/.graph.json';
31
32
 
@@ -637,15 +638,25 @@ function guidedMissHint(graph, term) {
637
638
  async function readBody(repoRoot, id) {
638
639
  // The id is either a path relative to the repo root, or a bare slug.
639
640
  // We try repoRoot/<id> first, then repoRoot/operator/knowledge/<id>.
640
- const candidates = [
641
- resolve(repoRoot, id),
642
- resolve(repoRoot, 'operator', 'knowledge', id),
643
- ];
644
- for (const path of candidates) {
645
- if (existsSync(path)) {
646
- try {
647
- return await readFile(path, 'utf8');
648
- } catch { return null; }
641
+ //
642
+ // Extra scan roots (Wave H, w-2026-08-24-dave-033): a doc merged into the
643
+ // graph from a pull-clone at `~/stravica-library-clones/<tier>/` has an id
644
+ // shaped `operator/knowledge/<domain>/<file>.md` but is not resolvable
645
+ // under the home repoRoot. The extra scan roots — same env var the
646
+ // builders read — are checked in declared order after the home root, so
647
+ // `--full` body search still finds the file.
648
+ const roots = [repoRoot, ...resolveExtraScanRoots()];
649
+ for (const root of roots) {
650
+ const candidates = [
651
+ resolve(root, id),
652
+ resolve(root, 'operator', 'knowledge', id),
653
+ ];
654
+ for (const path of candidates) {
655
+ if (existsSync(path)) {
656
+ try {
657
+ return await readFile(path, 'utf8');
658
+ } catch { return null; }
659
+ }
649
660
  }
650
661
  }
651
662
  return null;
@@ -27,7 +27,7 @@ import { readFile } from 'node:fs/promises';
27
27
  import { realpathSync } from 'node:fs';
28
28
  import { join, resolve, dirname, relative } from 'node:path';
29
29
  import { fileURLToPath } from 'node:url';
30
- import { buildFileIndex, extractLinks, scanDirsFor, toPosix, walkFiles } from './links.mjs';
30
+ import { buildFileIndex, extractLinks, scanSourcesFor, toPosix, walkFiles } from './links.mjs';
31
31
  import { writeJsonAtomic } from './write-atomic.mjs';
32
32
 
33
33
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -64,23 +64,35 @@ Output: operator/knowledge/.backlinks.json
64
64
  * writes a minimal one. Readers treat it as optional either way.
65
65
  * @returns {Promise<{outPath:string, targetCount:number, unresolved:object[]}>}
66
66
  */
67
- export async function buildBacklinkIndex({ root, coverage = null }) {
67
+ export async function buildBacklinkIndex({ root, coverage = null, extraRoots }) {
68
68
  // Pass 1 — enumerate. `[[slug]]` cannot be resolved without knowing which
69
69
  // files exist, so the whole tree is collected before any link is read.
70
- const allFiles = [];
71
- for (const dir of scanDirsFor(root)) {
72
- allFiles.push(...await walkFiles(dir));
70
+ //
71
+ // Sources: home root first, then env-supplied extra scan roots (Wave H,
72
+ // w-2026-08-24-dave-033). Files are relativised against THEIR source root
73
+ // so ids are shape-compatible across the flat namespace. Home-wins on
74
+ // collision.
75
+ const sources = scanSourcesFor(root, { extraRoots });
76
+ const fileEntries = [];
77
+ const seenIds = new Set();
78
+ for (const { sourceRoot, scanDirs } of sources) {
79
+ for (const dir of scanDirs) {
80
+ for (const abs of await walkFiles(dir)) {
81
+ const id = toPosix(relative(sourceRoot, abs));
82
+ if (seenIds.has(id)) continue;
83
+ seenIds.add(id);
84
+ fileEntries.push({ abs, id });
85
+ }
86
+ }
73
87
  }
74
- const fileIds = allFiles.map((f) => toPosix(relative(root, f)));
75
- const fileIndex = buildFileIndex(fileIds);
88
+ const fileIndex = buildFileIndex(fileEntries.map((e) => e.id));
76
89
 
77
90
  // Pass 2 — build backlinks map: target -> [sources]
78
91
  const backlinks = {};
79
92
  const unresolved = [];
80
93
 
81
- for (let i = 0; i < allFiles.length; i++) {
82
- const content = await readFile(allFiles[i], 'utf8');
83
- const relSource = fileIds[i];
94
+ for (const { abs, id: relSource } of fileEntries) {
95
+ const content = await readFile(abs, 'utf8');
84
96
  const result = extractLinks(content, relSource, fileIndex);
85
97
  unresolved.push(...result.unresolved);
86
98
 
@@ -24,7 +24,7 @@ import {
24
24
  deriveDomain,
25
25
  extractLinks,
26
26
  isIndexJson,
27
- scanDirsFor,
27
+ scanSourcesFor,
28
28
  toPosix,
29
29
  walkFiles,
30
30
  } from './links.mjs';
@@ -87,20 +87,38 @@ function reportUnresolved(label, entries) {
87
87
  * @returns {Promise<object>} outPath, counts, diagnostics and the unresolved
88
88
  * entries the CLI reports
89
89
  */
90
- export async function buildKnowledgeGraph({ root, coverage = null }) {
91
- const scanDirs = scanDirsFor(root);
90
+ export async function buildKnowledgeGraph({ root, coverage = null, extraRoots }) {
91
+ // Sources: home root first, then each extra scan root (env-supplied by
92
+ // default). Files from an extra root are relativised against THEIR root,
93
+ // so their ids are still shaped `operator/knowledge/…` and merge into the
94
+ // same flat namespace as home. Home wins on collision (first-in policy).
95
+ const sources = scanSourcesFor(root, { extraRoots });
92
96
 
93
97
  // ── Pass 1: enumerate the tree ────────────────────────────────────────
94
98
  // Resolution needs the whole file set before any link is interpreted —
95
99
  // `[[slug]]` cannot be resolved without knowing which files exist.
96
- const allFiles = [];
97
- const indexPaths = [];
98
- for (const dir of scanDirs) {
99
- allFiles.push(...await walkFiles(dir));
100
- indexPaths.push(...await walkFiles(dir, isIndexJson));
100
+ //
101
+ // Each entry pairs the on-disk path with the source root it belongs to,
102
+ // so pass 2 can read the file from disk while emitting a source-relative
103
+ // id. Ids that collide keep the first occurrence (home-wins).
104
+ const fileEntries = [];
105
+ const indexEntries = [];
106
+ const seenIds = new Set();
107
+ for (const { sourceRoot, scanDirs } of sources) {
108
+ for (const dir of scanDirs) {
109
+ for (const abs of await walkFiles(dir)) {
110
+ const id = toPosix(relative(sourceRoot, abs));
111
+ if (seenIds.has(id)) continue;
112
+ seenIds.add(id);
113
+ fileEntries.push({ abs, id });
114
+ }
115
+ for (const abs of await walkFiles(dir, isIndexJson)) {
116
+ const id = toPosix(relative(sourceRoot, abs));
117
+ indexEntries.push({ abs, id });
118
+ }
119
+ }
101
120
  }
102
- const fileIds = allFiles.map((f) => toPosix(relative(root, f)));
103
- const fileIndex = buildFileIndex(fileIds);
121
+ const fileIndex = buildFileIndex(fileEntries.map((e) => e.id));
104
122
 
105
123
  // ── Pass 2: nodes + forward edges ─────────────────────────────────────
106
124
  const nodesMap = new Map();
@@ -115,10 +133,8 @@ export async function buildKnowledgeGraph({ root, coverage = null }) {
115
133
  edges.push({ from, to, type });
116
134
  };
117
135
 
118
- for (let i = 0; i < allFiles.length; i++) {
119
- const filePath = allFiles[i];
120
- const relPath = fileIds[i];
121
- const content = await readFile(filePath, 'utf8');
136
+ for (const { abs, id: relPath } of fileEntries) {
137
+ const content = await readFile(abs, 'utf8');
122
138
  const title = extractTitle(content) || relPath.split('/').pop().replace('.md', '');
123
139
 
124
140
  nodesMap.set(relPath, { id: relPath, title, domain: deriveDomain(relPath) });
@@ -130,8 +146,7 @@ export async function buildKnowledgeGraph({ root, coverage = null }) {
130
146
 
131
147
  // ── index.json hubs ───────────────────────────────────────────────────
132
148
  const indexFiles = [];
133
- for (const indexPath of indexPaths) {
134
- const id = toPosix(relative(root, indexPath));
149
+ for (const { abs: indexPath, id } of indexEntries) {
135
150
  let data;
136
151
  try {
137
152
  data = JSON.parse(await readFile(indexPath, 'utf8'));
@@ -180,7 +195,7 @@ export async function buildKnowledgeGraph({ root, coverage = null }) {
180
195
  version: '1.0',
181
196
  generatedAt: new Date().toISOString(),
182
197
  diagnostics: {
183
- markdownFiles: allFiles.length,
198
+ markdownFiles: fileEntries.length,
184
199
  indexHubs: {
185
200
  files: indexFiles.length,
186
201
  entries: hubs.entryCount,
@@ -39,6 +39,50 @@ import { join, normalize, posix } from 'node:path';
39
39
  // Knowledge trees the builders scan, repo-root-relative.
40
40
  export const KNOWLEDGE_SCAN_DIRS = ['operator/knowledge', 'core/knowledge'];
41
41
 
42
+ // Environment variable that names ADDITIONAL scan roots beyond the workspace
43
+ // root. Colon-separated absolute paths, each pointing at a directory shaped
44
+ // like a workspace root (i.e. containing an `operator/knowledge/` subtree).
45
+ //
46
+ // Motivation (Wave H, w-2026-08-24-dave-033): the REMOTE-mode read path is
47
+ // `~/stravica-library-clones/<tier>/`. Each tier repo carries its own
48
+ // `operator/knowledge/**` tree, and canonical knowledge lives in those tier
49
+ // repos — not in the home workspace. Without extra scan roots, `pa knowledge`
50
+ // on an operator host misses every canonical doc. Extra roots let the builders
51
+ // merge tier-repo content into the SAME graph the home tree produces, so node
52
+ // ids are still `operator/knowledge/<domain>/<file>.md` regardless of which
53
+ // root supplied the file.
54
+ //
55
+ // Collision policy: FIRST WINS. The home workspace root is scanned first, so
56
+ // a doc present in both home and a tier clone keeps the home copy. This
57
+ // matches the retirement's repatriation rule ("HOME-REPO copy wins for
58
+ // estate-native classes"); the intent is that canonical docs only ever live
59
+ // on ONE side, and any collision is a symptom that needs resolving upstream.
60
+ export const EXTRA_SCAN_ROOTS_ENV = 'LIBRARY_KNOWLEDGE_EXTRA_ROOTS';
61
+
62
+ /**
63
+ * Read + normalise the extra scan roots from the environment. Returns an
64
+ * array of absolute paths (deduped, in declared order). Missing / empty
65
+ * entries are silently skipped so a stray colon never manufactures a bogus
66
+ * root.
67
+ *
68
+ * @param {NodeJS.ProcessEnv} [env] defaults to process.env
69
+ * @returns {string[]}
70
+ */
71
+ export function resolveExtraScanRoots(env = process.env) {
72
+ const raw = env[EXTRA_SCAN_ROOTS_ENV];
73
+ if (typeof raw !== 'string' || raw.trim() === '') return [];
74
+ const seen = new Set();
75
+ const out = [];
76
+ for (const part of raw.split(':')) {
77
+ const trimmed = part.trim();
78
+ if (!trimmed) continue;
79
+ if (seen.has(trimmed)) continue;
80
+ seen.add(trimmed);
81
+ out.push(trimmed);
82
+ }
83
+ return out;
84
+ }
85
+
42
86
  const WIKI_RE = /\[\[([^\]]+)\]\]/g;
43
87
  const MD_LINK_RE = /\[([^\]]*)\]\(([^)]+)\)/g;
44
88
  const EXTERNAL_RE = /^(?:https?:\/\/|mailto:|#)/;
@@ -120,6 +164,31 @@ export function scanDirsFor(root) {
120
164
  return KNOWLEDGE_SCAN_DIRS.map((rel) => join(root, ...rel.split('/')));
121
165
  }
122
166
 
167
+ /**
168
+ * Full scan-source list: the home workspace root first, then every extra
169
+ * scan root from the environment. Each entry pairs a source root with the
170
+ * scan dirs it contributes, so callers relativise ids against the SOURCE
171
+ * root (yielding `operator/knowledge/…` shaped ids regardless of where on
172
+ * disk the file lives).
173
+ *
174
+ * Home comes first so the first-wins collision policy resolves in favour
175
+ * of the home workspace copy.
176
+ *
177
+ * @param {string} root the home workspace root
178
+ * @param {object} [opts]
179
+ * @param {NodeJS.ProcessEnv} [opts.env] defaults to process.env
180
+ * @param {string[]} [opts.extraRoots] override the env-resolved list (tests)
181
+ * @returns {{ sourceRoot: string, scanDirs: string[] }[]}
182
+ */
183
+ export function scanSourcesFor(root, opts = {}) {
184
+ const extras = opts.extraRoots ?? resolveExtraScanRoots(opts.env);
185
+ const sources = [{ sourceRoot: root, scanDirs: scanDirsFor(root) }];
186
+ for (const extra of extras) {
187
+ sources.push({ sourceRoot: extra, scanDirs: scanDirsFor(extra) });
188
+ }
189
+ return sources;
190
+ }
191
+
123
192
  // ── File index ──────────────────────────────────────────────────────────────
124
193
 
125
194
  function pushInto(map, key, value) {
@@ -18,6 +18,7 @@ import { runFlush } from './verbs/flush.mjs';
18
18
  import { runGet } from './verbs/get.mjs';
19
19
  import { runSearch } from './verbs/search.mjs';
20
20
  import { runStatus } from './verbs/status.mjs';
21
+ import { runRefreshClones } from './verbs/refresh-clones.mjs';
21
22
 
22
23
  const USAGE = `library — REMOTE-mode client for the remote-library service.
23
24
 
@@ -27,6 +28,7 @@ Usage:
27
28
  library get <path>
28
29
  library search <query> [--files-only]
29
30
  library status [--json]
31
+ library refresh-clones [--json] [--timeout-ms <n>] [--tier <name>]...
30
32
  library -h | --help
31
33
 
32
34
  Reads config from operator/config/preferences.json (+ preferences.local.json overlay).
@@ -86,6 +88,7 @@ export async function main(argv, seams = {}) {
86
88
  case 'get': return await runGet({ ...ctx, args: parseGet(rest) });
87
89
  case 'search': return await runSearch({ ...ctx, args: parseSearch(rest) });
88
90
  case 'status': return await runStatus({ ...ctx, args: parseStatus(rest) });
91
+ case 'refresh-clones': return await runRefreshClones({ ...ctx, args: parseRefreshClones(rest) });
89
92
  default:
90
93
  err(`[library] unknown verb: ${verb}`);
91
94
  log(USAGE);
@@ -140,3 +143,17 @@ function parseStatus(argv) {
140
143
  });
141
144
  return { json: values.json };
142
145
  }
146
+ function parseRefreshClones(argv) {
147
+ const { values } = parseArgs({
148
+ args: argv,
149
+ strict: true,
150
+ allowPositionals: false,
151
+ options: {
152
+ json: { type: 'boolean', default: false },
153
+ 'timeout-ms': { type: 'string' },
154
+ tier: { type: 'string', multiple: true },
155
+ },
156
+ });
157
+ const timeoutMs = values['timeout-ms'] ? parseInt(values['timeout-ms'], 10) : undefined;
158
+ return { json: values.json, timeoutMs, tiers: values.tier };
159
+ }
@@ -0,0 +1,153 @@
1
+ // verbs/refresh-clones.mjs — `library refresh-clones`
2
+ //
3
+ // REMOTE-mode session-boot verb: refreshes every tier clone under the
4
+ // pull-clones root by running `git fetch --quiet origin` then
5
+ // `git pull --ff-only --quiet origin main` for each. Called from the
6
+ // session-boot hook (Wave H, w-2026-08-24-dave-033) so `pa knowledge`
7
+ // reads the freshest projection of the tier repos.
8
+ //
9
+ // Design notes:
10
+ // - Q4 boot-only refresh: this verb is meant to be invoked from the
11
+ // session-boot path (Claude Code SessionStart hook, `pa environment`
12
+ // adjacency). It is NOT a cron / launchd job — the retirement plan
13
+ // is boot-only refresh v1.
14
+ // - Parallel per tier. Total wall time is dominated by the slowest
15
+ // tier's fetch; per-tier timeout keeps a wedged remote from
16
+ // blocking session start.
17
+ // - Refuses to touch a tier clone with uncommitted local changes
18
+ // (`git status --porcelain` non-empty) — pull-clones are a
19
+ // read-only projection per spec §3.3 and any local edit is a
20
+ // corruption signal, not a merge target.
21
+ // - Exit code 0 iff every tier refreshed successfully. Any per-tier
22
+ // failure surfaces on stderr and produces a non-zero exit so the
23
+ // session-boot hook logs it.
24
+
25
+ import { readdir, stat } from 'node:fs/promises';
26
+ import { join } from 'node:path';
27
+ import { spawn } from 'node:child_process';
28
+
29
+ import { pullClonesRootExists, resolvePullClonesRoot } from '../pull-clones.mjs';
30
+
31
+ const DEFAULT_TIER_TIMEOUT_MS = 20_000;
32
+
33
+ /**
34
+ * @param {object} ctx
35
+ * @param {object} ctx.config
36
+ * @param {object} [ctx.args] { json?, timeoutMs?, tiers? }
37
+ * @param {(...m:unknown[])=>void} [ctx.log]
38
+ * @param {(...m:unknown[])=>void} [ctx.err]
39
+ * @param {(args:string[], cwd:string, opts?:object)=>Promise<{code:number, stdout:string, stderr:string}>} [ctx.git]
40
+ * @returns {Promise<number>}
41
+ */
42
+ export async function runRefreshClones({
43
+ config,
44
+ args = {},
45
+ log = defaultLog,
46
+ err = defaultErr,
47
+ git = runGit,
48
+ }) {
49
+ const root = resolvePullClonesRoot({ preferences: config.preferences });
50
+ const timeoutMs = args.timeoutMs ?? DEFAULT_TIER_TIMEOUT_MS;
51
+
52
+ if (!pullClonesRootExists(root)) {
53
+ const payload = { root, tiers: [], error: 'pull-clones root not found' };
54
+ if (args.json) log(JSON.stringify(payload, null, 2));
55
+ else err(`[library refresh-clones] pull-clones root not found: ${root}`);
56
+ return 2;
57
+ }
58
+
59
+ const tiers = await enumerateTiers(root, args.tiers);
60
+ const results = await Promise.all(
61
+ tiers.map((tier) => refreshOne({ tier, git, timeoutMs })),
62
+ );
63
+
64
+ const failures = results.filter((r) => r.status !== 'ok');
65
+ const payload = { root, tiers: results, ok: failures.length === 0 };
66
+
67
+ if (args.json) {
68
+ log(JSON.stringify(payload, null, 2));
69
+ } else {
70
+ log(`pull-clones root: ${root} (${results.length} tier${results.length === 1 ? '' : 's'})`);
71
+ for (const r of results) {
72
+ const line = r.status === 'ok'
73
+ ? ` ${r.name.padEnd(32)} ok (${r.newCommits} new commit${r.newCommits === 1 ? '' : 's'})`
74
+ : ` ${r.name.padEnd(32)} ${r.status}${r.error ? ` (${r.error})` : ''}`;
75
+ log(line);
76
+ }
77
+ if (failures.length > 0) {
78
+ err(`[library refresh-clones] ${failures.length} tier${failures.length === 1 ? '' : 's'} failed`);
79
+ }
80
+ }
81
+ return failures.length === 0 ? 0 : 2;
82
+ }
83
+
84
+ async function enumerateTiers(root, only) {
85
+ let entries;
86
+ try {
87
+ entries = await readdir(root, { withFileTypes: true });
88
+ } catch { return []; }
89
+ const filter = only && only.length ? new Set(only) : null;
90
+ const out = [];
91
+ for (const entry of entries) {
92
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
93
+ if (filter && !filter.has(entry.name)) continue;
94
+ const full = join(root, entry.name);
95
+ const s = await stat(join(full, '.git')).catch(() => null);
96
+ if (!s) continue;
97
+ out.push({ name: entry.name, path: full });
98
+ }
99
+ return out.sort((a, b) => a.name.localeCompare(b.name));
100
+ }
101
+
102
+ async function refreshOne({ tier, git, timeoutMs }) {
103
+ const cwd = tier.path;
104
+
105
+ // Dirty check — refuse rather than clobber. Pull-clones are read-only
106
+ // projections; any local edit is a corruption signal that should be
107
+ // investigated, not merged over.
108
+ const dirty = await git(['status', '--porcelain'], cwd, { timeoutMs });
109
+ if (dirty.code !== 0) {
110
+ return { name: tier.name, status: 'error', error: `git status: ${(dirty.stderr || 'nonzero exit').slice(0, 160)}` };
111
+ }
112
+ if (dirty.stdout.trim() !== '') {
113
+ return { name: tier.name, status: 'dirty', error: 'uncommitted local changes; refusing to refresh' };
114
+ }
115
+
116
+ const fetched = await git(['fetch', '--quiet', 'origin'], cwd, { timeoutMs });
117
+ if (fetched.code !== 0) {
118
+ return { name: tier.name, status: 'error', error: `git fetch: ${(fetched.stderr || 'nonzero exit').slice(0, 160)}` };
119
+ }
120
+
121
+ const count = await git(['rev-list', 'HEAD..origin/main', '--count'], cwd, { timeoutMs });
122
+ const newCommits = count.code === 0 ? parseInt(count.stdout.trim(), 10) || 0 : 0;
123
+
124
+ if (newCommits === 0) {
125
+ return { name: tier.name, status: 'ok', newCommits: 0 };
126
+ }
127
+
128
+ const pulled = await git(['merge', '--ff-only', '--quiet', 'origin/main'], cwd, { timeoutMs });
129
+ if (pulled.code !== 0) {
130
+ return { name: tier.name, status: 'error', error: `git merge --ff-only: ${(pulled.stderr || 'nonzero exit').slice(0, 160)}` };
131
+ }
132
+ return { name: tier.name, status: 'ok', newCommits };
133
+ }
134
+
135
+ function runGit(argsArr, cwd, opts = {}) {
136
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIER_TIMEOUT_MS;
137
+ return new Promise((resolve) => {
138
+ const proc = spawn('git', argsArr, { cwd });
139
+ let stdout = '';
140
+ let stderr = '';
141
+ const killer = setTimeout(() => {
142
+ stderr += `\n[timeout after ${timeoutMs}ms]`;
143
+ try { proc.kill('SIGTERM'); } catch { /* ignore */ }
144
+ }, timeoutMs);
145
+ proc.stdout.on('data', (b) => { stdout += b.toString('utf8'); });
146
+ proc.stderr.on('data', (b) => { stderr += b.toString('utf8'); });
147
+ proc.on('error', (e) => { clearTimeout(killer); resolve({ code: -1, stdout, stderr: e.message }); });
148
+ proc.on('close', (code) => { clearTimeout(killer); resolve({ code: code ?? -1, stdout, stderr }); });
149
+ });
150
+ }
151
+
152
+ function defaultLog(...m) { process.stdout.write(m.join(' ') + '\n'); }
153
+ function defaultErr(...m) { process.stderr.write(m.join(' ') + '\n'); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "description": "Digital Operator PA \u2014 single-bundled public package surface (bins + library) for the platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",