@dotdrelle/wiki-manager 0.15.97 → 0.15.99

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.
Files changed (36) hide show
  1. package/package.json +2 -2
  2. package/src/agent/graph.js +45 -7
  3. package/src/agent/graph.test.js +45 -0
  4. package/src/cli/wiki-manager.js +47 -33
  5. package/src/commands/slash.js +7 -2
  6. package/src/contracts/schemas.js +8 -17
  7. package/src/contracts/schemas.test.js +15 -0
  8. package/src/core/agentEvents.js +45 -7
  9. package/src/core/agentEvents.test.js +65 -0
  10. package/src/core/buildInfo.json +2 -2
  11. package/src/core/mcp.js +1 -1
  12. package/src/core/runtimeEventAdapter.js +99 -1
  13. package/src/core/runtimeEventAdapter.test.js +92 -2
  14. package/src/core/skillCompiler.test.js +1 -1
  15. package/src/core/testGate.test.js +33 -0
  16. package/src/core/toolLoop.js +14 -2
  17. package/src/core/toolLoop.test.js +28 -0
  18. package/src/orchestrator/dispatcher.js +19 -0
  19. package/src/orchestrator/knowledgeSignals.js +260 -0
  20. package/src/orchestrator/knowledgeSignals.test.js +193 -0
  21. package/src/orchestrator/proactiveReviewScheduler.js +240 -0
  22. package/src/orchestrator/proactiveReviewScheduler.test.js +243 -0
  23. package/src/orchestrator/providers/deepAgentsProvider.js +134 -29
  24. package/src/orchestrator/providers/deepAgentsProvider.test.js +138 -3
  25. package/src/orchestrator/resultAggregator.js +115 -1
  26. package/src/orchestrator/resultAggregator.test.js +138 -0
  27. package/src/runtime/controlClassify.test.js +31 -0
  28. package/src/runtime/runner.js +13 -4
  29. package/src/runtime/runner.test.js +20 -0
  30. package/src/runtime/server.js +256 -4
  31. package/src/runtime/server.test.js +13 -1
  32. package/src/runtime/store.js +1 -1
  33. package/src/runtime/store.test.js +5 -1
  34. package/src/shell/openExternal.js +43 -0
  35. package/src/shell/repl.js +1 -1
  36. package/wiki-workspace +0 -1
@@ -23,6 +23,19 @@ export function mapRuntimeEvent(event) {
23
23
  const content = String(event?.content ?? event?.message ?? '').trim();
24
24
  return content ? [{ type: 'assistant_message', payload: { content } }] : [];
25
25
  }
26
+ // Progressive final stream (lot 7). The gateway only streams the MAIN
27
+ // assembly, never a role, so a delta can only belong to the answer. The
28
+ // reducer replaces the streamed text with the final `assistant_message`
29
+ // (finalizeAssistantMessage), so streaming cannot duplicate it.
30
+ case 'assistant_delta': {
31
+ const delta = String(event?.delta ?? '');
32
+ return delta ? [{ type: 'assistant_delta', payload: { delta } }] : [];
33
+ }
34
+ // A tool call interrupted the streamed answer: the text so far was
35
+ // reasoning, not the answer — discard it (the reducer empties, never pops,
36
+ // the streaming entry).
37
+ case 'assistant_delta_reset':
38
+ return [{ type: 'assistant_delta_reset', payload: {} }];
26
39
  case 'tool_started':
27
40
  return log(`tool ${toolLabel(event)} started`);
28
41
  case 'tool_finished': {
@@ -60,17 +73,102 @@ export function mapRuntimeEvent(event) {
60
73
  },
61
74
  }];
62
75
  }
76
+ // ── Activity (lot 2) ────────────────────────────────────────────────────
77
+ //
78
+ // The runtime's phases enrich the EXISTING business activity line; they do
79
+ // not open a second axis of "phases" beside `projectWorkflow`. That is why
80
+ // they travel as runtime_log here and are aggregated downstream, rather
81
+ // than minting a new event type the reducer would have to reconcile.
82
+ case 'phase_started':
83
+ return log(`phase ${phaseLabel(event)} started`);
84
+ case 'phase_finished': {
85
+ const counters = phaseCounters(event);
86
+ const outcome = event?.ok === false ? 'interrupted' : 'done';
87
+ return log(`phase ${phaseLabel(event)} ${outcome}${counters}`);
88
+ }
89
+ case 'progress': {
90
+ const label = String(event?.label ?? event?.phase ?? '').trim();
91
+ return label ? log(`progress ${label}${phaseCounters(event)}`) : [];
92
+ }
93
+ // A heartbeat is liveness, not history: it proves the run is alive to
94
+ // whoever is watching right now. It travels as its own NON-persisted event
95
+ // so the run strip can read it, but it never reaches the journal — one
96
+ // line per beat would bury what actually happened. Persisting is the
97
+ // store's decision (NON_PERSISTED_EVENT_TYPES); dropping it here would
98
+ // leave the strip with no liveness signal at all.
99
+ case 'heartbeat':
100
+ return [{
101
+ type: 'runtime_heartbeat',
102
+ payload: { elapsedMs: Number(event?.elapsedMs) || 0 },
103
+ }];
104
+ case 'finding': {
105
+ const severity = String(event?.severity ?? '').trim();
106
+ const summary = String(event?.summary ?? '').trim();
107
+ if (!summary) return [];
108
+ const path = String(event?.path ?? '').trim();
109
+ const where = path ? ` at ${path}` : '';
110
+ return log(`finding${severity ? ` [${severity}]` : ''} from ${String(event?.role ?? 'runtime')}${where}: ${summary}`);
111
+ }
112
+ // Maintenance the gateway performed on its own memory (eviction of an
113
+ // inactive workspace, compaction of a thread). Not a failure — a notice,
114
+ // so a reader can tell "the agent forgot an old workspace" from "the
115
+ // agent broke".
116
+ case 'notice': {
117
+ const topic = String(event?.topic ?? 'notice').trim();
118
+ const detail = String(event?.detail ?? '').trim();
119
+ return log(`notice ${topic}${detail ? `: ${detail}` : ''}`);
120
+ }
121
+ // A degradation must announce itself — that is the whole contract. It is
122
+ // never filtered, whatever else this adapter decides to keep quiet.
123
+ case 'degraded': {
124
+ const capability = String(event?.capability ?? 'capability').trim();
125
+ const cause = String(event?.cause ?? 'unknown cause').trim();
126
+ const fallback = String(event?.fallback ?? '').trim();
127
+ return log(`degraded ${capability}: ${cause}${fallback ? ` — ${fallback}` : ''}`);
128
+ }
63
129
  case 'run_started':
64
130
  case 'run_created':
65
131
  case 'agent_thinking':
66
132
  case 'run_completed':
67
133
  case 'run_failed':
68
134
  case 'run_cancelled':
69
- default:
135
+ // Deliberately silent, and listed BY NAME so the silence is a decision
136
+ // rather than a default: `agent_thinking` is private reasoning the chat
137
+ // never shows, and the terminal events are already carried by the
138
+ // dispatcher's own `status()` poll.
70
139
  return [];
140
+ default:
141
+ // Everything else is a type this manager does not know — most likely a
142
+ // newer gateway talking to an older manager. Dropping it made that
143
+ // version skew invisible: the events simply never arrived, and nothing
144
+ // said so. One bounded line is the cost of knowing.
145
+ return log(`unrecognized runtime event "${type || 'unnamed'}"${unknownDetail(event)}`);
71
146
  }
72
147
  }
73
148
 
149
+ function phaseLabel(event) {
150
+ return String(event?.phase ?? event?.label ?? 'unnamed');
151
+ }
152
+
153
+ function phaseCounters(event) {
154
+ const parts = [];
155
+ const tools = Number(event?.tools);
156
+ const pages = Number(event?.pages);
157
+ if (Number.isFinite(tools) && tools > 0) parts.push(`${tools} tool(s)`);
158
+ if (Number.isFinite(pages) && pages > 0) parts.push(`${pages} page(s) read`);
159
+ return parts.length > 0 ? ` — ${parts.join(', ')}` : '';
160
+ }
161
+
162
+ // Bounded on purpose: this is a diagnostic breadcrumb for a version skew, not
163
+ // a channel for an unknown payload to reach the journal whole.
164
+ const UNKNOWN_EVENT_DETAIL_MAX = 200;
165
+ function unknownDetail(event) {
166
+ const keys = Object.keys(event ?? {})
167
+ .filter((key) => !['type', 'runId', 'ts', 'sequence'].includes(key));
168
+ if (keys.length === 0) return '';
169
+ return ` (fields: ${keys.join(', ')})`.slice(0, UNKNOWN_EVENT_DETAIL_MAX);
170
+ }
171
+
74
172
  function log(message) {
75
173
  return [{ type: 'runtime_log', payload: { message } }];
76
174
  }
@@ -61,6 +61,96 @@ test('private reasoning and terminal events are never re-emitted', () => {
61
61
  assert.deepEqual(mapRuntimeEvent({ type: 'run_cancelled' }), []);
62
62
  });
63
63
 
64
- test('an unknown event type produces nothing', () => {
65
- assert.deepEqual(mapRuntimeEvent({ type: 'made_up' }), []);
64
+ // (An unknown type used to produce nothing. It now produces one journal line —
65
+ // see "an unknown event type is journalled instead of vanishing" below. The
66
+ // old assertion pinned the silence that hid a version skew.)
67
+
68
+ // ── Activity events (lot 2) ──────────────────────────────────────────────────
69
+
70
+ test('phase and progress events enrich the journal with bounded counters', () => {
71
+ assert.deepEqual(
72
+ mapRuntimeEvent({ type: 'phase_started', phase: 'discover' }),
73
+ [{ type: 'runtime_log', payload: { message: 'phase discover started' } }],
74
+ );
75
+ const [finished] = mapRuntimeEvent({
76
+ type: 'phase_finished', phase: 'discover', ok: true, tools: 7, pages: 4,
77
+ });
78
+ assert.match(finished.payload.message, /phase discover done — 7 tool\(s\), 4 page\(s\) read/);
79
+
80
+ const [interrupted] = mapRuntimeEvent({ type: 'phase_finished', phase: 'critique', ok: false });
81
+ assert.match(interrupted.payload.message, /phase critique interrupted/);
82
+ });
83
+
84
+ // A beat proves the run is alive to whoever watches NOW. It still travels — as
85
+ // its own non-persisted event, so the run strip can read it — but one journal
86
+ // line per beat would bury what actually happened under "still alive".
87
+ test('a heartbeat becomes a non-persisted liveness event, not a journal line', () => {
88
+ assert.deepEqual(
89
+ mapRuntimeEvent({ type: 'heartbeat', elapsedMs: 30_000 }),
90
+ [{ type: 'runtime_heartbeat', payload: { elapsedMs: 30_000 } }],
91
+ );
92
+ });
93
+
94
+ test('a finding carries its severity, its author and its path', () => {
95
+ const [entry] = mapRuntimeEvent({
96
+ type: 'finding',
97
+ role: 'critique',
98
+ severity: 'blocking',
99
+ path: 'wiki/concepts/demo/a.md',
100
+ summary: 'cites no source',
101
+ });
102
+ assert.match(
103
+ entry.payload.message,
104
+ /finding \[blocking\] from critique at wiki\/concepts\/demo\/a\.md: cites no source/,
105
+ );
106
+ });
107
+
108
+ test('a degradation is never filtered', () => {
109
+ const [entry] = mapRuntimeEvent({
110
+ type: 'degraded',
111
+ capability: 'role:critique',
112
+ cause: 'model timeout',
113
+ fallback: 'the run continues without this role',
114
+ });
115
+ assert.match(entry.payload.message, /degraded role:critique: model timeout — the run continues/);
116
+ });
117
+
118
+ /*
119
+ The version-skew guard. A newer gateway talking to an older manager used to
120
+ lose EVERY new event here, silently — the adapter ended on `default: return []`.
121
+ The deliberate silences stay silent, but they are now listed by name, so the
122
+ difference between "we chose not to show this" and "we did not recognise it"
123
+ is visible in the journal instead of being the same thing.
124
+ */
125
+ test('an unknown event type is journalled instead of vanishing', () => {
126
+ const [entry] = mapRuntimeEvent({ type: 'sub_phase_started', detail: 'x', weight: 2 });
127
+ assert.equal(entry.type, 'runtime_log');
128
+ assert.match(entry.payload.message, /unrecognized runtime event "sub_phase_started"/);
129
+ assert.match(entry.payload.message, /fields: detail, weight/);
130
+ });
131
+
132
+ test('the deliberate silences stay silent', () => {
133
+ for (const type of ['agent_thinking', 'run_started', 'run_completed', 'run_failed', 'run_cancelled']) {
134
+ assert.deepEqual(mapRuntimeEvent({ type }), [], `${type} must stay silent`);
135
+ }
136
+ });
137
+
138
+ test('a memory notice is journalled as maintenance, not as a failure', () => {
139
+ const [entry] = mapRuntimeEvent({
140
+ type: 'notice', topic: 'memory.evicted', detail: 'old-workspace',
141
+ });
142
+ assert.equal(entry.type, 'runtime_log');
143
+ assert.match(entry.payload.message, /^notice memory\.evicted: old-workspace$/);
144
+ });
145
+
146
+ test('the final stream maps as deltas, and a reset clears them', () => {
147
+ assert.deepEqual(
148
+ mapRuntimeEvent({ type: 'assistant_delta', delta: 'Hi' }),
149
+ [{ type: 'assistant_delta', payload: { delta: 'Hi' } }],
150
+ );
151
+ assert.deepEqual(
152
+ mapRuntimeEvent({ type: 'assistant_delta_reset' }),
153
+ [{ type: 'assistant_delta_reset', payload: {} }],
154
+ );
155
+ assert.deepEqual(mapRuntimeEvent({ type: 'assistant_delta', delta: '' }), []);
66
156
  });
@@ -31,7 +31,7 @@ test('validation rejects technical routing details', () => {
31
31
  });
32
32
 
33
33
  test('every shipped scaffold skill compiles to a single intention, deterministically', async () => {
34
- const expected = { pipeline: 1, 'wiki-sync': 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1, 'wiki-rebuild': 1 };
34
+ const expected = { pipeline: 1, 'wiki-sync': 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1, 'wiki-rebuild': 1, curate: 1 };
35
35
  // Passing no llmFallback used to make this test assert the one path
36
36
  // production never takes: an ambiguous body silently returns the safe
37
37
  // mono-intention fallback, so the count was 1 and the test was green while
@@ -0,0 +1,33 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { readFileSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
9
+
10
+ // The `npm test` argument list is hand-maintained, and it has drifted twice:
11
+ // four files were once found outside the gate, and mcpEndpoints.test.js was
12
+ // green on disk while nothing ran it. A test nobody runs is worse than no
13
+ // test — it reads as coverage. This is the check that says so.
14
+ test('every test file on disk is in the npm test gate', () => {
15
+ const pkg = JSON.parse(readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
16
+ const listed = new Set(
17
+ pkg.scripts.test.split(/\s+/).filter((argument) => argument.endsWith('.test.js')),
18
+ );
19
+ const onDisk = execFileSync('find', ['src', '-name', '*.test.js'], {
20
+ cwd: repoRoot,
21
+ encoding: 'utf8',
22
+ })
23
+ .trim()
24
+ .split('\n')
25
+ .filter(Boolean)
26
+ .sort();
27
+
28
+ const missing = onDisk.filter((file) => !listed.has(file));
29
+ assert.deepEqual(missing, [], `test files not run by \`npm test\`:\n${missing.join('\n')}`);
30
+
31
+ const stale = [...listed].filter((file) => !onDisk.includes(file));
32
+ assert.deepEqual(stale, [], `\`npm test\` names files that no longer exist:\n${stale.join('\n')}`);
33
+ });
@@ -1,3 +1,5 @@
1
+ import { truncateToolResult } from './mcp.js';
2
+
1
3
  // Minimal, side-effect-free bounded tool-use loop.
2
4
  //
3
5
  // This is the shared mechanic of "ask the LLM with a tool set, run the tool
@@ -79,9 +81,13 @@ export async function runBoundedToolLoop({
79
81
  convo.push(result.message ?? { role: 'assistant', content: result.content ?? '', tool_calls: calls });
80
82
  // Tool calls within one turn are independent: dispatch concurrently, then
81
83
  // replay results in the model's call order so the transcript stays stable.
84
+ // Bound what enters the LLM context, exactly like the /agent loop
85
+ // (graph.js). Without it a wide read — a CME Confluence search at limit 50
86
+ // can weigh ~35 kB — is re-sent on every iteration (up to the cap), and the
87
+ // chat answer pays for tokens the model never needed.
82
88
  const outcomes = await Promise.all(calls.map(async (call) => ({
83
89
  tool_call_id: call.id,
84
- content: await executeCall(call),
90
+ content: truncateToolResult(await executeCall(call)),
85
91
  })));
86
92
  for (const outcome of outcomes) {
87
93
  convo.push({ role: 'tool', tool_call_id: outcome.tool_call_id, content: outcome.content });
@@ -129,7 +135,13 @@ async function finalAnswerWithoutTools({
129
135
  });
130
136
  if (result?.tool_calls?.length) return '';
131
137
  return String(result?.content ?? result?.message?.content ?? '').trim();
132
- } catch {
138
+ } catch (err) {
139
+ // An abort is the user cancelling, not an empty answer. Swallowing it here
140
+ // made `runBoundedToolLoop` return `{ content: '', capped: true }`, and the
141
+ // caller printed the iteration-limit notice for a turn that was cancelled
142
+ // — the loop's contract is that an abort escapes, and this was the one
143
+ // call that broke it.
144
+ if (err?.name === 'AbortError' || signal?.aborted) throw err;
133
145
  return '';
134
146
  }
135
147
  }
@@ -104,6 +104,34 @@ test('answers from the gathered results when the cap is reached', async () => {
104
104
  assert.equal(out.content, "Voici ce que j'ai trouvé.");
105
105
  });
106
106
 
107
+ test('bounds a wide tool result before it enters the LLM context', async () => {
108
+ // A CME Confluence search at limit 50 can weigh ~35 kB and would otherwise be
109
+ // re-sent on every iteration. The /agent loop already truncates at 16 kB
110
+ // (graph.js); the chat loop must not be the one unbounded path.
111
+ let round = 0;
112
+ let toolContent = '';
113
+ const llm = {
114
+ async completeWithTools({ messages }) {
115
+ round += 1;
116
+ if (round === 1) {
117
+ const calls = [toolCall('c1', 'cme__cme_confluence_search')];
118
+ return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
119
+ }
120
+ toolContent = messages.find((m) => m.role === 'tool')?.content ?? '';
121
+ return { content: 'ok', tool_calls: [] };
122
+ },
123
+ };
124
+ const wide = 'x'.repeat(50000);
125
+ await runBoundedToolLoop({
126
+ llm,
127
+ tools: [{ function: { name: 'cme__cme_confluence_search' } }],
128
+ executeCall: async () => wide,
129
+ });
130
+ assert.ok(toolContent.length < wide.length, 'the result must be bounded');
131
+ assert.ok(toolContent.length <= 16200, `bounded length was ${toolContent.length}`);
132
+ assert.match(toolContent, /tronqu/);
133
+ });
134
+
107
135
  test('propagates an abort thrown by executeCall', async () => {
108
136
  const llm = {
109
137
  async completeWithTools() {
@@ -255,6 +255,7 @@ async function executeExternalRuntime(task, assignment, {
255
255
  capability: task.requiredCapability ?? null,
256
256
  arguments: task.arguments && typeof task.arguments === 'object' ? task.arguments : {},
257
257
  workspace: workspaceRequest(session),
258
+ memoryScope: memoryScopeRequest(session),
258
259
  model: activeProfileModel(session),
259
260
  language: session?.language ?? session?.wikircConfig?.language ?? null,
260
261
  mcp: mcpPool,
@@ -471,6 +472,24 @@ function executeRequest(task, session, runId, assignment) {
471
472
  };
472
473
  }
473
474
 
475
+ /**
476
+ * Which past conversation this run resumes, on the external runtime.
477
+ *
478
+ * The workspace alone today. The multi-user lot turns this into
479
+ * `<workspace>:<actorId>` — the shape is already the one the gateway accepts,
480
+ * so identity lands here and nowhere else. Returning null is legitimate and
481
+ * silent: the gateway then scopes to the workspace it resolved itself.
482
+ *
483
+ * Never a value a caller supplied: the runtime treats the scope as a read
484
+ * capability and refuses one that leaves its own workspace.
485
+ */
486
+ function memoryScopeRequest(session) {
487
+ const actorId = session?._currentRunIdentity?.actorId ?? session?.actorId ?? null;
488
+ if (!actorId) return null;
489
+ const workspace = workspaceRequest(session)?.name;
490
+ return workspace ? `${workspace}:${String(actorId)}` : null;
491
+ }
492
+
474
493
  function workspaceRequest(session) {
475
494
  const workspace = session.workspace ?? session._currentRunIdentity?.workspace;
476
495
  if (workspace && typeof workspace === 'object' && !Array.isArray(workspace)) return { ...workspace };
@@ -0,0 +1,260 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync } from 'node:fs';
3
+ import { join, relative, sep } from 'node:path';
4
+ import { parse as parseYaml } from 'yaml';
5
+
6
+ /*
7
+ Deterministic corpus signals: what the LIVE wiki says about itself, with no
8
+ model and no ingest plan in the loop.
9
+
10
+ `detectConceptSplits` only ever saw the current ingest plan, and
11
+ `subjectMatchInventory` builds a prompt context without producing a
12
+ persistent diagnosis — so two homonym leaves already written under one concept
13
+ folder were invisible until now. This reads the corpus on disk and reports the
14
+ paths, nothing else.
15
+ */
16
+
17
+ // Case, accents and punctuation are not meaning.
18
+ export function normalizeSubject(value) {
19
+ return String(value ?? '')
20
+ .normalize('NFKD')
21
+ .replace(/[\u0300-\u036f]/g, '')
22
+ .toLowerCase()
23
+ .replace(/[^a-z0-9]+/g, '-')
24
+ .replace(/^-+|-+$/g, '');
25
+ }
26
+
27
+ /**
28
+ * Two homonym leaves under one concept are ONE conflict. The result is bounded
29
+ * for display, but `total` counts the whole set and `dropped` names what the
30
+ * ceiling hid — a silent cap would also freeze the fingerprint below.
31
+ */
32
+ export function detectConceptConflicts(leaves, { max = 50 } = {}) {
33
+ const groups = new Map();
34
+ for (const leaf of leaves ?? []) {
35
+ const concept = String(leaf?.concept ?? '').trim();
36
+ const subject = normalizeSubject(leaf?.subject);
37
+ const path = String(leaf?.path ?? '').trim();
38
+ if (!concept || !subject || !path) continue;
39
+ const key = `${concept}\u0000${subject}`;
40
+ const group = groups.get(key) ?? { concept, subject, paths: [] };
41
+ group.paths.push(path);
42
+ groups.set(key, group);
43
+ }
44
+ const all = [...groups.values()]
45
+ .map((group) => ({ ...group, paths: [...new Set(group.paths)].sort() }))
46
+ .filter((group) => group.paths.length > 1)
47
+ .sort((a, b) => a.concept.localeCompare(b.concept) || a.subject.localeCompare(b.subject));
48
+ const conflicts = all.slice(0, max);
49
+ return { conflicts, total: all.length, dropped: all.length - conflicts.length };
50
+ }
51
+
52
+ /**
53
+ * A stable version for the trigger. `total` participates on purpose: a 51st
54
+ * conflict appearing later must MOVE the fingerprint even though it is beyond
55
+ * the display ceiling — otherwise it would dedup as already-seen and the corpus
56
+ * could degrade with no review.
57
+ */
58
+ export function conflictFingerprint(conflicts, total = Array.isArray(conflicts) ? conflicts.length : 0) {
59
+ const lines = (conflicts ?? [])
60
+ .map((conflict) => `${conflict.concept}/${conflict.subject}:${[...conflict.paths].sort().join('|')}`)
61
+ .sort();
62
+ lines.push(`total:${total}`);
63
+ return createHash('sha1').update(lines.join('\n')).digest('hex').slice(0, 16);
64
+ }
65
+
66
+ function frontmatterSubject(raw) {
67
+ if (!raw || !raw.startsWith('---')) return null;
68
+ const end = raw.indexOf('\n---', 3);
69
+ if (end === -1) return null;
70
+ try {
71
+ const data = parseYaml(raw.slice(3, end)) ?? {};
72
+ const subject = data.subject ?? data.title;
73
+ return subject == null ? null : String(subject);
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ // The frontmatter is at the top of the file; reading a whole page to look at
80
+ // its header is the cost the manager cannot pay synchronously on its event
81
+ // loop (the same reason llm-wiki's sidebar reads a bounded head).
82
+ function readFileHead(filePath, maxBytes = 4_096) {
83
+ let handle;
84
+ try {
85
+ handle = openSync(filePath, 'r');
86
+ const buffer = Buffer.allocUnsafe(maxBytes);
87
+ const bytes = readSync(handle, buffer, 0, maxBytes, 0);
88
+ return buffer.toString('utf8', 0, bytes);
89
+ } catch {
90
+ return null;
91
+ } finally {
92
+ if (handle !== undefined) {
93
+ try { closeSync(handle); } catch { /* already gone */ }
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Every `.md` under `wiki/concepts/`, with its top-level concept folder, its
100
+ * subject (frontmatter `subject`, else the file name) and its path RELATIVE to
101
+ * `wiki/concepts/` — nested folders included, so two leaves in different
102
+ * sub-folders still report two DISTINCT paths.
103
+ */
104
+ /**
105
+ * The engine's source registry (`.wiki/source-registry.json`), read as the
106
+ * stable contract it is. Unreadable or corrupt yields no sources: a failed
107
+ * observation never breaks the run that triggered it.
108
+ */
109
+ export function readSourceRegistry(rootDir) {
110
+ let raw;
111
+ try {
112
+ raw = readFileSync(join(String(rootDir), '.wiki', 'source-registry.json'), 'utf8');
113
+ } catch {
114
+ return { sources: [] };
115
+ }
116
+ try {
117
+ const parsed = JSON.parse(raw);
118
+ return { sources: Array.isArray(parsed?.sources) ? parsed.sources : [] };
119
+ } catch {
120
+ return { sources: [] };
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Every `.md` under `wiki/`, relative to the workspace — the inventory the
126
+ * engine's `reconcileRegistry` calls `wikiPages`. Names only, no content.
127
+ */
128
+ export function readWikiPages(rootDir, { max = 5_000 } = {}) {
129
+ const base = join(String(rootDir), 'wiki');
130
+ const pages = [];
131
+ let entries;
132
+ try {
133
+ entries = readdirSync(base, { withFileTypes: true, recursive: true });
134
+ } catch {
135
+ return pages;
136
+ }
137
+ const toPosix = (value) => value.split(sep).join('/');
138
+ for (const entry of entries) {
139
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
140
+ const parent = entry.parentPath ?? entry.path;
141
+ if (!parent) continue;
142
+ const rel = toPosix(relative(base, join(parent, entry.name)));
143
+ if (!rel || rel.startsWith('..')) continue;
144
+ pages.push(`wiki/${rel}`);
145
+ if (pages.length >= max) break;
146
+ }
147
+ return pages.sort();
148
+ }
149
+
150
+ // The engine's `orphanPages` rule: a page no ACTIVE source lists among the pages
151
+ // it produced. A hand-written or pre-registry page is an orphan too — a
152
+ // provenance gap the operator is asked about, never a deletion.
153
+ function orphanPagesFromRegistry(registry, wikiPages) {
154
+ const supported = new Set(
155
+ (registry?.sources ?? [])
156
+ .filter((source) => String(source?.status ?? 'active') === 'active')
157
+ .flatMap((source) => (Array.isArray(source?.producedPages) ? source.producedPages.map(String) : [])),
158
+ );
159
+ return wikiPages.map(String).filter((page) => !supported.has(page)).sort();
160
+ }
161
+
162
+ const DAY_MS = 24 * 60 * 60 * 1000;
163
+
164
+ /**
165
+ * The registry's own deterministic staleness facts, with no rule to mirror:
166
+ *
167
+ * - `aged` — an active source whose `lastIngestedAt` (the engine writes it) is
168
+ * older than the window. A source never ingested (`null`) is not "aging
169
+ * knowledge", it simply produced none;
170
+ * - `vanished-archive` / `vanished-page` — the registry names a path that no
171
+ * longer exists. Existence is existence: this is the engine's
172
+ * `reconcileRegistry` truth, reached without re-implementing its rules.
173
+ *
174
+ * (Orphans — a wiki page no active source backs — need the full wiki inventory
175
+ * and the supported-set rule; that one stays the engine's to expose.)
176
+ * Bounded like the conflict scan: `total` counts the whole set.
177
+ */
178
+ export function detectStaleKnowledge(registry, {
179
+ rootDir = '',
180
+ now = Date.now(),
181
+ staleAfterDays = 180,
182
+ max = 50,
183
+ exists = existsSync,
184
+ // The `wiki/**/*.md` inventory (`readWikiPages`), when the caller has it:
185
+ // orphan detection is a join on the registry, nothing more.
186
+ wikiPages = null,
187
+ } = {}) {
188
+ const cutoff = Number(now) - staleAfterDays * DAY_MS;
189
+ const evidence = [];
190
+ // Counted per kind over the FULL set: the ceiling line must describe the
191
+ // facts it is capping, not lump natures into one number. "61 source(s) not
192
+ // re-verified" sent the reader looking for the wrong defect.
193
+ const counts = { aged: 0, vanishedArchive: 0, vanishedPage: 0, orphan: 0 };
194
+ for (const source of registry?.sources ?? []) {
195
+ if (String(source?.status ?? 'active') !== 'active') continue;
196
+ const sourceId = String(source?.sourceId ?? '');
197
+ const archivePath = String(source?.archivePath ?? '');
198
+ if (archivePath && !exists(join(String(rootDir), archivePath))) {
199
+ evidence.push({ kind: 'vanished-archive', sourceId, path: archivePath });
200
+ counts.vanishedArchive += 1;
201
+ }
202
+ for (const page of source?.producedPages ?? []) {
203
+ const pagePath = String(page ?? '');
204
+ if (pagePath && !exists(join(String(rootDir), pagePath))) {
205
+ evidence.push({ kind: 'vanished-page', sourceId, path: pagePath });
206
+ counts.vanishedPage += 1;
207
+ }
208
+ }
209
+ const lastIngestedAt = source?.lastIngestedAt ?? null;
210
+ const observed = Date.parse(String(lastIngestedAt ?? ''));
211
+ if (Number.isFinite(observed) && observed <= cutoff) {
212
+ evidence.push({ kind: 'aged', sourceId, path: archivePath, lastIngestedAt });
213
+ counts.aged += 1;
214
+ }
215
+ }
216
+ if (Array.isArray(wikiPages)) {
217
+ for (const page of orphanPagesFromRegistry(registry, wikiPages)) {
218
+ evidence.push({ kind: 'orphan', sourceId: null, path: page });
219
+ counts.orphan += 1;
220
+ }
221
+ }
222
+ evidence.sort((a, b) => a.kind.localeCompare(b.kind)
223
+ || a.path.localeCompare(b.path)
224
+ || String(a.sourceId ?? '').localeCompare(String(b.sourceId ?? '')));
225
+ const stale = evidence.slice(0, max);
226
+ return { stale, total: evidence.length, dropped: evidence.length - stale.length, counts };
227
+ }
228
+
229
+ export function staleFingerprint(stale, total = Array.isArray(stale) ? stale.length : 0) {
230
+ const lines = (stale ?? [])
231
+ .map((entry) => `${entry.kind}:${entry.sourceId}:${entry.path}:${entry.lastIngestedAt ?? ''}`)
232
+ .sort();
233
+ lines.push(`total:${total}`);
234
+ return createHash('sha1').update(lines.join('\n')).digest('hex').slice(0, 16);
235
+ }
236
+
237
+ export function readConceptLeaves(rootDir) {
238
+ const base = join(String(rootDir), 'wiki', 'concepts');
239
+ const leaves = [];
240
+ let entries;
241
+ try {
242
+ entries = readdirSync(base, { withFileTypes: true, recursive: true });
243
+ } catch {
244
+ return leaves;
245
+ }
246
+ const toPosix = (value) => value.split(sep).join('/');
247
+ for (const entry of entries) {
248
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
249
+ const parent = entry.parentPath ?? entry.path;
250
+ if (!parent) continue;
251
+ const rel = toPosix(relative(base, join(parent, entry.name)));
252
+ if (!rel || rel.startsWith('..')) continue;
253
+ const concept = rel.split('/')[0];
254
+ if (!concept) continue;
255
+ const head = readFileHead(join(parent, entry.name));
256
+ const subject = frontmatterSubject(head) ?? entry.name.replace(/\.md$/, '');
257
+ leaves.push({ path: rel, concept, subject });
258
+ }
259
+ return leaves;
260
+ }