@stravica-ai/digital-operator-pa 0.1.8 → 0.1.11

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.
@@ -63,6 +63,10 @@ Graph location: <repo-root>/operator/knowledge/.graph.json.
63
63
  Repo-root resolution: \$DAVE_REPO_ROOT / \$PA_REPO_ROOT, else walk upward
64
64
  from cwd looking for a .git entry.
65
65
 
66
+ The graph is a per-device artefact: untracked, and rebuilt locally by
67
+ library-sync-cycle's graph-rebuild leg. If it is absent, the first query
68
+ rebuilds it on demand (one stderr line, then the answer) rather than failing.
69
+
66
70
  JSON output shape (stable-by-convention — consumed by dispatch-briefer):
67
71
  { "verb": "<name>", ...verb-specific fields }
68
72
 
@@ -31,12 +31,65 @@ const GRAPH_RELATIVE_PATH = 'operator/knowledge/.graph.json';
31
31
 
32
32
  // --- loader ---------------------------------------------------------------
33
33
 
34
- export async function loadGraph({ repoRoot, graphPath } = {}) {
34
+ // SELF-HEAL ON A MISSING GRAPH (w-2026-07-29-007)
35
+ //
36
+ // Since the estate moved to per-device retrieval artefacts (w-2026-07-29-002),
37
+ // `.graph.json` is untracked and rebuilt locally by `library-sync-cycle`'s
38
+ // graph-rebuild leg. Any host that pulls the untracking commit before it runs a
39
+ // sync cycle -- an unmigrated laptop, a host that has been off during a move --
40
+ // therefore has a knowledge tree on disk and no graph over it, and every
41
+ // `pa knowledge` verb used to exit 3. Refusing to answer when the source data
42
+ // is right there is the wrong default for a retrieval tool.
43
+ //
44
+ // So a missing artefact triggers an on-demand build (same two builders, same
45
+ // order, same root as the scheduled leg) and the query is served. The artefact
46
+ // is a pure function of the tree, so this cannot produce a graph the scheduled
47
+ // leg would not have produced -- only a less well-stamped one, which the
48
+ // `coverage.builtBy` marker makes visible.
49
+ //
50
+ // GATE. Self-heal fires only when the caller passed a `repoRoot` and did NOT
51
+ // pass an explicit `graphPath`. An explicit `graphPath` means the caller is
52
+ // pointing at a specific artefact (a fixture, a snapshot, an inspection copy);
53
+ // the builders write to `<root>/operator/knowledge/.graph.json` and nowhere
54
+ // else, so building would silently answer a different question than the one
55
+ // asked. Those callers keep the exit-3 refusal.
56
+ //
57
+ // Set `autoBuild: false` to opt out entirely.
58
+ export async function loadGraph({ repoRoot, graphPath, autoBuild = true, stderr = process.stderr } = {}) {
35
59
  const path = graphPath ?? join(repoRoot, GRAPH_RELATIVE_PATH);
36
60
  if (!existsSync(path)) {
37
- const err = new Error(`knowledge graph not found at ${path}`);
38
- err.exitCode = 3;
39
- throw err;
61
+ const canBuild = autoBuild && Boolean(repoRoot) && graphPath == null;
62
+ if (!canBuild) {
63
+ const err = new Error(`knowledge graph not found at ${path}`);
64
+ err.exitCode = 3;
65
+ throw err;
66
+ }
67
+ try {
68
+ const { rebuildRetrievalArtefacts } = await import('./rebuild.mjs');
69
+ const built = await rebuildRetrievalArtefacts({ root: repoRoot });
70
+ stderr.write(
71
+ `[pa knowledge] no graph at ${path} -- built on demand ` +
72
+ `(${built.nodeCount} nodes, ${built.edgeCount} edges, ${built.durationMs}ms); ` +
73
+ 'retrieval artefacts are per-device and normally rebuilt by library-sync-cycle\n',
74
+ );
75
+ } catch (e) {
76
+ // Both errors, in one message: "graph missing" alone sends the reader
77
+ // looking for the artefact, when what they need to fix is why it could
78
+ // not be built.
79
+ const err = new Error(
80
+ `knowledge graph not found at ${path}, and the on-demand rebuild failed: ${e.message}`,
81
+ );
82
+ err.exitCode = 3;
83
+ err.cause = e;
84
+ throw err;
85
+ }
86
+ if (!existsSync(path)) {
87
+ const err = new Error(
88
+ `knowledge graph not found at ${path}, and the on-demand rebuild did not produce it`,
89
+ );
90
+ err.exitCode = 3;
91
+ throw err;
92
+ }
40
93
  }
41
94
  let raw;
42
95
  try {
@@ -0,0 +1,118 @@
1
+ // rebuild.mjs — in-process rebuild of the retrieval artefacts.
2
+ //
3
+ // WHY THIS EXISTS
4
+ // ---------------
5
+ // As of 2026-07-29 (estate ruling w-2026-07-29-002) `operator/knowledge/.graph.json`
6
+ // and `.backlinks.json` are PER-DEVICE artefacts: untracked by the library
7
+ // repos, and rebuilt locally by `library-sync-cycle`'s `graph-rebuild` leg
8
+ // immediately after the down-path materialises the tree they describe. Each
9
+ // device's graph then describes exactly the tree that device can see, instead
10
+ // of one build host's partial view being shipped to everyone.
11
+ //
12
+ // The migration has a gap. A host that pulls the untracking commit but has not
13
+ // yet run a sync cycle -- or whose cycle is not installed at all -- ends up with
14
+ // no artefact on disk, and every `pa knowledge` verb hard-failed with exit 3
15
+ // ("knowledge graph not found"). The retrieval tool going dark is a bad failure
16
+ // for a host whose whole job is to answer questions from the library, and the
17
+ // data needed to fix it is sitting right there on disk.
18
+ //
19
+ // So `loadGraph` self-heals: on a missing artefact it calls this module, which
20
+ // runs the same two builders the graph-rebuild leg runs, in the same order,
21
+ // against the same workspace root -- then serves the query. The artefact is a
22
+ // pure function of the tree, so building it on demand cannot produce anything
23
+ // the scheduled leg would not have produced.
24
+ //
25
+ // IN-PROCESS, NOT SPAWNED
26
+ // -----------------------
27
+ // The builders ship in the same package as this module, so we import and call
28
+ // them rather than spawning `node <script> --root <root>`. That removes a
29
+ // process launch (~50 ms of the total) and, more importantly, removes the
30
+ // `--root`-resolution hazard entirely: a spawned builder without `--root`
31
+ // resolves its default four levels up from its own script path, which for a
32
+ // package install lands inside the versioned `dist/` tree rather than the
33
+ // workspace (the "versioned-dist-path trap"). An imported function takes the
34
+ // root as an argument and cannot get this wrong.
35
+ //
36
+ // PROVENANCE
37
+ // ----------
38
+ // Both artefacts carry a `coverage` stamp naming which path built them. The
39
+ // scheduled leg writes `builtBy: 'library-sync-cycle:graph-rebuild'` plus the
40
+ // device's entitlement picture; this path writes `builtBy: 'loadGraph:on-demand'`
41
+ // and honestly reports `complete: null`, because a CLI invocation has no
42
+ // registry context and therefore cannot know which knowledge mounts this device
43
+ // is not entitled to. An on-demand graph is a fallback, not a substitute for
44
+ // the stamped one -- and it says so in its own output.
45
+
46
+ import { buildBacklinkIndex } from '../../../engineering-pa-core/src/knowledge/retrieval/backlink-indexer.mjs';
47
+ import { buildKnowledgeGraph } from '../../../engineering-pa-core/src/knowledge/retrieval/graph-builder.mjs';
48
+
49
+ /** Marks artefacts produced by the self-heal path rather than the scheduled leg. */
50
+ export const ON_DEMAND_BUILT_BY = 'loadGraph:on-demand';
51
+
52
+ /**
53
+ * The coverage stamp this path writes.
54
+ *
55
+ * Shape-compatible with the one `library-sync-cycle:graph-rebuild` writes so a
56
+ * reader can branch on the same fields, but honest about what it does not know:
57
+ * `complete: null` means "this build could not determine its own coverage",
58
+ * which is different from `false` ("it has a named hole"). `registryError`
59
+ * carries the reason in the same slot the scheduled leg uses, so the estate's
60
+ * `formatCoverageSummary` renders a truthful "coverage UNKNOWN (...)" line
61
+ * without special-casing.
62
+ *
63
+ * `rebuildDurationMs` stays null on this path: the stamp has to exist before the
64
+ * artefacts are serialised, so the elapsed time is not known yet. The caller
65
+ * gets it as a return value for its stderr line instead of the artefact
66
+ * carrying a number it would have to be rewritten to hold.
67
+ *
68
+ * @param {{workspace:string, builtOn?:string}} args
69
+ * @returns {object}
70
+ */
71
+ export function buildOnDemandCoverage({
72
+ workspace,
73
+ builtOn = new Date().toISOString(),
74
+ }) {
75
+ return {
76
+ builtOn,
77
+ deviceId: null,
78
+ builtBy: ON_DEMAND_BUILT_BY,
79
+ workspace,
80
+ sourceMarkdownCount: null,
81
+ rebuildDurationMs: null,
82
+ registryDir: null,
83
+ entitledRepos: [],
84
+ unentitledRepos: [],
85
+ unentitledKnowledgeMountTargets: [],
86
+ complete: null,
87
+ registryError: 'built on demand by pa knowledge -- no registry context, coverage not determined',
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Rebuild `.backlinks.json` then `.graph.json` over `root`.
93
+ *
94
+ * Order is a correctness property, not a preference: `graph-builder` reads the
95
+ * file `backlink-indexer` writes, so running them the other way round builds a
96
+ * graph from the previous run's backlinks.
97
+ *
98
+ * Throws whatever the builders throw (a missing `operator/knowledge/` yields
99
+ * ENOENT on the first write) -- the caller decides how loud to be.
100
+ *
101
+ * @param {{root:string, coverage?:object|null}} args
102
+ * @returns {Promise<{nodeCount:number, edgeCount:number, targetCount:number, durationMs:number, graphPath:string, backlinksPath:string}>}
103
+ */
104
+ export async function rebuildRetrievalArtefacts({ root, coverage }) {
105
+ const started = Date.now();
106
+ const stamp = coverage ?? buildOnDemandCoverage({ workspace: root });
107
+ const backlinks = await buildBacklinkIndex({ root, coverage: stamp });
108
+ const graph = await buildKnowledgeGraph({ root, coverage: stamp });
109
+ const durationMs = Date.now() - started;
110
+ return {
111
+ nodeCount: graph.nodeCount,
112
+ edgeCount: graph.edgeCount,
113
+ targetCount: backlinks.targetCount,
114
+ durationMs,
115
+ graphPath: graph.outPath,
116
+ backlinksPath: backlinks.outPath,
117
+ };
118
+ }
@@ -1,32 +1,98 @@
1
1
  // identity.mjs — pure: load + validate an agent identity from the operator roster.
2
- // Roster path: <repoRoot>/operator/knowledge/hr/roster/<name>.json
2
+ //
3
+ // Roster resolution is mount-aware (w-2026-07-29-007). Rosters live at two
4
+ // levels of the operator knowledge tree:
5
+ // 1. Flat (HQ) path: <repoRoot>/operator/knowledge/hr/roster/<name>.json
6
+ // 2. Team-mount subtrees: <repoRoot>/operator/knowledge/<team>/hr/roster/<name>.json
7
+ // (e.g. WSD's dex at operator/knowledge/wsd/hr/roster/dex.json)
8
+ //
9
+ // The flat path is checked first, then every team mount. A single hit --
10
+ // wherever it lives -- resolves. The SAME agent name present in more than one
11
+ // roster (flat + team, or two teams) is an ambiguity the operator must fix:
12
+ // the loader errors listing every matching path rather than silently picking
13
+ // one. Silent precedence is exactly the failure mode this guards against -- a
14
+ // stale flat copy shadowing a team roster (or vice versa) would launch an
15
+ // agent under the wrong identity with no signal.
16
+ //
3
17
  // Required fields: id, name, roleId, runFolder, status
4
- import { readFile } from 'node:fs/promises';
18
+ import { readFile, readdir } from 'node:fs/promises';
5
19
  import { join } from 'node:path';
6
20
 
7
21
  const REQUIRED_FIELDS = ['id', 'name', 'roleId', 'runFolder', 'status'];
8
22
 
9
- export async function loadIdentity(repoRoot, agentName) {
10
- const path = join(repoRoot, 'operator', 'knowledge', 'hr', 'roster', `${agentName}.json`);
11
- let raw;
23
+ const ROSTER_SEGMENTS = ['hr', 'roster'];
24
+
25
+ function flatRosterPath(repoRoot, agentName) {
26
+ return join(repoRoot, 'operator', 'knowledge', ...ROSTER_SEGMENTS, `${agentName}.json`);
27
+ }
28
+
29
+ // Enumerate team-mount roster candidates: operator/knowledge/<team>/hr/roster/<name>.json.
30
+ // Directory listing is sorted so error output is deterministic across
31
+ // filesystems. The flat tree's own `hr/` directory is skipped --
32
+ // `operator/knowledge/hr` is the flat roster's parent, not a team mount, and
33
+ // matching it would double-count the flat path.
34
+ async function teamRosterPaths(repoRoot, agentName) {
35
+ const knowledgeRoot = join(repoRoot, 'operator', 'knowledge');
36
+ let entries;
37
+ try {
38
+ entries = await readdir(knowledgeRoot, { withFileTypes: true });
39
+ } catch {
40
+ return []; // no knowledge tree at all -- nothing to scan
41
+ }
42
+ return entries
43
+ .filter((e) => e.isDirectory() && e.name !== 'hr')
44
+ .map((e) => e.name)
45
+ .sort()
46
+ .map((team) => join(knowledgeRoot, team, ...ROSTER_SEGMENTS, `${agentName}.json`));
47
+ }
48
+
49
+ // Read a roster file's raw content, distinguishing "not there" (null) from
50
+ // real read failures (thrown, exitCode 3).
51
+ async function readRosterFile(path, agentName) {
12
52
  try {
13
- raw = await readFile(path, 'utf8');
53
+ return await readFile(path, 'utf8');
14
54
  } catch (err) {
15
- if (err.code === 'ENOENT') {
16
- const e = new Error(`agent "${agentName}" not found in roster (${path})`);
17
- e.exitCode = 2;
18
- throw e;
19
- }
55
+ if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return null;
20
56
  const e = new Error(`could not read roster file for "${agentName}": ${err.message}`);
21
57
  e.exitCode = 3;
22
58
  throw e;
23
59
  }
60
+ }
61
+
62
+ export async function loadIdentity(repoRoot, agentName) {
63
+ const flatPath = flatRosterPath(repoRoot, agentName);
64
+ const candidates = [flatPath, ...(await teamRosterPaths(repoRoot, agentName))];
65
+
66
+ const hits = [];
67
+ for (const candidate of candidates) {
68
+ const content = await readRosterFile(candidate, agentName);
69
+ if (content !== null) hits.push({ path: candidate, content });
70
+ }
71
+
72
+ if (hits.length === 0) {
73
+ const e = new Error(
74
+ `agent "${agentName}" not found in roster (searched ${flatPath} and team mounts operator/knowledge/*/hr/roster/)`,
75
+ );
76
+ e.exitCode = 2;
77
+ throw e;
78
+ }
79
+ if (hits.length > 1) {
80
+ const e = new Error(
81
+ `agent "${agentName}" is ambiguous -- found in ${hits.length} rosters: ${hits
82
+ .map((h) => h.path)
83
+ .join(', ')}. Remove the duplicate roster entry (or rename one agent) before launching.`,
84
+ );
85
+ e.exitCode = 2;
86
+ throw e;
87
+ }
88
+
89
+ const { path: resolvedPath, content: raw } = hits[0];
24
90
 
25
91
  let data;
26
92
  try {
27
93
  data = JSON.parse(raw);
28
94
  } catch (err) {
29
- const e = new Error(`roster file for "${agentName}" is not valid JSON: ${err.message}`);
95
+ const e = new Error(`roster file for "${agentName}" is not valid JSON (${resolvedPath}): ${err.message}`);
30
96
  e.exitCode = 2;
31
97
  throw e;
32
98
  }
@@ -56,7 +56,13 @@ Use double-bracket syntax to link to other documents:
56
56
  See [[fermentation]] for details on lacto-fermentation.
57
57
  ```
58
58
 
59
- Wiki-links are resolved by searching for a file matching the target name (case-insensitive) across all knowledge domains. If multiple matches exist, the link resolves to the most recently updated one.
59
+ Wiki-links are resolved against the real file set, in this order:
60
+
61
+ 1. A target containing a `/` (`[[../wespa/spi-no-staging]]`, `[[engineering/standards/secrets]]`) is tried relative to the linking document first, then repo-root-relative, then relative to each knowledge root. An over-deep `../` chain is salvaged when exactly one file can be meant.
62
+ 2. A bare target (`[[fermentation]]`) matches on filename across all knowledge domains -- exact case first, case-insensitive second.
63
+ 3. If several files share the name, the one in the linking document's own directory wins, then the one in its own domain. If neither disambiguates, the link is reported as ambiguous and produces no edge -- the builder does not guess, because a guess silently changes what `related`, `path` and `orphans` answer.
64
+
65
+ A wiki-link that resolves to nothing is reported as dangling and produces no edge. It is never turned into a node: a graph that invents a document for every broken reference inflates itself and corrupts its own orphan statistics.
60
66
 
61
67
  ### Standard markdown links
62
68
 
@@ -66,6 +72,26 @@ Use standard markdown links for explicit paths:
66
72
  See [fermentation](techniques/fermentation.md) for details.
67
73
  ```
68
74
 
75
+ Markdown links resolve relative to the linking document. Unlike wiki-links, a markdown link to a file that is not present still produces a node -- that is deliberate, and is how a document the build host is not entitled to stays visible in the graph.
76
+
77
+ ### Machine-readable hubs (`index.json`)
78
+
79
+ A directory can carry an `index.json` listing its documents with curated descriptions:
80
+
81
+ ```json
82
+ {
83
+ "version": "1.0",
84
+ "patterns": [
85
+ { "id": "some-pattern", "path": "knowledge/engineering/patterns/some-pattern.md",
86
+ "description": "What it is and when it bites" }
87
+ ]
88
+ }
89
+ ```
90
+
91
+ Every `.md` path in such a file becomes an edge from the hub to the document, so a document listed in its domain's index is indexed for retrieval without also needing a prose link. Entry paths may be canonical-repo-relative (`knowledge/...`), repo-root-relative, or relative to the index file. A `path` naming a directory resolves to that directory's `README.md`. Hubs nest: each `index.json` is linked from the nearest `index.json` above it.
92
+
93
+ `index.json` files are hand-maintained source, not build output -- no builder writes them.
94
+
69
95
 
70
96
  ## Backlink indexing
71
97
 
@@ -89,7 +115,8 @@ The retrieval system builds a graph of document relationships based on links. Th
89
115
 
90
116
  The retrieval tools at `core/knowledge/retrieval/` provide:
91
117
 
92
- - **index-builder.mjs** -- Rebuilds all indexes (domain registry, backlinks, adjacency graph)
118
+ - **index-builder.mjs** -- Rebuilds the domain registry (`operator/knowledge/domains.json`)
119
+ - **backlink-indexer.mjs** / **graph-builder.mjs** -- Rebuild the per-device retrieval artefacts (`.backlinks.json`, `.graph.json`). These two are untracked and device-local: each host builds its own over the tree it can actually see, and `pa knowledge` rebuilds them on demand if they are missing. Do not commit them.
93
120
  - **search.mjs** -- Full-text search across all knowledge
94
121
  - **graph.mjs** -- Graph queries (neighbours, paths, clusters)
95
122
 
@@ -46,13 +46,36 @@ any file changes distill produced.
46
46
  2. Count markdown observations under `agents/*/staging/` (depth ≤ 2,
47
47
  excluding `_archived/`). If zero, append a `noop-empty-staging`
48
48
  audit record and exit 0.
49
- 3. Invoke `claude` with the librarian definition as an appended system
50
- prompt and the distillation instructions as the positional prompt.
49
+ 3. Invoke `claude` with an explicit `--model` (see Model selection below),
50
+ the librarian definition as an appended system prompt, and the
51
+ distillation instructions as the positional prompt.
51
52
  Output is written to `<repoRoot>/.cache/distill/logs/<ts>.log`.
52
53
  4. Append a one-line JSON audit record to
53
54
  `<repoRoot>/.cache/distill/logs/runs.jsonl` (schema below).
54
55
  5. Exit 0 (ok) or 3 (claude transport failure).
55
56
 
57
+ ### Model selection
58
+
59
+ The sweep passes `--model claude-opus-4-7` explicitly (operator ruling
60
+ 2026-07-29). Before the pin, no `--model` was passed and the Librarian
61
+ inherited whatever the host CLI defaulted to -- so the model running the
62
+ sweep was an accident of host configuration, and a host-side default bump
63
+ silently changed sweep behaviour across the estate. The pin makes the
64
+ choice deliberate. The reasoning behind 4.7 specifically: it follows
65
+ briefs better than 4.8/5 in estate experience, and brief-following
66
+ matters more for a scripted sweep than raw capability.
67
+
68
+ Override per host or per run with the `DISTILL_MODEL` environment
69
+ variable:
70
+
71
+ ```
72
+ DISTILL_MODEL=claude-opus-4-8 pnpm exec distill run
73
+ ```
74
+
75
+ An empty or whitespace-only `DISTILL_MODEL` is treated as unset. The
76
+ dry-run plan reports the effective model and whether it came from the
77
+ pinned default or the override.
78
+
56
79
  ### Librarian definition resolution
57
80
 
58
81
  The system prompt appended to the sweep session is resolved in this
@@ -14,6 +14,11 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
14
14
  // Absolute paths to the bundled retrieval tools. From this module
15
15
  // (`packages/core/src/distill/prompt.mjs`) the retrieval tools live at
16
16
  // `../knowledge/retrieval/`.
17
+ //
18
+ // Only the index builder is named in the prompt. The backlink indexer and the
19
+ // graph builder are still exported here -- they are part of this module's
20
+ // public surface and callers outside the sweep use them -- but a distillation
21
+ // sweep must NOT run them; see step 4 for why.
17
22
  export const RETRIEVAL_INDEX_BUILDER = resolve(__dirname, '..', 'knowledge', 'retrieval', 'index-builder.mjs');
18
23
  export const RETRIEVAL_BACKLINK_INDEXER = resolve(__dirname, '..', 'knowledge', 'retrieval', 'backlink-indexer.mjs');
19
24
  export const RETRIEVAL_GRAPH_BUILDER = resolve(__dirname, '..', 'knowledge', 'retrieval', 'graph-builder.mjs');
@@ -22,11 +27,19 @@ export const RETRIEVAL_GRAPH_BUILDER = resolve(__dirname, '..', 'knowledge', 're
22
27
  * Build the distillation prompt text. Absolute paths are interpolated for the
23
28
  * retrieval-tool invocations so Claude doesn't have to guess at install
24
29
  * locations.
30
+ *
31
+ * `workspaceRoot` is interpolated as the builder's `--root`, and it is
32
+ * load-bearing. Without `--root` the builder falls back to `$DAVE_ROOT`, then to
33
+ * a path resolved four levels up from its own script -- which for a
34
+ * package-installed platform lands inside the versioned `dist/` tree, so the
35
+ * sweep writes its index INTO THE PACKAGE instead of the workspace (the
36
+ * "versioned-dist-path trap"). `runDistill` passes the repo root it has already
37
+ * resolved from prereqs; the default is this process's cwd, which is correct for
38
+ * the cron/bin invocations that construct the prompt with no arguments.
25
39
  */
26
40
  export function buildDistillPrompt({
27
41
  indexBuilder = RETRIEVAL_INDEX_BUILDER,
28
- backlinkIndexer = RETRIEVAL_BACKLINK_INDEXER,
29
- graphBuilder = RETRIEVAL_GRAPH_BUILDER,
42
+ workspaceRoot = process.cwd(),
30
43
  } = {}) {
31
44
  return `You are operating as the Librarian. Run a full distillation sweep now.
32
45
 
@@ -41,10 +54,12 @@ export function buildDistillPrompt({
41
54
 
42
55
  3. **Synthesise the operator profile narrative.** After updating operator/profile/operator.json, regenerate operator/profile/profile.md as a human-readable markdown narrative derived from the JSON plus the body of accumulated observations. This is the file the workspace and PA read first; it must always exist and reflect current state. Replace it in full each run — it is a derived artefact, not an append log.
43
56
 
44
- 4. **Rebuild indexes.** After all content changes, run these in order:
45
- - node ${indexBuilder}
46
- - node ${backlinkIndexer}
47
- - node ${graphBuilder}
57
+ 4. **Rebuild the domain index.** After all content changes, run exactly this one command:
58
+ - node ${indexBuilder} --root ${workspaceRoot}
59
+
60
+ The \`--root\` argument is required: without it the builder resolves a default root inside the installed package and writes the index there instead of into the workspace.
61
+
62
+ Do NOT rebuild, and do NOT commit, \`operator/knowledge/.backlinks.json\` or \`operator/knowledge/.graph.json\`. Since w-2026-07-29-002 those two artefacts are per-device and untracked -- every host rebuilds its own from the tree it can actually see, via library-sync-cycle's graph-rebuild leg -- so a sweep that rebuilds them commits one host's partial view of the library over everybody else's.
48
63
 
49
64
  5. **Library hygiene check.** Per your role definition — verify JSON camelCase, cross-reference integrity, index completeness, and lastUpdated freshness. Fix violations in place.
50
65
 
@@ -54,17 +69,38 @@ export function buildDistillPrompt({
54
69
  }
55
70
 
56
71
  /**
57
- * Back-compat constant: the prompt assembled with default (bundled) paths.
58
- * Existing consumers that import DISTILL_PROMPT keep working; new callers
59
- * should use buildDistillPrompt() with explicit overrides where needed.
72
+ * Back-compat constant: the prompt assembled with default (bundled) paths and
73
+ * this process's cwd as the workspace root. Existing consumers that import
74
+ * DISTILL_PROMPT keep working; new callers should use buildDistillPrompt() with
75
+ * an explicit `workspaceRoot`, as `runDistill` does -- the resolved repo root is
76
+ * more trustworthy than cwd at module-load time.
60
77
  */
61
78
  export const DISTILL_PROMPT = buildDistillPrompt();
62
79
 
63
- export function claudeArgs({ sessionName, librarianDef, prompt = DISTILL_PROMPT }) {
80
+ // The model the distillation sweep runs on, pinned explicitly (Baz ruling
81
+ // 2026-07-29). Before this pin, claudeArgs passed no `--model`, so the
82
+ // Librarian inherited whatever the host CLI happened to default to -- the
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
+ // claude-opus-4-7 follows briefs better than 4.8/5 in estate experience,
86
+ // which matters more for a scripted sweep than raw capability.
87
+ //
88
+ // Override per host or per run via the DISTILL_MODEL environment variable
89
+ // (documented in docs/tools/distill.md).
90
+ export const DEFAULT_DISTILL_MODEL = 'claude-opus-4-7';
91
+
92
+ export function resolveDistillModel(env = process.env) {
93
+ const override = env.DISTILL_MODEL;
94
+ if (typeof override === 'string' && override.trim() !== '') return override.trim();
95
+ return DEFAULT_DISTILL_MODEL;
96
+ }
97
+
98
+ export function claudeArgs({ sessionName, librarianDef, prompt = DISTILL_PROMPT, model = resolveDistillModel() }) {
64
99
  // Positional prompt last. No shell interpolation — this array is
65
100
  // handed directly to child_process.spawn.
66
101
  return [
67
102
  '--print',
103
+ '--model', model,
68
104
  '--permission-mode', 'bypassPermissions',
69
105
  '--no-session-persistence',
70
106
  '--name', sessionName,
@@ -37,7 +37,7 @@ import { basename, join } from 'node:path';
37
37
 
38
38
  import { resolvePrereqs } from './prereqs.mjs';
39
39
  import { countStagedObservations } from './staging.mjs';
40
- import { DISTILL_PROMPT, claudeArgs } from './prompt.mjs';
40
+ import { buildDistillPrompt, claudeArgs, resolveDistillModel } from './prompt.mjs';
41
41
 
42
42
  function log(line, { stderr = process.stderr } = {}) {
43
43
  stderr.write(`[distill] ${line}\n`);
@@ -116,6 +116,12 @@ export async function runDistill({
116
116
  }
117
117
  log(`found ${stagingCount} staged observation(s)`, { stderr });
118
118
 
119
+ // Assemble the sweep prompt against THIS host's resolved repo root. The
120
+ // index-builder invocation in step 4 carries it as `--root`; the module-level
121
+ // DISTILL_PROMPT default (cwd) is a fallback for callers that have not
122
+ // resolved a root, and a cron sweep always has.
123
+ const prompt = buildDistillPrompt({ workspaceRoot: repoRoot });
124
+
119
125
  // Step 3 — assemble & invoke.
120
126
  //
121
127
  // Two timestamp shapes for two jobs:
@@ -165,8 +171,9 @@ export async function runDistill({
165
171
  log(` appended system: ${librarianDef} (${librarianBase.length} bytes)`, { stderr });
166
172
  log(` operator supplement: ${supplementPlanLine}`, { stderr });
167
173
  log(` composed total: ${librarianIdentity.length} bytes`, { stderr });
168
- log(` prompt: <inline> (${DISTILL_PROMPT.length} bytes)`, { stderr });
169
- log(' command: claude --print --permission-mode bypassPermissions \\', { stderr });
174
+ log(` prompt: <inline> (${prompt.length} bytes, --root ${repoRoot})`, { stderr });
175
+ log(` model: ${resolveDistillModel()}${process.env.DISTILL_MODEL ? ' (DISTILL_MODEL override)' : ' (pinned default)'}`, { stderr });
176
+ log(` command: claude --print --model ${resolveDistillModel()} --permission-mode bypassPermissions \\`, { stderr });
170
177
  log(` --no-session-persistence --name ${sessionName} \\`, { stderr });
171
178
  log(' --append-system-prompt <librarian definition> \\', { stderr });
172
179
  log(' <distill prompt as positional arg>', { stderr });
@@ -177,7 +184,7 @@ export async function runDistill({
177
184
  log(`starting distillation sweep — log: ${runLogPath}`, { stderr });
178
185
  const start = Date.now();
179
186
 
180
- const claudeArgv = claudeArgs({ sessionName, librarianDef: librarianIdentity });
187
+ const claudeArgv = claudeArgs({ sessionName, librarianDef: librarianIdentity, prompt });
181
188
  const claudeResult = await runner.spawn('claude', claudeArgv, { cwd: repoRoot });
182
189
 
183
190
  // Persist claude stdout/stderr to the per-run log file.