@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
@@ -4,6 +4,21 @@ import {
4
4
  normalizeRuntimeEvent,
5
5
  } from './runtimeProvider.js';
6
6
 
7
+ // The gateway closes the stream when one of these arrives; the client stops
8
+ // reconnecting on it too, so a finished run never leaves a retry loop behind.
9
+ const TERMINAL_RUNTIME_EVENT_TYPES = new Set(['run_completed', 'run_failed', 'run_cancelled']);
10
+
11
+ function sleep(ms, signal) {
12
+ return new Promise((resolve) => {
13
+ if (signal?.aborted) {
14
+ resolve();
15
+ return;
16
+ }
17
+ const timer = setTimeout(resolve, ms);
18
+ signal?.addEventListener?.('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
19
+ });
20
+ }
21
+
7
22
  /**
8
23
  * DeepAgentsProvider — client HTTP vers un runtime Deep Agents externe
9
24
  * (RFC § 11, option A). Implémente le contrat RuntimeProvider :
@@ -28,6 +43,8 @@ export function createDeepAgentsProvider({
28
43
  headers = {},
29
44
  version = null,
30
45
  timeoutMs = 10_000,
46
+ streamRetries = Number(process.env.WIKI_MANAGER_RUNTIME_STREAM_RETRIES ?? 5),
47
+ streamBackoffMs = Number(process.env.WIKI_MANAGER_RUNTIME_STREAM_BACKOFF_MS ?? 250),
31
48
  } = {}) {
32
49
  const base = String(endpoint).replace(/\/+$/, '');
33
50
 
@@ -117,6 +134,11 @@ export function createDeepAgentsProvider({
117
134
  capability: request.capability ?? null,
118
135
  arguments: request.arguments ?? {},
119
136
  workspace: request.workspace ?? null,
137
+ // The body is rebuilt field by field here, so a value the dispatcher
138
+ // adds only at the runtime layer would be dropped in transit. The
139
+ // memory scope decides which past conversation the run resumes: it
140
+ // has to be named in this list to exist at all.
141
+ memoryScope: request.memoryScope ?? null,
120
142
  model: request.model ?? null,
121
143
  language: request.language ?? null,
122
144
  mcp: Array.isArray(request.mcp) ? request.mcp : [],
@@ -152,43 +174,126 @@ export function createDeepAgentsProvider({
152
174
  },
153
175
  });
154
176
  },
177
+ // A broken stream must not mean "silence forever", and a naive reconnect
178
+ // must not replay what was already seen. `cursor` is the last `sequence`
179
+ // delivered, `epoch` the gateway instance that produced it; both travel
180
+ // back on the next connection. Bounded retries then announce the give-up:
181
+ // a dead subscription that never says so is the defect this closes.
155
182
  subscribe(runId, listener) {
156
183
  const controller = new AbortController();
157
- const url = `${base}/runs/${encodeURIComponent(String(runId))}/events`;
158
- void (async () => {
184
+ const target = `${base}/runs/${encodeURIComponent(String(runId))}/events`;
185
+ let cursor = null;
186
+ let epoch = null;
187
+ let attempts = 0;
188
+ let stopped = false;
189
+
190
+ const emit = (event) => {
159
191
  try {
160
- const response = await fetchImpl(url, {
161
- headers: { accept: 'text/event-stream', ...headers },
162
- signal: controller.signal,
163
- });
164
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
165
- const reader = response.body.getReader();
166
- const decoder = new TextDecoder();
167
- let buffer = '';
168
- while (true) {
169
- const { done, value } = await reader.read();
170
- if (done) break;
171
- buffer += decoder.decode(value, { stream: true });
172
- const blocks = buffer.split('\n\n');
173
- buffer = blocks.pop() ?? '';
174
- for (const block of blocks) {
175
- let data = '';
176
- for (const line of block.split('\n')) {
177
- if (line.startsWith('data: ')) data += line.slice(6);
178
- }
179
- if (!data) continue;
180
- try {
181
- listener(normalizeRuntimeEvent(JSON.parse(data)));
182
- } catch {
183
- // malformed or out-of-contract frame — skip
192
+ listener(normalizeRuntimeEvent(event));
193
+ } catch {
194
+ // The contract refused the frame: journal it instead of vanishing.
195
+ try {
196
+ listener(normalizeRuntimeEvent({
197
+ type: 'degraded',
198
+ capability: 'stream',
199
+ cause: 'an out-of-contract frame arrived from the runtime',
200
+ fallback: 'the frame was skipped',
201
+ }));
202
+ } catch { /* nothing left to report with */ }
203
+ }
204
+ };
205
+ const announce = (cause, fallback) => emit({ type: 'degraded', capability: 'stream', cause, fallback });
206
+
207
+ void (async () => {
208
+ while (!stopped && attempts < streamRetries) {
209
+ attempts += 1;
210
+ try {
211
+ const query = new URLSearchParams();
212
+ if (cursor != null) query.set('after', String(cursor));
213
+ if (epoch) query.set('epoch', epoch);
214
+ const suffix = query.toString();
215
+ const response = await fetchImpl(suffix ? `${target}?${suffix}` : target, {
216
+ headers: { accept: 'text/event-stream', ...headers },
217
+ signal: controller.signal,
218
+ });
219
+ if (response.status === 404) {
220
+ announce(`run ${runId} is not known to the runtime`, 'it was purged or never existed; no replay is possible');
221
+ return;
222
+ }
223
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
224
+ // The budget resets on PROGRESS, not on a mere HTTP 200: a gateway
225
+ // that accepts a connection and immediately closes it (or replays
226
+ // only what was already seen) would otherwise reset the budget
227
+ // forever and reconnect in a loop that never gives up.
228
+ let progressed = false;
229
+ const reader = response.body.getReader();
230
+ const decoder = new TextDecoder();
231
+ let buffer = '';
232
+ while (true) {
233
+ const { done, value } = await reader.read();
234
+ if (done) break;
235
+ buffer += decoder.decode(value, { stream: true });
236
+ const blocks = buffer.split('\n\n');
237
+ buffer = blocks.pop() ?? '';
238
+ for (const block of blocks) {
239
+ const data = block
240
+ .split('\n')
241
+ .filter((line) => line.startsWith('data: '))
242
+ .map((line) => line.slice(6))
243
+ .join('');
244
+ if (!data) continue;
245
+ let parsed;
246
+ try {
247
+ parsed = JSON.parse(data);
248
+ } catch {
249
+ announce('a malformed frame arrived from the runtime', 'the frame was skipped');
250
+ continue;
251
+ }
252
+ if (parsed?.type === 'stream_epoch') {
253
+ const next = String(parsed.epoch ?? '');
254
+ if (epoch && next && next !== epoch) {
255
+ announce('the runtime restarted (stream epoch changed)', 'reconnect from a fresh subscription; no events were replayed');
256
+ return;
257
+ }
258
+ epoch = next || epoch;
259
+ continue;
260
+ }
261
+ if (Number.isFinite(Number(parsed?.sequence))) {
262
+ const sequence = Number(parsed.sequence);
263
+ if (sequence > (cursor ?? -1)) progressed = true;
264
+ cursor = Math.max(cursor ?? 0, sequence);
265
+ }
266
+ emit(parsed);
267
+ if (TERMINAL_RUNTIME_EVENT_TYPES.has(String(parsed?.type))) return;
184
268
  }
185
269
  }
270
+ if (progressed) attempts = 0;
271
+ // The stream ended without a terminal event: reconnect from the cursor.
272
+ } catch (error) {
273
+ if (controller.signal.aborted || stopped) return;
274
+ if (attempts >= streamRetries) {
275
+ announce(
276
+ 'gave up reconnecting to the runtime stream',
277
+ `after ${attempts} attempt(s): ${error instanceof Error ? error.message : String(error)}`,
278
+ );
279
+ return;
280
+ }
186
281
  }
187
- } catch {
188
- // stream ended or aborted — the unsubscribe path is a no-op
282
+ if (stopped || controller.signal.aborted) return;
283
+ if (attempts >= streamRetries) break;
284
+ await sleep(streamBackoffMs * 2 ** (attempts - 1), controller.signal);
285
+ }
286
+ // The loop can also end because the budget ran out on a stream that
287
+ // kept closing cleanly — that is a give-up too, and it must be said.
288
+ if (!stopped && !controller.signal.aborted) {
289
+ announce('gave up reconnecting to the runtime stream', `after ${attempts} attempt(s) without progress`);
189
290
  }
190
291
  })();
191
- return () => controller.abort();
292
+
293
+ return () => {
294
+ stopped = true;
295
+ controller.abort();
296
+ };
192
297
  },
193
298
  };
194
299
  return provider;
@@ -170,13 +170,18 @@ test('subscribe parses the SSE stream and forwards normalized events', async ()
170
170
  ]),
171
171
  }),
172
172
  });
173
- const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
173
+ // A single attempt: this test is about parsing, not reconnection. The mock
174
+ // stream carries no terminal event, so the provider would otherwise reconnect
175
+ // (and the mock would re-deliver) until its budget ran out.
176
+ const provider = createDeepAgentsProvider({
177
+ endpoint: 'http://agent-runtime:8080', fetchImpl, streamRetries: 1, streamBackoffMs: 1,
178
+ });
174
179
 
175
180
  const events = [];
176
181
  provider.subscribe('run-1', (event) => events.push(event));
177
182
 
178
- await waitFor(() => events.length === 2);
179
- assert.deepEqual(events.map((event) => event.type), ['tool_started', 'tool_finished']);
183
+ await waitFor(() => events.length >= 2);
184
+ assert.deepEqual(events.slice(0, 2).map((event) => event.type), ['tool_started', 'tool_finished']);
180
185
  assert.equal(events[0].tool, 'wiki_search');
181
186
  });
182
187
 
@@ -204,3 +209,133 @@ test('the deepagents factory is reachable from the agentRuntimes config', () =>
204
209
  assert.equal(providers[0].type, 'deepagents');
205
210
  assertRuntimeProvider(providers[0].provider);
206
211
  });
212
+
213
+ // ── Lot 0: a broken stream must not mean "silence forever" ──────────────────
214
+
215
+ test('an event type this manager does not know still reaches the listener', async () => {
216
+ const fetchImpl = mockFetch({
217
+ 'GET /runs/run-6/events': () => ({
218
+ ok: true,
219
+ status: 200,
220
+ body: sseBody([
221
+ { type: 'phase_started', phase: 'discover', sequence: 1 },
222
+ { type: 'run_completed', sequence: 2 },
223
+ ]),
224
+ }),
225
+ });
226
+ const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
227
+ const events = [];
228
+ provider.subscribe('run-6', (event) => events.push(event));
229
+
230
+ await waitFor(() => events.some((event) => event.type === 'phase_started'));
231
+ assert.equal(events.find((event) => event.type === 'phase_started').phase, 'discover');
232
+ });
233
+
234
+ test('a dropped stream reconnects from the cursor and never repeats an event', async () => {
235
+ let connections = 0;
236
+ const fetchImpl = mockFetch({
237
+ 'GET /runs/run-1/events': () => {
238
+ connections += 1;
239
+ if (connections === 1) {
240
+ return {
241
+ ok: true,
242
+ status: 200,
243
+ body: sseBody([
244
+ { type: 'stream_epoch', epoch: 'epoch-a' },
245
+ { type: 'tool_started', tool: 'wiki_search', sequence: 5 },
246
+ ]),
247
+ };
248
+ }
249
+ return {
250
+ ok: true,
251
+ status: 200,
252
+ body: sseBody([
253
+ { type: 'stream_epoch', epoch: 'epoch-a' },
254
+ { type: 'run_completed', sequence: 6 },
255
+ ]),
256
+ };
257
+ },
258
+ });
259
+ const provider = createDeepAgentsProvider({
260
+ endpoint: 'http://agent-runtime:8080', fetchImpl, streamRetries: 3, streamBackoffMs: 1,
261
+ });
262
+ const events = [];
263
+ const unsubscribe = provider.subscribe('run-1', (event) => events.push(event));
264
+
265
+ await waitFor(() => events.some((event) => event.type === 'run_completed'));
266
+ unsubscribe();
267
+
268
+ assert.equal(connections, 2, 'the stream was reconnected once');
269
+ assert.match(fetchImpl.calls[1].url, /after=5/, 'the cursor travels back');
270
+ assert.match(fetchImpl.calls[1].url, /epoch=epoch-a/, 'the epoch travels back');
271
+ assert.equal(events.filter((event) => event.type === 'tool_started').length, 1, 'no duplicate delivery');
272
+ assert.equal(events.filter((event) => event.type === 'stream_epoch').length, 0, 'the epoch frame is consumed, never forwarded');
273
+ });
274
+
275
+ test('a stream epoch change stops the subscription with an announced reason', async () => {
276
+ let connections = 0;
277
+ const fetchImpl = mockFetch({
278
+ 'GET /runs/run-2/events': () => {
279
+ connections += 1;
280
+ return {
281
+ ok: true,
282
+ status: 200,
283
+ body: sseBody([{ type: 'stream_epoch', epoch: connections === 1 ? 'epoch-a' : 'epoch-b' }]),
284
+ };
285
+ },
286
+ });
287
+ const provider = createDeepAgentsProvider({
288
+ endpoint: 'http://agent-runtime:8080', fetchImpl, streamRetries: 5, streamBackoffMs: 1,
289
+ });
290
+ const events = [];
291
+ provider.subscribe('run-2', (event) => events.push(event));
292
+
293
+ await waitFor(() => events.some((event) => event.type === 'degraded'));
294
+ assert.equal(connections, 2, 'it stopped after the mismatch');
295
+ assert.match(events.find((event) => event.type === 'degraded').cause, /epoch changed/);
296
+ });
297
+
298
+ test('a purged run ends the subscription with an announced reason', async () => {
299
+ const fetchImpl = mockFetch({}); // 404 on every route
300
+ const provider = createDeepAgentsProvider({
301
+ endpoint: 'http://agent-runtime:8080', fetchImpl, streamRetries: 5, streamBackoffMs: 1,
302
+ });
303
+ const events = [];
304
+ provider.subscribe('run-gone', (event) => events.push(event));
305
+
306
+ await waitFor(() => events.some((event) => event.type === 'degraded'));
307
+ assert.match(events.find((event) => event.type === 'degraded').cause, /not known to the runtime/);
308
+ });
309
+
310
+ test('a stream that keeps failing announces the give-up', async () => {
311
+ const fetchImpl = mockFetch({
312
+ 'GET /runs/run-4/events': () => ({ ok: false, status: 500, json: async () => ({}) }),
313
+ });
314
+ const provider = createDeepAgentsProvider({
315
+ endpoint: 'http://agent-runtime:8080', fetchImpl, streamRetries: 2, streamBackoffMs: 1,
316
+ });
317
+ const events = [];
318
+ provider.subscribe('run-4', (event) => events.push(event));
319
+
320
+ await waitFor(() => events.some((event) => event.type === 'degraded' && /gave up/.test(event.cause)));
321
+ });
322
+
323
+ test('a malformed frame is journalled instead of silently skipped', async () => {
324
+ const encoder = new TextEncoder();
325
+ const body = new ReadableStream({
326
+ start(controller) {
327
+ controller.enqueue(encoder.encode('data: {not json\n\n'));
328
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'run_completed' })}\n\n`));
329
+ controller.close();
330
+ },
331
+ });
332
+ const fetchImpl = mockFetch({
333
+ 'GET /runs/run-5/events': () => ({ ok: true, status: 200, body }),
334
+ });
335
+ const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
336
+ const events = [];
337
+ provider.subscribe('run-5', (event) => events.push(event));
338
+
339
+ await waitFor(() => events.some((event) => event.type === 'degraded' && /malformed/.test(event.cause)));
340
+ assert.ok(events.some((event) => event.type === 'run_completed'), 'the good frame still arrives');
341
+ });
@@ -9,6 +9,7 @@ import { resolve as resolveCapability } from './capabilityResolver.js';
9
9
  import { integrate } from './planIntegrator.js';
10
10
  import { validateFragment } from './planValidator.js';
11
11
  import { isSuccessful } from './taskStatuses.js';
12
+ import { PROACTIVE_REVIEW_CAPABILITY, triggerForTask } from './proactiveReviewScheduler.js';
12
13
 
13
14
  export function createResultAggregator({
14
15
  session = null,
@@ -102,6 +103,52 @@ export async function accept(result, {
102
103
  taskId,
103
104
  payload,
104
105
  })));
106
+ // A successful ingest/rebuild is a business FACT the rest of the manager can
107
+ // act on. We publish it as a stable event and hand it to a trigger hook; the
108
+ // runtime decides whether it is worth a read-only review. Nothing here
109
+ // mutates anything — a trigger only ever proposes.
110
+ if (ok) {
111
+ const trigger = triggerForTask({
112
+ capability: task?.requiredCapability,
113
+ operation: task?.operation,
114
+ });
115
+ if (trigger) {
116
+ const workspace = session.workspace ?? session._currentRunIdentity?.workspace ?? null;
117
+ // The RUN is the fact, not the task: a parallel ingest of N sources is N
118
+ // tasks with N idempotency keys, and keying on the task would fire on the
119
+ // first source applied — an audit of a half-written corpus. One run, one
120
+ // version, one review; the queued review itself starts only after the
121
+ // run's control-lane drain.
122
+ const sourceVersion = runId ?? result?.idempotencyKey ?? task?.idempotencyKey ?? null;
123
+ const triggerPayload = { workspace, trigger, sourceVersion, taskId, runId };
124
+ persistDispatch(store, dispatchAgentEvent(session, createAgentEvent(trigger, {
125
+ origin: 'result_aggregator',
126
+ runId,
127
+ taskId,
128
+ workspace,
129
+ payload: triggerPayload,
130
+ })));
131
+ session._onKnowledgeTrigger?.(triggerPayload);
132
+ }
133
+ // A proactive review's result is a NOTE, not a mutation: file it in the
134
+ // workspace's review queue (never in agent-proposals, which is for merges)
135
+ // and announce where it waits. The run is read-only by capability, so
136
+ // nothing here can have changed the wiki.
137
+ if (task?.requiredCapability === PROACTIVE_REVIEW_CAPABILITY && session._proactiveReview) {
138
+ const persisted = persistProactiveReview(session, result, session._proactiveReview, taskId);
139
+ persistDispatch(store, dispatchAgentEvent(session, createAgentEvent('runtime_log', {
140
+ origin: 'result_aggregator',
141
+ runId,
142
+ taskId,
143
+ payload: {
144
+ message: persisted.error
145
+ ? `proactive-review: could not file the review — ${persisted.error}`
146
+ : `proactive-review: ${persisted.id} is waiting — ${persisted.path} (${persisted.findings} finding(s))`,
147
+ },
148
+ })));
149
+ session._proactiveReview = null;
150
+ }
151
+ }
105
152
  const expansion = await maybeExpandPlan(result, {
106
153
  session,
107
154
  runId,
@@ -258,7 +305,14 @@ function persistDispatch(store, event) {
258
305
  * write machinery — this function only records, it never touches wiki content.
259
306
  */
260
307
  function persistWorktreeProposal(session, result, { runId, taskId }) {
261
- const proposal = result?.result?.worktreeProposal ?? result?.worktreeProposal;
308
+ // The dispatcher wraps the agent's status payload under `rawStatus`
309
+ // (`taskResultFromStatus`), so the gateway's proposal lives at
310
+ // `rawStatus.result.worktreeProposal`. Reading only `result.result`/
311
+ // `result.worktreeProposal` matched the unit-test fixture but never the real
312
+ // run: the proposal was silently dropped and no review item ever appeared.
313
+ const proposal = result?.rawStatus?.result?.worktreeProposal
314
+ ?? result?.result?.worktreeProposal
315
+ ?? result?.worktreeProposal;
262
316
  if (!proposal || typeof proposal !== 'object') return { path: null };
263
317
  const changes = Array.isArray(proposal.changes) ? proposal.changes : [];
264
318
  if (changes.length === 0) return { path: null };
@@ -294,6 +348,66 @@ function persistWorktreeProposal(session, result, { runId, taskId }) {
294
348
  }
295
349
  }
296
350
 
351
+ /*
352
+ A proactive review is a read-only audit the workspace asked for. Its result is
353
+ FILED, never merged: `.wiki/agent-reviews/<id>.json` is a queue of notes a
354
+ human reads, distinct from `.wiki/agent-proposals/` (which exists to be
355
+ merged). No worktree, no mutation, no external message — the capability is
356
+ read-only by construction; this is only where its conclusion is kept.
357
+ */
358
+ function persistProactiveReview(session, result, pending, taskId) {
359
+ const workspacePath = session?.workspacePath;
360
+ if (!workspacePath || typeof workspacePath !== 'string') {
361
+ return { error: 'no workspace path on the session — the review stays in the run result only' };
362
+ }
363
+ // Same wrapping as the worktree proposal above: the real dispatcher result
364
+ // carries the gateway's content under `rawStatus.result.content`.
365
+ const content = String(result?.rawStatus?.result?.content ?? result?.result?.content ?? result?.content ?? '');
366
+ const findings = extractReviewFindings(content);
367
+ const id = String(pending?.id ?? `review-${taskId}`).replace(/[^a-zA-Z0-9._-]/g, '_');
368
+ const record = {
369
+ id,
370
+ workspace: String(pending?.workspace ?? session.workspace ?? ''),
371
+ trigger: pending?.trigger ?? null,
372
+ createdAt: pending?.createdAt ?? new Date().toISOString(),
373
+ status: 'proposed',
374
+ summary: content.replace(/\s+/g, ' ').trim().slice(0, 1000),
375
+ findings,
376
+ sourceVersion: pending?.sourceVersion ?? null,
377
+ budget: pending?.budget ?? null,
378
+ // What the deterministic scan knew when it queued this review.
379
+ evidence: pending?.evidence ?? null,
380
+ };
381
+ try {
382
+ const dir = join(workspacePath, '.wiki', 'agent-reviews');
383
+ mkdirSync(dir, { recursive: true });
384
+ const path = join(dir, `${id}.json`);
385
+ writeFileSync(path, JSON.stringify(record, null, 2));
386
+ return { path, id, findings: findings.length };
387
+ } catch (error) {
388
+ return { error: error instanceof Error ? error.message : String(error) };
389
+ }
390
+ }
391
+
392
+ // The `[objection] severity: … — path — statement` lines the gateway's Critique
393
+ // emits, lifted so a review's findings are structured, not a prose blob.
394
+ function extractReviewFindings(content, { max = 50 } = {}) {
395
+ const findings = [];
396
+ for (const line of String(content ?? '').split('\n')) {
397
+ const match = /^\s*\[objection\]\s*severity:\s*(blocking|non-blocking)\s*[-—]\s*(.+)$/i.exec(line.trim());
398
+ if (!match) continue;
399
+ const tail = match[2].trim();
400
+ const separator = tail.indexOf(' — ');
401
+ findings.push({
402
+ severity: match[1].toLowerCase(),
403
+ path: separator === -1 ? null : tail.slice(0, separator).trim() || null,
404
+ statement: (separator === -1 ? tail : tail.slice(separator + 3).trim()).slice(0, 300),
405
+ });
406
+ if (findings.length >= max) break;
407
+ }
408
+ return findings;
409
+ }
410
+
297
411
  function agentPlanRequest(request, session) {
298
412
  return {
299
413
  capability: request.capability,
@@ -1,5 +1,8 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
3
6
 
4
7
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
5
8
  import { accept } from './resultAggregator.js';
@@ -271,3 +274,138 @@ test('resultAggregator honours an explicit opt-out from the proposal constraints
271
274
  assert.equal(calls[0].constraints.requireApprovalForMutations, false);
272
275
  assert.equal(calls[0].constraints.maxTasks, 2);
273
276
  });
277
+
278
+ test('a completed ingest publishes knowledge.ingested and hands the trigger to the hook', async () => {
279
+ const session = { agentEvents: [], activities: {}, workspace: 'docs', headlessPlan: [] };
280
+ const triggers = [];
281
+ session._onKnowledgeTrigger = (payload) => triggers.push(payload);
282
+ const task = { id: 't1', requiredCapability: 'knowledge.update', operation: 'ingest_apply', idempotencyKey: 'sha-1' };
283
+
284
+ await accept({ ok: true, taskId: 't1', status: 'succeeded', idempotencyKey: 'sha-1' }, {
285
+ session,
286
+ runId: 'run-1',
287
+ task,
288
+ });
289
+
290
+ assert.ok(session.agentEvents.some((event) => event.type === 'knowledge.ingested'));
291
+ assert.equal(triggers.length, 1);
292
+ assert.deepEqual(triggers[0], {
293
+ workspace: 'docs',
294
+ trigger: 'knowledge.ingested',
295
+ // The RUN is the fact: the task's own idempotency key is neither. A
296
+ // parallel ingest's other tasks must land on the same version.
297
+ sourceVersion: 'run-1',
298
+ taskId: 't1',
299
+ runId: 'run-1',
300
+ });
301
+ });
302
+
303
+ test('a completed build publishes no knowledge trigger', async () => {
304
+ const session = { agentEvents: [], activities: {}, workspace: 'docs', headlessPlan: [] };
305
+ const triggers = [];
306
+ session._onKnowledgeTrigger = (payload) => triggers.push(payload);
307
+
308
+ await accept({ ok: true, taskId: 't2', status: 'succeeded' }, {
309
+ session,
310
+ runId: 'run-1',
311
+ task: { id: 't2', requiredCapability: 'document.build', operation: 'build' },
312
+ });
313
+
314
+ assert.equal(triggers.length, 0);
315
+ assert.ok(!session.agentEvents.some((event) => event.type.startsWith('knowledge.')));
316
+ });
317
+
318
+ test('a completed proactive review is filed under .wiki/agent-reviews, never merged', async () => {
319
+ const workspacePath = mkdtempSync(join(tmpdir(), 'proactive-review-'));
320
+ const session = { agentEvents: [], activities: {}, workspace: 'docs', workspacePath, headlessPlan: [] };
321
+ session._proactiveReview = {
322
+ id: 'review-1',
323
+ workspace: 'docs',
324
+ trigger: 'knowledge.ingested',
325
+ sourceVersion: 'sha-9',
326
+ createdAt: '2026-01-01T00:00:00.000Z',
327
+ budget: { runsToday: 1, inFlight: 1 },
328
+ evidence: { kind: 'stale', counts: { aged: 0, vanishedArchive: 1, vanishedPage: 0 }, items: [{ kind: 'vanished-archive', path: 'raw/ingested/gone.md' }] },
329
+ };
330
+ try {
331
+ const content = [
332
+ 'The workspace is missing a cost concept.',
333
+ '[objection] severity: blocking — wiki/concepts/cout/a.md — unsourced claim',
334
+ ].join('\n');
335
+ await accept(
336
+ { ok: true, taskId: 't-review', status: 'succeeded', result: { content } },
337
+ { session, runId: 'run-review', task: { id: 't-review', requiredCapability: 'agent.review' } },
338
+ );
339
+
340
+ const file = join(workspacePath, '.wiki', 'agent-reviews', 'review-1.json');
341
+ assert.ok(existsSync(file), 'the review is filed');
342
+ const record = JSON.parse(readFileSync(file, 'utf8'));
343
+ assert.equal(record.id, 'review-1');
344
+ assert.equal(record.trigger, 'knowledge.ingested');
345
+ assert.equal(record.sourceVersion, 'sha-9');
346
+ assert.equal(record.status, 'proposed');
347
+ assert.equal(record.findings.length, 1);
348
+ assert.equal(record.findings[0].severity, 'blocking');
349
+ assert.equal(record.findings[0].path, 'wiki/concepts/cout/a.md');
350
+ // The deterministic evidence travels with the note: "a review happened"
351
+ // vs "the review knew what to look at".
352
+ assert.deepEqual(record.evidence, {
353
+ kind: 'stale',
354
+ counts: { aged: 0, vanishedArchive: 1, vanishedPage: 0 },
355
+ items: [{ kind: 'vanished-archive', path: 'raw/ingested/gone.md' }],
356
+ });
357
+ // It is a note, not a proposal to merge.
358
+ assert.ok(!existsSync(join(workspacePath, '.wiki', 'agent-proposals')));
359
+ assert.equal(session._proactiveReview, null, 'the marker is consumed');
360
+ } finally {
361
+ rmSync(workspacePath, { recursive: true, force: true });
362
+ }
363
+ });
364
+
365
+ test('a gateway worktree proposal (rawStatus shape) is persisted for review', async () => {
366
+ const workspacePath = mkdtempSync(join(tmpdir(), 'worktree-proposal-'));
367
+ const session = { agentEvents: [], activities: {}, workspace: 'docs', workspacePath, headlessPlan: [] };
368
+ try {
369
+ const proposal = {
370
+ workspace: 'docs',
371
+ branch: 'agent/gateway-1',
372
+ worktreePath: '/workspaces/docs/.wiki/agent-worktrees/gateway-1',
373
+ worktreeRelativePath: '.wiki/agent-worktrees/gateway-1',
374
+ justification: 'dedup',
375
+ changedFiles: [{ status: 'M', path: 'wiki/a.md' }],
376
+ changes: [{ path: 'wiki/a.md', status: 'M', content: '# A' }],
377
+ diff: 'diff --git a/wiki/a.md b/wiki/a.md',
378
+ };
379
+ // The dispatcher wraps the agent's status payload under `rawStatus`
380
+ // (`taskResultFromStatus`). The fixture used to hand `{ result: {...} }`
381
+ // straight to accept — a shape the real gateway run never produces — so
382
+ // the proposal was silently dropped and no review item ever appeared.
383
+ const result = await accept({
384
+ ok: true,
385
+ taskId: 't-curate',
386
+ status: 'completed',
387
+ outputRefs: [],
388
+ rawStatus: {
389
+ runId: 'gateway-1',
390
+ status: 'completed',
391
+ result: { status: 'completed', content: 'report', worktreeProposal: proposal },
392
+ },
393
+ }, {
394
+ session,
395
+ runId: 'run-curate',
396
+ task: { id: 't-curate', requiredCapability: 'agent.curate', operation: 'run' },
397
+ });
398
+
399
+ assert.equal(result.ok, true);
400
+ const dir = join(workspacePath, '.wiki', 'agent-proposals');
401
+ assert.ok(existsSync(dir), 'the proposal directory is created');
402
+ const record = JSON.parse(readFileSync(join(dir, 't-curate.json'), 'utf8'));
403
+ assert.equal(record.branch, 'agent/gateway-1');
404
+ assert.equal(record.changes.length, 1);
405
+ assert.equal(record.changes[0].path, 'wiki/a.md');
406
+ // A proposal nobody is told about is a proposal nobody merges.
407
+ assert.ok(session.agentEvents.some((event) => String(event.payload?.message ?? '').includes('agent-proposal')));
408
+ } finally {
409
+ rmSync(workspacePath, { recursive: true, force: true });
410
+ }
411
+ });