forge-workflow 0.1.0-beta.3 → 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 (196) 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 +21 -1
  5. package/bin/forge.js +16 -369
  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 +9 -4
  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 +117 -17
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/greptile-review-adapter.js +1 -1
  19. package/lib/adapters/pr-state-adapter.js +397 -100
  20. package/lib/agents-config.js +5 -0
  21. package/lib/audit-evidence.js +71 -110
  22. package/lib/capped-jsonl-log.js +236 -0
  23. package/lib/commands/_issue.js +31 -46
  24. package/lib/commands/_manifest.js +1 -1
  25. package/lib/commands/_registry.js +2 -2
  26. package/lib/commands/_resolve-command-opts.js +36 -29
  27. package/lib/commands/claim.js +2 -4
  28. package/lib/commands/clean.js +196 -32
  29. package/lib/commands/dev.js +4 -33
  30. package/lib/commands/hooks.js +358 -13
  31. package/lib/commands/insights.js +8 -3
  32. package/lib/commands/merge.js +600 -40
  33. package/lib/commands/plan.js +23 -115
  34. package/lib/commands/pr.js +1 -1
  35. package/lib/commands/preflight.js +11 -2
  36. package/lib/commands/prime.js +23 -3
  37. package/lib/commands/push.js +41 -51
  38. package/lib/commands/recall.js +60 -16
  39. package/lib/commands/recap.js +6 -1
  40. package/lib/commands/release.js +18 -4
  41. package/lib/commands/serve.js +5 -2
  42. package/lib/commands/setup.js +191 -95
  43. package/lib/commands/shepherd.js +49 -4
  44. package/lib/commands/ship.js +22 -23
  45. package/lib/commands/skill.js +383 -0
  46. package/lib/commands/status.js +54 -33
  47. package/lib/commands/test.js +56 -34
  48. package/lib/commands/worktree.js +247 -43
  49. package/lib/core/runtime-graph.js +89 -15
  50. package/lib/doc-assertions.js +297 -0
  51. package/lib/existing-tdd-gate.js +253 -0
  52. package/lib/forge-context.js +1 -4
  53. package/lib/forge-issues.js +64 -491
  54. package/lib/git-defaults.js +56 -0
  55. package/lib/harness-capability-matrix.js +5 -5
  56. package/lib/hook-renderer.js +147 -16
  57. package/lib/insights.js +96 -80
  58. package/lib/issue-backend.js +42 -3
  59. package/lib/kernel/backing-issue.js +14 -2
  60. package/lib/kernel/broker.js +44 -0
  61. package/lib/kernel/cli-broker-factory.js +12 -1
  62. package/lib/kernel/close-on-merge.js +154 -0
  63. package/lib/kernel/fs-class.js +42 -25
  64. package/lib/kernel/migrations.js +30 -2
  65. package/lib/kernel/schema.js +35 -0
  66. package/lib/kernel/sqlite-driver.js +292 -18
  67. package/lib/lefthook-wiring.js +21 -1
  68. package/lib/memory/router.js +16 -1
  69. package/lib/memory-digest.js +47 -15
  70. package/lib/memory-recall-events.js +145 -0
  71. package/lib/memory-recall.js +212 -0
  72. package/lib/merge-rules.js +8 -4
  73. package/lib/npm-publish-workflow.js +272 -0
  74. package/lib/orientation.js +371 -49
  75. package/lib/plugin-catalog.js +14 -4
  76. package/lib/pr-bundle.js +9 -6
  77. package/lib/pr-monitor/journal.js +18 -2
  78. package/lib/pr-monitor/reconcile-executor.js +842 -0
  79. package/lib/pr-monitor/reconcile-tick.js +138 -0
  80. package/lib/pr-monitor/reconcile.js +0 -0
  81. package/lib/pr-monitor/render-summary.js +196 -0
  82. package/lib/pr-monitor/shepherd-lease.js +252 -0
  83. package/lib/pr-monitor/watch-lifecycle.js +14 -2
  84. package/lib/pr-pull.js +98 -24
  85. package/lib/pr-shepherd.js +34 -8
  86. package/lib/preflight/gates.js +65 -18
  87. package/lib/preflight/runner.js +5 -0
  88. package/lib/project-memory.js +40 -0
  89. package/lib/protected-state-authority.js +305 -0
  90. package/lib/protected-state-surfaces.js +64 -44
  91. package/lib/release-readiness.js +51 -4
  92. package/lib/rules-sync.js +4 -0
  93. package/lib/runtime-health.js +15 -46
  94. package/lib/shell-utils.js +1 -1
  95. package/lib/skill-eval.js +750 -0
  96. package/lib/skills-sync.js +6 -3
  97. package/lib/smart-merge.js +28 -4
  98. package/lib/status/identity.js +46 -0
  99. package/lib/status/presenter.js +0 -35
  100. package/lib/status/snapshot.js +11 -16
  101. package/lib/symlink-utils.js +74 -26
  102. package/lib/upgrade-safety.js +47 -9
  103. package/lib/using-forge.js +328 -0
  104. package/lib/workflow/enforce-stage.js +5 -5
  105. package/lib/workflow/state-manager.js +23 -23
  106. package/package.json +6 -7
  107. package/rules/using-forge.md +24 -0
  108. package/scripts/doc-asserting-tests.js +158 -0
  109. package/scripts/forge-team/index.sh +0 -5
  110. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  111. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  112. package/scripts/lib/behavioral-eval-runner.js +310 -0
  113. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  114. package/scripts/lib/eval-evidence.js +328 -0
  115. package/scripts/lib/eval-runner.js +81 -41
  116. package/scripts/lib/immutable-eval-corpus.js +309 -0
  117. package/scripts/lib/promotion-evidence-loader.js +94 -0
  118. package/scripts/lib/promotion-scorecard.js +314 -0
  119. package/scripts/npm-release-receipt.js +134 -0
  120. package/scripts/process-tree.js +761 -0
  121. package/scripts/protected-state-check.js +47 -22
  122. package/scripts/run-command-eval.js +29 -1
  123. package/scripts/sync-d20-audit.js +172 -0
  124. package/scripts/test-full-suite.js +249 -37
  125. package/scripts/test.js +184 -44
  126. package/skills/claim-safety/SKILL.md +4 -0
  127. package/skills/claim-safety/evals/scorecard.json +41 -0
  128. package/skills/coverage.json +83 -0
  129. package/skills/dev/SKILL.md +4 -0
  130. package/skills/dev/evals/scorecard.json +41 -0
  131. package/skills/gates/SKILL.md +80 -0
  132. package/skills/gates/evals/evals.json +38 -0
  133. package/skills/gates/evals/scorecard.json +41 -0
  134. package/skills/hermes-forge/SKILL.md +1 -0
  135. package/skills/hermes-forge/evals/scorecard.json +41 -0
  136. package/skills/issue-basics/SKILL.md +1 -0
  137. package/skills/issue-basics/evals/scorecard.json +41 -0
  138. package/skills/kernel/SKILL.md +38 -0
  139. package/skills/kernel/evals/scorecard.json +41 -0
  140. package/skills/memory/SKILL.md +16 -1
  141. package/skills/memory/evals/scorecard.json +41 -0
  142. package/skills/parallel-deep-research/SKILL.md +1 -0
  143. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  144. package/skills/plan/SKILL.md +6 -0
  145. package/skills/plan/evals/scorecard.json +41 -0
  146. package/skills/portability/SKILL.md +47 -0
  147. package/skills/portability/evals/evals.json +34 -0
  148. package/skills/portability/evals/scorecard.json +41 -0
  149. package/skills/research/SKILL.md +1 -0
  150. package/skills/research/evals/scorecard.json +41 -0
  151. package/skills/review/SKILL.md +10 -11
  152. package/skills/review/evals/scorecard.json +41 -0
  153. package/skills/rollback/SKILL.md +5 -11
  154. package/skills/rollback/evals/scorecard.json +41 -0
  155. package/skills/setup/SKILL.md +91 -0
  156. package/skills/setup/evals/evals.json +42 -0
  157. package/skills/setup/evals/scorecard.json +41 -0
  158. package/skills/shepherd/SKILL.md +84 -38
  159. package/skills/shepherd/evals/evals.json +21 -9
  160. package/skills/shepherd/evals/scorecard.json +41 -0
  161. package/skills/ship/SKILL.md +10 -12
  162. package/skills/ship/evals/scorecard.json +41 -0
  163. package/skills/smith/SKILL.md +8 -0
  164. package/skills/smith/evals/scorecard.json +41 -0
  165. package/skills/sonarcloud/SKILL.md +1 -0
  166. package/skills/sonarcloud/evals/scorecard.json +41 -0
  167. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  168. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  169. package/skills/status/SKILL.md +3 -0
  170. package/skills/status/evals/scorecard.json +41 -0
  171. package/skills/triage-ready/SKILL.md +2 -0
  172. package/skills/triage-ready/evals/scorecard.json +41 -0
  173. package/skills/using-forge/SKILL.md +104 -0
  174. package/skills/using-forge/evals/scorecard.json +41 -0
  175. package/skills/validate/SKILL.md +4 -0
  176. package/skills/validate/evals/scorecard.json +41 -0
  177. package/skills/verify/SKILL.md +4 -0
  178. package/skills/verify/evals/scorecard.json +41 -0
  179. package/skills/worktree/SKILL.md +92 -0
  180. package/skills/worktree/evals/evals.json +38 -0
  181. package/skills/worktree/evals/scorecard.json +41 -0
  182. package/lib/adapters/beads-issue-adapter.js +0 -127
  183. package/lib/beads-nudge.js +0 -91
  184. package/lib/beads-setup.js +0 -538
  185. package/lib/beads-sync-scaffold.js +0 -189
  186. package/lib/commands/board.js +0 -64
  187. package/lib/pat-setup.js +0 -207
  188. package/lib/pr-monitor/render-sticky.js +0 -192
  189. package/lib/pr-monitor/upsert-sticky.js +0 -169
  190. package/lib/status/beads-snapshot.js +0 -145
  191. package/scripts/beads-context.sh +0 -577
  192. package/scripts/beads-migrate-to-dolt.sh +0 -7
  193. package/scripts/beads-upgrade-smoke.sh +0 -284
  194. package/scripts/forge-team/lib/dashboard.sh +0 -316
  195. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -27,16 +27,151 @@ const {
27
27
  renderGlobalHookBlock,
28
28
  installGlobalHooks,
29
29
  } = require('../hook-global-installer');
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
30
32
  const { sessionStartCapability, userPromptSubmitCapability, sessionEndCapability } = require('../hook-renderer');
31
- const { collectDigestData, buildMemoryDigest, defaultFetchIssues, defaultFetchNotes } = require('../memory-digest');
33
+ const { buildMemoryDigest, defaultFetchIssues, defaultFetchNotes } = require('../memory-digest');
32
34
  const { collectInbox, buildInboxNudge } = require('../inbox');
33
35
  const { collectDigest } = require('../pr-monitor/digest');
36
+ const { loadDispatchText } = require('../using-forge');
37
+ const projectMemory = require('../project-memory');
38
+ const { parseHookInput, selectInjection, meaningfulTokens, DEFAULT_TOKEN_BUDGET, DEFAULT_SCORE_FLOOR } = require('../memory-recall');
39
+ const { launchMemoryRecallEvent } = require('../memory-recall-events');
40
+ const { getResolvedRuntimeGraph } = require('../core/runtime-graph');
41
+ const { fireAndForget } = require('../pr-monitor/reconcile-executor');
42
+
43
+ // Default-ON rail; `forge gate disable rail.memory_recall` turns tier-2 off. Mirrors
44
+ // autoShepherdRailEnabled (lib/commands/ship.js): absent id = enabled, fail-open to true.
45
+ const MEMORY_RECALL_RAIL = 'rail.memory_recall';
46
+ // bm25 candidate pool to rank/floor down from. Wider than what we inject so the floor +
47
+ // dedupe have room (mirrors the research "widen before ranking" guidance).
48
+ const MEMORY_RECALL_CANDIDATES = 25;
49
+ // Cross-turn dedupe memory: how many recently-injected keys to remember per session.
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
+ }
87
+
88
+ function memoryRecallRailEnabled(projectRoot, resolveGraph = getResolvedRuntimeGraph) {
89
+ try {
90
+ const graph = resolveGraph({ projectRoot });
91
+ const rail = [...(graph.rails || []), ...(graph.gates || [])].find(entry => entry.id === MEMORY_RECALL_RAIL);
92
+ return !(rail?.enabled === false);
93
+ } catch {
94
+ return true;
95
+ }
96
+ }
97
+
98
+ // Read the hook's stdin payload (Claude delivers UserPromptSubmit JSON on fd 0). Never
99
+ // throws — no stdin / a closed fd yields '' so the hook fails open.
100
+ function readHookStdin() {
101
+ try {
102
+ return fs.readFileSync(0, 'utf8');
103
+ } catch {
104
+ return '';
105
+ }
106
+ }
107
+
108
+ function seenPath(projectRoot, sessionId) {
109
+ const safe = String(sessionId || 'nosession').replace(/[^a-zA-Z0-9_-]/g, '_');
110
+ return path.join(projectRoot, '.forge', 'memory-recall', `${safe}.json`);
111
+ }
112
+
113
+ // Keys injected on recent turns of THIS session (cross-turn dedupe). Best-effort: any read
114
+ // error yields [] so a first turn or a corrupt file simply injects without exclusion.
115
+ function loadSeenKeys(projectRoot, sessionId) {
116
+ try {
117
+ const parsed = JSON.parse(fs.readFileSync(seenPath(projectRoot, sessionId), 'utf8'));
118
+ return Array.isArray(parsed) ? parsed.filter(k => typeof k === 'string') : [];
119
+ } catch {
120
+ return [];
121
+ }
122
+ }
123
+
124
+ // Append the just-injected keys to the session's seen-list (newest last, capped). Best-effort.
125
+ function saveSeenKeys(projectRoot, sessionId, keys) {
126
+ try {
127
+ const merged = [...loadSeenKeys(projectRoot, sessionId), ...keys].slice(-SEEN_KEYS_CAP);
128
+ const file = seenPath(projectRoot, sessionId);
129
+ fs.mkdirSync(path.dirname(file), { recursive: true });
130
+ fs.writeFileSync(file, JSON.stringify(merged), 'utf8');
131
+ } catch {
132
+ // Dedupe is a nicety, not a correctness gate — a failed write never breaks the prompt.
133
+ }
134
+ }
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
+ }
34
168
 
35
169
  function usage() {
36
170
  return 'Usage: forge hooks install --global [--harness codex|hermes|all] [--dry-run]\n'
37
171
  + ' forge hooks session-start --harness <claude> (machine-facing; emits SessionStart context)\n'
38
172
  + ' forge hooks inbox-pickup --harness <claude> (machine-facing; emits UserPromptSubmit context)\n'
39
173
  + ' forge hooks shepherd-events --harness <claude> (machine-facing; emits UserPromptSubmit PR-monitor deltas)\n'
174
+ + ' forge hooks memory-recall --harness <claude> (machine-facing; emits UserPromptSubmit query-relevant memory)\n'
40
175
  + ' forge hooks capture --harness <claude> --trigger <precompact|stop> (machine-facing; captures a session summary on exit)';
41
176
  }
42
177
 
@@ -111,21 +246,46 @@ function formatSessionStart(harness, text) {
111
246
  *
112
247
  * @param {string[]} rest - args after the `session-start` action.
113
248
  * @param {string} projectRoot
114
- * @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 }) }).
115
251
  * @returns {Promise<{ success: boolean, output: string }>}
116
252
  */
117
253
  async function handleSessionStart(rest, projectRoot, opts = {}) {
254
+ const harness = parseHarness(rest);
255
+ const capability = sessionStartCapability(harness);
256
+ if (!capability.rendered) return { success: true, output: '', reason: capability.reason };
118
257
  try {
119
- const harness = parseHarness(rest);
120
- if (!sessionStartCapability(harness).rendered) return { success: true, output: '' };
121
- const data = await collectDigestData(projectRoot, opts);
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 */ }
262
+
263
+ // The using-forge dispatch bootstrap is injected FIRST so Forge skills auto-trigger from turn
264
+ // one (the Superpowers mechanism): a reasoning-driven system, not just harness description
265
+ // matching. It is a deterministic file read that survives a kernel outage. The memory digest
266
+ // (remembered notes + top open issues) is appended when present. Either alone is enough to
267
+ // inject; a total blank yields '' (the harness injects nothing). FAIL-OPEN throughout.
268
+ let dispatch = '';
269
+ try {
270
+ // No projectRoot: the dispatch skill is read from the Forge PACKAGE's canonical skills/
271
+ // (a set-up consumer project has no root skills/, only generated mirrors).
272
+ dispatch = (opts.loadDispatchText || loadDispatchText)() || '';
273
+ } catch { /* keep '' — a missing dispatch skill must not break session start */ }
274
+
275
+ let digestText = '';
276
+ try {
277
+ const data = await collectSessionStartData(
278
+ projectRoot,
279
+ opts,
280
+ opts.sessionStartDeadlineMs || SESSION_START_DEADLINE_MS,
281
+ );
122
282
  const digest = buildMemoryDigest(data, opts);
123
- if (digest.empty) return { success: true, output: '' };
124
- return { success: true, output: formatSessionStart(harness, digest.text) };
125
- } catch {
126
- // Fail-open: a context hook must never break a session.
127
- return { success: true, output: '' };
128
- }
283
+ if (!digest.empty) digestText = digest.text;
284
+ } catch { /* fail-open: a kernel outage must not suppress the dispatch bootstrap */ }
285
+
286
+ const combined = [dispatch, digestText].filter(Boolean).join('\n\n');
287
+ if (!combined) return { success: true, output: '' };
288
+ return { success: true, output: formatSessionStart(harness, combined) };
129
289
  }
130
290
 
131
291
  /** Wrap a digest into a harness-native UserPromptSubmit payload, or '' when unsupported. */
@@ -160,7 +320,8 @@ function formatUserPromptSubmit(harness, text) {
160
320
  async function handleInboxPickup(rest, projectRoot, opts = {}) {
161
321
  try {
162
322
  const harness = parseHarness(rest);
163
- if (!userPromptSubmitCapability(harness).rendered) return { success: true, output: '' };
323
+ const capability = userPromptSubmitCapability(harness);
324
+ if (!capability.rendered) return { success: true, output: '', reason: capability.reason };
164
325
  const pending = await collectInbox(projectRoot, opts);
165
326
  const nudge = buildInboxNudge(pending);
166
327
  if (nudge.empty) return { success: true, output: '' };
@@ -205,6 +366,187 @@ function handleShepherdEvents(rest, projectRoot, opts = {}) {
205
366
  }
206
367
  }
207
368
 
369
+ /**
370
+ * `forge hooks memory-recall --harness <h>` — the QUERY-RELEVANT memory tier (tier-2). On each
371
+ * prompt it reads the submitted prompt from the hook's stdin, ranks stored memories by BM25
372
+ * relevance to it (NOT recency — that is the SessionStart digest's job), applies a relevance
373
+ * floor + anaphora guard + cross-turn dedupe + a hard token budget, and emits the survivors as
374
+ * harness-native UserPromptSubmit context. Complements the always-on recency digest: this one
375
+ * answers "what memory is relevant to THIS turn".
376
+ *
377
+ * Compliance + safety (verified against the Claude Code hooks contract, kernel issue 781f6f65):
378
+ * - It READS its own hook stdin (the supported input channel) — it does NOT inject into a
379
+ * running session's stdin and never drives the agent (Anthropic Usage Policy).
380
+ * - additionalContext APPENDS to history every prompt, so the injection is tiny and gated:
381
+ * below the relevance floor, or on a low-signal (anaphora) prompt, it injects NOTHING.
382
+ * - Injected memory bodies are UNTRUSTED (a planted note could carry directives), so each is
383
+ * provenance-fenced.
384
+ * - UserPromptSubmit has a 30s timeout and blocks the prompt; the read is local BM25 over
385
+ * SQLite (sub-ms) and FAIL-OPEN — any error, a disabled rail, an unsupported harness, or
386
+ * nothing relevant yields '' and the prompt proceeds untouched. NEVER throws.
387
+ * - Kill-switch: `forge gate disable rail.memory_recall`.
388
+ *
389
+ * @param {string[]} rest - args after the `memory-recall` action.
390
+ * @param {string} projectRoot
391
+ * @param {object} [opts] - injectable seams for tests: { railEnabled, readInput, search,
392
+ * loadSeen, saveSeen, scoreFloor, tokenBudget }.
393
+ * @returns {{ success: boolean, output: string }}
394
+ */
395
+ async function runMemoryRecall(rest, projectRoot, opts = {}) {
396
+ const observation = opts.observation || {};
397
+ try {
398
+ const harness = parseHarness(rest);
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
+ }
405
+
406
+ const railEnabled = opts.railEnabled || memoryRecallRailEnabled;
407
+ if (!railEnabled(projectRoot)) {
408
+ observation.outcome = 'filtered';
409
+ return { success: true, output: '' };
410
+ }
411
+
412
+ const readInput = opts.readInput || readHookStdin;
413
+ const { prompt, sessionId } = parseHookInput(readInput());
414
+ if (!prompt) {
415
+ observation.outcome = 'filtered';
416
+ return { success: true, output: '' };
417
+ }
418
+
419
+ const search = opts.search
420
+ || ((root, query, limit, options) => projectMemory.searchRankedScored(root, query, limit, options));
421
+ const loadSeen = opts.loadSeen || loadSeenKeys;
422
+ const saveSeen = opts.saveSeen || saveSeenKeys;
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({
450
+ query: prompt,
451
+ hits,
452
+ scoreFloor,
453
+ tokenBudget: opts.tokenBudget || DEFAULT_TOKEN_BUDGET,
454
+ excludeKeys,
455
+ });
456
+
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);
504
+ return { success: true, output: formatUserPromptSubmit(harness, fenced) };
505
+ } catch {
506
+ // Fail-open: a context hook must never break a prompt.
507
+ observation.outcome = 'error';
508
+ return { success: true, output: '' };
509
+ }
510
+ }
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
+
208
550
  /**
209
551
  * `forge hooks capture --harness <h> --trigger <precompact|stop>` — the CAPTURE-on-exit hook.
210
552
  * PreCompact (before context compaction) and Stop (turn end) fire it; it snapshots a bounded
@@ -228,7 +570,8 @@ function handleShepherdEvents(rest, projectRoot, opts = {}) {
228
570
  async function handleCapture(rest, projectRoot, opts = {}) {
229
571
  try {
230
572
  const harness = parseHarness(rest);
231
- if (!sessionEndCapability(harness).rendered) return { success: true, output: '' };
573
+ const capability = sessionEndCapability(harness);
574
+ if (!capability.rendered) return { success: true, output: '', reason: capability.reason };
232
575
  const trigger = parseTrigger(rest);
233
576
 
234
577
  const fetchIssues = opts.fetchIssues || defaultFetchIssues;
@@ -356,6 +699,7 @@ async function handler(args, flags = {}, projectRoot, opts = {}) {
356
699
  if (action === 'session-start') return handleSessionStart(args.slice(1), projectRoot, opts);
357
700
  if (action === 'inbox-pickup') return handleInboxPickup(args.slice(1), projectRoot, opts);
358
701
  if (action === 'shepherd-events') return handleShepherdEvents(args.slice(1), projectRoot, opts);
702
+ if (action === 'memory-recall') return handleMemoryRecall(args.slice(1), projectRoot, opts);
359
703
  if (action === 'capture') return handleCapture(args.slice(1), projectRoot, opts);
360
704
  if (action === 'install') return handleInstall(args, flags, opts);
361
705
  return {
@@ -374,4 +718,5 @@ module.exports = {
374
718
  '--dry-run': 'Preview the merge without writing anything',
375
719
  },
376
720
  handler,
721
+ _internal: { appendShadowLog, SHADOW_LOG_MAX_BYTES },
377
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
  }