forge-workflow 0.1.0-beta.4 → 0.1.0-beta.5

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 (119) hide show
  1. package/AGENTS.md +14 -7
  2. package/CHANGELOG.md +43 -1
  3. package/README.md +6 -2
  4. package/bin/forge-cmd.js +20 -0
  5. package/bin/forge.js +16 -374
  6. package/docs/INDEX.md +1 -1
  7. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  8. package/docs/guides/MIGRATION.md +4 -4
  9. package/docs/guides/SETUP.md +16 -16
  10. package/docs/reference/COMMANDS.md +8 -5
  11. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  12. package/docs/reference/RELEASE.md +5 -3
  13. package/docs/reference/TOOLCHAIN.md +8 -0
  14. package/docs/reference/protected-state-surfaces.md +4 -4
  15. package/docs/reference/shepherd.md +54 -25
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/pr-state-adapter.js +344 -142
  19. package/lib/audit-evidence.js +71 -110
  20. package/lib/capped-jsonl-log.js +236 -0
  21. package/lib/commands/_registry.js +2 -2
  22. package/lib/commands/clean.js +196 -32
  23. package/lib/commands/dev.js +4 -33
  24. package/lib/commands/hooks.js +223 -25
  25. package/lib/commands/insights.js +8 -3
  26. package/lib/commands/merge.js +600 -40
  27. package/lib/commands/pr.js +1 -1
  28. package/lib/commands/preflight.js +11 -2
  29. package/lib/commands/prime.js +21 -8
  30. package/lib/commands/push.js +41 -51
  31. package/lib/commands/recall.js +60 -16
  32. package/lib/commands/recap.js +6 -1
  33. package/lib/commands/release.js +17 -2
  34. package/lib/commands/setup.js +191 -94
  35. package/lib/commands/shepherd.js +13 -1
  36. package/lib/commands/ship.js +22 -23
  37. package/lib/commands/skill.js +119 -11
  38. package/lib/commands/status.js +17 -1
  39. package/lib/commands/test.js +24 -34
  40. package/lib/commands/worktree.js +220 -42
  41. package/lib/core/runtime-graph.js +1 -1
  42. package/lib/doc-assertions.js +297 -0
  43. package/lib/existing-tdd-gate.js +253 -0
  44. package/lib/forge-context.js +1 -4
  45. package/lib/forge-issues.js +56 -32
  46. package/lib/git-defaults.js +56 -0
  47. package/lib/harness-capability-matrix.js +3 -3
  48. package/lib/hook-renderer.js +93 -4
  49. package/lib/insights.js +96 -80
  50. package/lib/kernel/backing-issue.js +14 -2
  51. package/lib/kernel/broker.js +16 -0
  52. package/lib/kernel/cli-broker-factory.js +12 -1
  53. package/lib/kernel/close-on-merge.js +154 -0
  54. package/lib/kernel/fs-class.js +42 -25
  55. package/lib/kernel/sqlite-driver.js +153 -29
  56. package/lib/lefthook-wiring.js +21 -1
  57. package/lib/memory/router.js +16 -1
  58. package/lib/memory-digest.js +47 -15
  59. package/lib/memory-recall-events.js +145 -0
  60. package/lib/memory-recall.js +71 -10
  61. package/lib/merge-rules.js +8 -4
  62. package/lib/npm-publish-workflow.js +272 -0
  63. package/lib/orientation.js +68 -43
  64. package/lib/plugin-catalog.js +14 -4
  65. package/lib/pr-bundle.js +5 -6
  66. package/lib/pr-monitor/journal.js +18 -2
  67. package/lib/pr-monitor/reconcile-executor.js +224 -41
  68. package/lib/pr-monitor/render-summary.js +196 -0
  69. package/lib/pr-monitor/shepherd-lease.js +10 -1
  70. package/lib/pr-monitor/watch-lifecycle.js +13 -1
  71. package/lib/pr-pull.js +33 -14
  72. package/lib/pr-shepherd.js +34 -8
  73. package/lib/preflight/gates.js +65 -18
  74. package/lib/preflight/runner.js +5 -0
  75. package/lib/project-memory.js +33 -1
  76. package/lib/protected-state-authority.js +305 -0
  77. package/lib/protected-state-surfaces.js +64 -44
  78. package/lib/release-readiness.js +51 -4
  79. package/lib/shell-utils.js +1 -1
  80. package/lib/skills-sync.js +6 -3
  81. package/lib/smart-merge.js +28 -4
  82. package/lib/symlink-utils.js +74 -26
  83. package/lib/upgrade-safety.js +39 -0
  84. package/lib/using-forge.js +19 -6
  85. package/package.json +6 -7
  86. package/scripts/doc-asserting-tests.js +158 -0
  87. package/scripts/lib/behavioral-eval-runner.js +310 -0
  88. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  89. package/scripts/lib/eval-evidence.js +328 -0
  90. package/scripts/lib/eval-runner.js +81 -41
  91. package/scripts/lib/immutable-eval-corpus.js +309 -0
  92. package/scripts/lib/promotion-evidence-loader.js +94 -0
  93. package/scripts/lib/promotion-scorecard.js +314 -0
  94. package/scripts/npm-release-receipt.js +134 -0
  95. package/scripts/process-tree.js +761 -0
  96. package/scripts/protected-state-check.js +47 -22
  97. package/scripts/run-command-eval.js +29 -1
  98. package/scripts/sync-d20-audit.js +172 -0
  99. package/scripts/test-full-suite.js +249 -37
  100. package/scripts/test.js +176 -43
  101. package/skills/review/SKILL.md +4 -11
  102. package/skills/review/evals/scorecard.json +3 -3
  103. package/skills/rollback/SKILL.md +4 -11
  104. package/skills/rollback/evals/scorecard.json +3 -3
  105. package/skills/shepherd/SKILL.md +20 -14
  106. package/skills/shepherd/evals/scorecard.json +2 -2
  107. package/skills/ship/SKILL.md +4 -12
  108. package/skills/ship/evals/scorecard.json +3 -3
  109. package/skills/worktree/SKILL.md +6 -1
  110. package/skills/worktree/evals/scorecard.json +2 -2
  111. package/lib/beads-setup.js +0 -538
  112. package/lib/beads-sync-scaffold.js +0 -189
  113. package/lib/pat-setup.js +0 -207
  114. package/lib/pr-monitor/render-sticky.js +0 -206
  115. package/lib/pr-monitor/upsert-sticky.js +0 -169
  116. package/scripts/beads-context.sh +0 -577
  117. package/scripts/beads-migrate-to-dolt.sh +0 -7
  118. package/scripts/beads-upgrade-smoke.sh +0 -284
  119. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -30,14 +30,15 @@ const {
30
30
  const fs = require('node:fs');
31
31
  const path = require('node:path');
32
32
  const { sessionStartCapability, userPromptSubmitCapability, sessionEndCapability } = require('../hook-renderer');
33
- const { collectDigestData, buildMemoryDigest, defaultFetchIssues, defaultFetchNotes } = require('../memory-digest');
33
+ const { buildMemoryDigest, defaultFetchIssues, defaultFetchNotes } = require('../memory-digest');
34
34
  const { collectInbox, buildInboxNudge } = require('../inbox');
35
35
  const { collectDigest } = require('../pr-monitor/digest');
36
36
  const { loadDispatchText } = require('../using-forge');
37
37
  const projectMemory = require('../project-memory');
38
- const { parseHookInput, selectInjection, DEFAULT_TOKEN_BUDGET, DEFAULT_SCORE_FLOOR } = require('../memory-recall');
39
- const { fenceUntrusted } = require('../untrusted-content');
38
+ const { parseHookInput, selectInjection, meaningfulTokens, DEFAULT_TOKEN_BUDGET, DEFAULT_SCORE_FLOOR } = require('../memory-recall');
39
+ const { launchMemoryRecallEvent } = require('../memory-recall-events');
40
40
  const { getResolvedRuntimeGraph } = require('../core/runtime-graph');
41
+ const { fireAndForget } = require('../pr-monitor/reconcile-executor');
41
42
 
42
43
  // Default-ON rail; `forge gate disable rail.memory_recall` turns tier-2 off. Mirrors
43
44
  // autoShepherdRailEnabled (lib/commands/ship.js): absent id = enabled, fail-open to true.
@@ -47,6 +48,42 @@ const MEMORY_RECALL_RAIL = 'rail.memory_recall';
47
48
  const MEMORY_RECALL_CANDIDATES = 25;
48
49
  // Cross-turn dedupe memory: how many recently-injected keys to remember per session.
49
50
  const SEEN_KEYS_CAP = 40;
51
+ const SESSION_START_DEADLINE_MS = 9_000;
52
+ const PROMPT_RECALL_DEADLINE_MS = 4_500;
53
+ const PROMPT_RECALL_SQLITE_BUSY_MS = 1_000;
54
+
55
+ function withinDeadline(work, fallback, deadlineMs, onTimeout) {
56
+ return new Promise((resolve) => {
57
+ const timer = setTimeout(() => {
58
+ if (onTimeout) onTimeout();
59
+ resolve(fallback);
60
+ }, deadlineMs);
61
+ Promise.resolve().then(work).then(
62
+ value => {
63
+ clearTimeout(timer);
64
+ resolve(value);
65
+ },
66
+ () => {
67
+ clearTimeout(timer);
68
+ resolve(fallback);
69
+ },
70
+ );
71
+ });
72
+ }
73
+
74
+ async function collectSessionStartData(projectRoot, opts, deadlineMs) {
75
+ const fetchNotes = opts.fetchNotes || defaultFetchNotes;
76
+ const fetchIssues = opts.fetchIssues || defaultFetchIssues;
77
+ const fetchInbox = opts.fetchInbox || collectInbox;
78
+ const bounded = work => withinDeadline(work, [], deadlineMs);
79
+ const [notes, ready, claimed, inbox] = await Promise.all([
80
+ bounded(() => fetchNotes(projectRoot, opts)),
81
+ bounded(() => fetchIssues(projectRoot, 'ready', opts)),
82
+ bounded(() => fetchIssues(projectRoot, 'in_progress', opts)),
83
+ bounded(() => fetchInbox(projectRoot, opts)),
84
+ ]);
85
+ return { notes, ready, claimed, inbox };
86
+ }
50
87
 
51
88
  function memoryRecallRailEnabled(projectRoot, resolveGraph = getResolvedRuntimeGraph) {
52
89
  try {
@@ -96,6 +133,39 @@ function saveSeenKeys(projectRoot, sessionId, keys) {
96
133
  }
97
134
  }
98
135
 
136
+ // Shadow-log the tier-2 recall decision (kernel issue f71784d3 step-0 instrument): one JSON
137
+ // line per run records what was retrieved and what cleared the floor, so the corpus-dependent
138
+ // scoreFloor can be tuned from real data instead of guessed. Capped so it never grows unbounded.
139
+ const SHADOW_LOG_MAX_BYTES = 512 * 1024;
140
+ // A shadow record is a tuning sample, not an archive. It contains only aggregate counts/scores.
141
+
142
+ function shadowLogPath(projectRoot) {
143
+ return path.join(projectRoot, '.forge', 'memory-recall', 'shadow.jsonl');
144
+ }
145
+
146
+ // Append one JSON line, then evict whole records oldest-first until the file fits the byte cap.
147
+ // Trimming by bytes (not line count) is what keeps this hook's synchronous read/write bounded: a
148
+ // record that exceeds the cap alone leaves the file empty rather than permanently oversized.
149
+ // Best-effort — any failure is swallowed by the caller so logging never affects the hook result.
150
+ function appendShadowLog(projectRoot, record) {
151
+ const file = shadowLogPath(projectRoot);
152
+ fs.mkdirSync(path.dirname(file), { recursive: true });
153
+ fs.appendFileSync(file, `${JSON.stringify(record)}\n`, 'utf8');
154
+ let size;
155
+ try { size = fs.statSync(file).size; } catch { size = 0; }
156
+ if (size <= SHADOW_LOG_MAX_BYTES) return;
157
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
158
+ const keep = [];
159
+ let bytes = 0;
160
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
161
+ const cost = Buffer.byteLength(`${lines[i]}\n`, 'utf8');
162
+ if (bytes + cost > SHADOW_LOG_MAX_BYTES) break;
163
+ bytes += cost;
164
+ keep.unshift(lines[i]);
165
+ }
166
+ fs.writeFileSync(file, keep.length ? `${keep.join('\n')}\n` : '', 'utf8');
167
+ }
168
+
99
169
  function usage() {
100
170
  return 'Usage: forge hooks install --global [--harness codex|hermes|all] [--dry-run]\n'
101
171
  + ' forge hooks session-start --harness <claude> (machine-facing; emits SessionStart context)\n'
@@ -176,16 +246,19 @@ function formatSessionStart(harness, text) {
176
246
  *
177
247
  * @param {string[]} rest - args after the `session-start` action.
178
248
  * @param {string} projectRoot
179
- * @param {object} [opts] - injectable digest fetchers ({ fetchNotes, fetchIssues }).
249
+ * @param {object} [opts] - injectable digest fetchers ({ fetchNotes, fetchIssues }) and an
250
+ * optional host `harness` capability ({ hasBgShell: true, runBgShell(argv, { cwd }) }).
180
251
  * @returns {Promise<{ success: boolean, output: string }>}
181
252
  */
182
253
  async function handleSessionStart(rest, projectRoot, opts = {}) {
183
254
  const harness = parseHarness(rest);
184
- if (!sessionStartCapability(harness).rendered) return { success: true, output: '' };
185
-
186
- // NOTE: the autonomous-shepherd session-start trigger is deferred to W-S4c along with the
187
- // per-command dispatch trigger both need a firing-policy + containment design (a naive
188
- // trigger spawns a session-outliving daemon that breaks test isolation).
255
+ const capability = sessionStartCapability(harness);
256
+ if (!capability.rendered) return { success: true, output: '', reason: capability.reason };
257
+ try {
258
+ const triggerContext = { projectRoot };
259
+ if (opts.harness !== undefined) triggerContext.harness = opts.harness;
260
+ (opts.fireAndForget || fireAndForget)(triggerContext);
261
+ } catch { /* the automatic trigger must never break session start */ }
189
262
 
190
263
  // The using-forge dispatch bootstrap is injected FIRST so Forge skills auto-trigger from turn
191
264
  // one (the Superpowers mechanism): a reasoning-driven system, not just harness description
@@ -201,7 +274,11 @@ async function handleSessionStart(rest, projectRoot, opts = {}) {
201
274
 
202
275
  let digestText = '';
203
276
  try {
204
- const data = await collectDigestData(projectRoot, opts);
277
+ const data = await collectSessionStartData(
278
+ projectRoot,
279
+ opts,
280
+ opts.sessionStartDeadlineMs || SESSION_START_DEADLINE_MS,
281
+ );
205
282
  const digest = buildMemoryDigest(data, opts);
206
283
  if (!digest.empty) digestText = digest.text;
207
284
  } catch { /* fail-open: a kernel outage must not suppress the dispatch bootstrap */ }
@@ -243,7 +320,8 @@ function formatUserPromptSubmit(harness, text) {
243
320
  async function handleInboxPickup(rest, projectRoot, opts = {}) {
244
321
  try {
245
322
  const harness = parseHarness(rest);
246
- if (!userPromptSubmitCapability(harness).rendered) return { success: true, output: '' };
323
+ const capability = userPromptSubmitCapability(harness);
324
+ if (!capability.rendered) return { success: true, output: '', reason: capability.reason };
247
325
  const pending = await collectInbox(projectRoot, opts);
248
326
  const nudge = buildInboxNudge(pending);
249
327
  if (nudge.empty) return { success: true, output: '' };
@@ -314,43 +392,161 @@ function handleShepherdEvents(rest, projectRoot, opts = {}) {
314
392
  * loadSeen, saveSeen, scoreFloor, tokenBudget }.
315
393
  * @returns {{ success: boolean, output: string }}
316
394
  */
317
- function handleMemoryRecall(rest, projectRoot, opts = {}) {
395
+ async function runMemoryRecall(rest, projectRoot, opts = {}) {
396
+ const observation = opts.observation || {};
318
397
  try {
319
398
  const harness = parseHarness(rest);
320
- if (!userPromptSubmitCapability(harness).rendered) return { success: true, output: '' };
399
+ observation.harness = harness;
400
+ const capability = userPromptSubmitCapability(harness);
401
+ if (!capability.rendered) {
402
+ observation.outcome = 'unsupported';
403
+ return { success: true, output: '', reason: capability.reason };
404
+ }
321
405
 
322
406
  const railEnabled = opts.railEnabled || memoryRecallRailEnabled;
323
- if (!railEnabled(projectRoot)) return { success: true, output: '' };
407
+ if (!railEnabled(projectRoot)) {
408
+ observation.outcome = 'filtered';
409
+ return { success: true, output: '' };
410
+ }
324
411
 
325
412
  const readInput = opts.readInput || readHookStdin;
326
413
  const { prompt, sessionId } = parseHookInput(readInput());
327
- if (!prompt) return { success: true, output: '' };
414
+ if (!prompt) {
415
+ observation.outcome = 'filtered';
416
+ return { success: true, output: '' };
417
+ }
328
418
 
329
419
  const search = opts.search
330
- || ((root, query, limit) => projectMemory.searchRankedScored(root, query, limit));
420
+ || ((root, query, limit, options) => projectMemory.searchRankedScored(root, query, limit, options));
331
421
  const loadSeen = opts.loadSeen || loadSeenKeys;
332
422
  const saveSeen = opts.saveSeen || saveSeenKeys;
333
-
334
- const hits = search(projectRoot, prompt, MEMORY_RECALL_CANDIDATES) || [];
335
- const excludeKeys = loadSeen(projectRoot, sessionId) || [];
336
- const { lines, injectedKeys } = selectInjection({
423
+ const appendShadow = opts.appendShadow || appendShadowLog;
424
+
425
+ // Stopword-aware, unicode-safe tokenization lives HERE (single source); the driver's
426
+ // buildMemoryFtsMatchOr only quotes+ORs whatever tokens it receives. Passing the raw prompt
427
+ // to a token-AND match was the 0-recall bug (kernel issue f71784d3).
428
+ const tokens = meaningfulTokens(prompt);
429
+ const excludeKeys = await loadSeen(projectRoot, sessionId) || [];
430
+ const hits = await search(
431
+ projectRoot,
432
+ tokens.join(' '),
433
+ MEMORY_RECALL_CANDIDATES,
434
+ {
435
+ excludeKeys: excludeKeys.slice(0, 256),
436
+ busyTimeoutMs: Math.max(0, Math.min(
437
+ opts.promptRecallSqliteBusyMs ?? PROMPT_RECALL_SQLITE_BUSY_MS,
438
+ (opts.promptRecallDeadlineMs || PROMPT_RECALL_DEADLINE_MS) - 100,
439
+ )),
440
+ },
441
+ ) || [];
442
+ observation.candidateCount = hits.length;
443
+ observation.eligibleCount = hits.length;
444
+ if (opts.deadline?.expired) {
445
+ observation.outcome = 'timeout';
446
+ return { success: true, output: '', reason: 'timeout' };
447
+ }
448
+ const scoreFloor = typeof opts.scoreFloor === 'number' ? opts.scoreFloor : DEFAULT_SCORE_FLOOR;
449
+ const { lines, entries, injectedKeys } = selectInjection({
337
450
  query: prompt,
338
451
  hits,
339
- scoreFloor: typeof opts.scoreFloor === 'number' ? opts.scoreFloor : DEFAULT_SCORE_FLOOR,
452
+ scoreFloor,
340
453
  tokenBudget: opts.tokenBudget || DEFAULT_TOKEN_BUDGET,
341
454
  excludeKeys,
342
455
  });
343
- if (!lines.length) return { success: true, output: '' };
344
456
 
345
- saveSeen(projectRoot, sessionId, injectedKeys);
346
- const fenced = lines.map(line => fenceUntrusted(line, { source: 'memory' })).join('\n');
457
+ // Step-0 instrument: record every real query (even ones that inject nothing) so the floor is
458
+ // tuned from data. Own try/catch a logging failure must NEVER fall through to the fail-open
459
+ // outer catch (which would suppress a legitimate injection).
460
+ if (tokens.length) {
461
+ try {
462
+ appendShadow(projectRoot, {
463
+ candidateCount: hits.length,
464
+ injectedCount: injectedKeys.length,
465
+ candidateScoreMin: hits.length ? Math.min(...hits.map(hit => Number(hit?.score) || 0)) : null,
466
+ candidateScoreMax: hits.length ? Math.max(...hits.map(hit => Number(hit?.score) || 0)) : null,
467
+ scoreFloor,
468
+ });
469
+ } catch { /* best-effort: shadow logging never affects the hook result */ }
470
+ }
471
+
472
+ if (!lines.length) {
473
+ observation.outcome = hits.length ? 'filtered' : 'empty';
474
+ return { success: true, output: '' };
475
+ }
476
+
477
+ if (opts.deadline?.expired) return { success: true, output: '', reason: 'timeout' };
478
+ try {
479
+ const pending = saveSeen(projectRoot, sessionId, injectedKeys);
480
+ if (pending && typeof pending.catch === 'function') pending.catch(() => {});
481
+ } catch { /* dedupe persistence never suppresses an injection */ }
482
+ const sections = [
483
+ ['confirmed', 'Confirmed memory (project-local; provenance shown)'],
484
+ ['suggested', 'Suggested memory — verify before relying'],
485
+ ].map(([trust, heading]) => {
486
+ const selectedLines = entries.filter(entry => entry.trust === trust).map(entry => entry.line);
487
+ return selectedLines.length ? `${heading}\n${selectedLines.join('\n')}` : '';
488
+ }).filter(Boolean);
489
+ const fenced = sections.join('\n');
490
+ const selected = hits.filter(hit => injectedKeys.includes(hit.memory_id || hit.key));
491
+ observation.outcome = 'selected';
492
+ observation.selectedIds = injectedKeys;
493
+ observation.sourceMix = selected.reduce((mix, hit) => {
494
+ const sourceAgent = hit.provenance?.source_agent || hit.sourceAgent || 'unknown';
495
+ mix[sourceAgent] = (mix[sourceAgent] || 0) + 1;
496
+ return mix;
497
+ }, {});
498
+ observation.trustMix = selected.reduce((mix, hit) => {
499
+ const trust = hit.trust_status || 'unknown';
500
+ mix[trust] = (mix[trust] || 0) + 1;
501
+ return mix;
502
+ }, {});
503
+ observation.tokenEstimate = Math.ceil(fenced.length / 4);
347
504
  return { success: true, output: formatUserPromptSubmit(harness, fenced) };
348
505
  } catch {
349
506
  // Fail-open: a context hook must never break a prompt.
507
+ observation.outcome = 'error';
350
508
  return { success: true, output: '' };
351
509
  }
352
510
  }
353
511
 
512
+ async function handleMemoryRecall(rest, projectRoot, opts = {}) {
513
+ const harness = parseHarness(rest);
514
+ const startedAt = Date.now();
515
+ const observation = { harness };
516
+ const record = result => {
517
+ const outcome = result.reason === 'timeout'
518
+ ? 'timeout'
519
+ : (observation.outcome || (result.output ? 'selected' : 'empty'));
520
+ const eventObservation = {
521
+ ...observation,
522
+ outcome,
523
+ harness,
524
+ elapsedMs: Date.now() - startedAt,
525
+ };
526
+ try {
527
+ const pending = (opts.recordRecallEvent || opts.launchRecallEvent || launchMemoryRecallEvent)(
528
+ projectRoot,
529
+ eventObservation,
530
+ );
531
+ if (pending && typeof pending.catch === 'function') pending.catch(() => {});
532
+ } catch { /* Operational evidence is best-effort and must never delay or suppress a prompt. */ }
533
+ return result;
534
+ };
535
+ const capability = userPromptSubmitCapability(harness);
536
+ if (!capability.rendered) {
537
+ observation.outcome = 'unsupported';
538
+ return record({ success: true, output: '', reason: capability.reason });
539
+ }
540
+ const deadline = { expired: false };
541
+ const result = await withinDeadline(
542
+ () => runMemoryRecall(rest, projectRoot, { ...opts, deadline, observation }),
543
+ { success: true, output: '', reason: 'timeout' },
544
+ opts.promptRecallDeadlineMs || PROMPT_RECALL_DEADLINE_MS,
545
+ () => { deadline.expired = true; },
546
+ );
547
+ return record(result);
548
+ }
549
+
354
550
  /**
355
551
  * `forge hooks capture --harness <h> --trigger <precompact|stop>` — the CAPTURE-on-exit hook.
356
552
  * PreCompact (before context compaction) and Stop (turn end) fire it; it snapshots a bounded
@@ -374,7 +570,8 @@ function handleMemoryRecall(rest, projectRoot, opts = {}) {
374
570
  async function handleCapture(rest, projectRoot, opts = {}) {
375
571
  try {
376
572
  const harness = parseHarness(rest);
377
- if (!sessionEndCapability(harness).rendered) return { success: true, output: '' };
573
+ const capability = sessionEndCapability(harness);
574
+ if (!capability.rendered) return { success: true, output: '', reason: capability.reason };
378
575
  const trigger = parseTrigger(rest);
379
576
 
380
577
  const fetchIssues = opts.fetchIssues || defaultFetchIssues;
@@ -521,4 +718,5 @@ module.exports = {
521
718
  '--dry-run': 'Preview the merge without writing anything',
522
719
  },
523
720
  handler,
721
+ _internal: { appendShadowLog, SHADOW_LOG_MAX_BYTES },
524
722
  };
@@ -36,7 +36,7 @@ function positionals(args) {
36
36
  return values;
37
37
  }
38
38
 
39
- async function handler(args, flags, projectRoot) {
39
+ async function handler(args, flags, projectRoot, opts = {}) {
40
40
  const commandFlags = flags ?? {};
41
41
  const [subcommand, candidateId] = positionals(args);
42
42
  if (subcommand === 'accept' || subcommand === 'reject') {
@@ -54,14 +54,19 @@ async function handler(args, flags, projectRoot) {
54
54
  };
55
55
  }
56
56
 
57
- const result = analyzeInsights(projectRoot, {
57
+ const result = await analyzeInsights(projectRoot, {
58
58
  limit: readOption(args, '--limit', undefined),
59
59
  minCount: readOption(args, '--min-count', undefined),
60
60
  since: readOption(args, '--since', undefined),
61
+ // Injectable kernel-read seams (Slice C2); undefined falls back to the real
62
+ // cli-broker-factory-backed reads inside analyzeInsights.
63
+ runIssueOperation: opts.runIssueOperation,
64
+ listRecentEvents: opts.listRecentEvents,
65
+ env: opts.env,
61
66
  });
62
67
  if (args.includes('--review-feedback')) {
63
68
  result.limitations = [
64
- 'Compatibility note: --review-feedback now reads Beads interactions and issue evidence; external review-provider comments are not inferred.',
69
+ 'Compatibility note: --review-feedback now reads kernel events and issue evidence; external review-provider comments are not inferred.',
65
70
  ...result.limitations,
66
71
  ];
67
72
  }