@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.
- package/dist/engineering-pa-cli/src/knowledge/cli.mjs +4 -0
- package/dist/engineering-pa-cli/src/knowledge/graph.mjs +57 -4
- package/dist/engineering-pa-cli/src/knowledge/rebuild.mjs +118 -0
- package/dist/engineering-pa-cli/src/launch/identity.mjs +78 -12
- package/dist/engineering-pa-core/docs/knowledge-scaffold.md +29 -2
- package/dist/engineering-pa-core/docs/tools/distill.md +25 -2
- package/dist/engineering-pa-core/src/distill/prompt.mjs +46 -10
- package/dist/engineering-pa-core/src/distill/run.mjs +11 -4
- package/dist/engineering-pa-core/src/knowledge/retrieval/backlink-indexer.mjs +102 -93
- package/dist/engineering-pa-core/src/knowledge/retrieval/graph-builder.mjs +181 -107
- package/dist/engineering-pa-core/src/knowledge/retrieval/links.mjs +445 -0
- package/dist/engineering-pa-core/src/knowledge/retrieval/write-atomic.mjs +36 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
45
|
-
- node ${indexBuilder}
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
*
|
|
59
|
-
* should use buildDistillPrompt() with
|
|
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
|
-
|
|
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 {
|
|
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> (${
|
|
169
|
-
log(
|
|
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.
|