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

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 +18 -7
  2. package/CHANGELOG.md +79 -1
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/README.md +6 -2
  6. package/bin/forge-cmd.js +20 -0
  7. package/bin/forge.js +28 -375
  8. package/docs/INDEX.md +1 -1
  9. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  10. package/docs/guides/MIGRATION.md +4 -4
  11. package/docs/guides/SETUP.md +16 -16
  12. package/docs/reference/COMMANDS.md +8 -5
  13. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  14. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  15. package/docs/reference/INSTALL.md +4 -0
  16. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  17. package/docs/reference/RELEASE.md +5 -3
  18. package/docs/reference/TOOLCHAIN.md +8 -0
  19. package/docs/reference/github-accounts.md +134 -0
  20. package/docs/reference/protected-state-surfaces.md +4 -4
  21. package/docs/reference/shepherd.md +114 -35
  22. package/lefthook.yml +12 -0
  23. package/lib/activation/ensure-forge-home.js +33 -15
  24. package/lib/adapters/pr-state-adapter.js +359 -144
  25. package/lib/audit-evidence.js +71 -110
  26. package/lib/base-remote.js +138 -0
  27. package/lib/beta5-compatibility-evidence.js +1093 -0
  28. package/lib/bun-lockfile-proof.js +413 -0
  29. package/lib/bun-workflow-pins.js +461 -0
  30. package/lib/capabilities/index.js +9 -0
  31. package/lib/capabilities/model.js +141 -0
  32. package/lib/capabilities/probes.js +347 -0
  33. package/lib/capped-jsonl-log.js +236 -0
  34. package/lib/codex-skills.js +2 -2
  35. package/lib/commands/_manifest.js +1 -0
  36. package/lib/commands/_registry.js +50 -20
  37. package/lib/commands/clean.js +252 -32
  38. package/lib/commands/dev.js +4 -33
  39. package/lib/commands/doctor.js +37 -6
  40. package/lib/commands/gate.js +197 -27
  41. package/lib/commands/github.js +215 -0
  42. package/lib/commands/hooks.js +276 -30
  43. package/lib/commands/insights.js +8 -3
  44. package/lib/commands/memory.js +66 -2
  45. package/lib/commands/merge.js +1265 -58
  46. package/lib/commands/plan.js +33 -2
  47. package/lib/commands/pr.js +3 -1
  48. package/lib/commands/preflight.js +21 -4
  49. package/lib/commands/prime.js +21 -8
  50. package/lib/commands/push.js +146 -54
  51. package/lib/commands/recall.js +127 -49
  52. package/lib/commands/recap.js +6 -1
  53. package/lib/commands/release.js +39 -3
  54. package/lib/commands/remember.js +28 -4
  55. package/lib/commands/serve.js +26 -9
  56. package/lib/commands/setup.js +323 -98
  57. package/lib/commands/shepherd.js +591 -73
  58. package/lib/commands/ship.js +36 -91
  59. package/lib/commands/skill.js +127 -11
  60. package/lib/commands/status.js +17 -1
  61. package/lib/commands/team.js +47 -8
  62. package/lib/commands/test.js +187 -38
  63. package/lib/commands/validate.js +65 -21
  64. package/lib/commands/worktree.js +359 -45
  65. package/lib/core/runtime-graph.js +1 -1
  66. package/lib/doc-assertions.js +297 -0
  67. package/lib/existing-tdd-gate.js +253 -0
  68. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  69. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  70. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  71. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  72. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  73. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  74. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  75. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  76. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  77. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  78. package/lib/forge-context.js +1 -4
  79. package/lib/forge-issues.js +134 -32
  80. package/lib/gate-events.js +98 -10
  81. package/lib/git-defaults.js +56 -0
  82. package/lib/github-context.js +308 -0
  83. package/lib/global-flags.js +1 -0
  84. package/lib/harness-capability-matrix.js +3 -3
  85. package/lib/hook-renderer.js +122 -5
  86. package/lib/insights.js +96 -80
  87. package/lib/issue-render.js +19 -0
  88. package/lib/kernel/backing-issue.js +14 -2
  89. package/lib/kernel/broker.js +739 -31
  90. package/lib/kernel/claim-reconciler.js +238 -0
  91. package/lib/kernel/cli-broker-factory.js +12 -1
  92. package/lib/kernel/close-on-merge.js +154 -0
  93. package/lib/kernel/fs-class.js +42 -25
  94. package/lib/kernel/lease-enforcer.js +9 -4
  95. package/lib/kernel/legacy-claim-repair.js +442 -0
  96. package/lib/kernel/live-claim-projection.js +26 -0
  97. package/lib/kernel/migrations.js +118 -3
  98. package/lib/kernel/readiness-model.js +184 -12
  99. package/lib/kernel/schema.js +49 -1
  100. package/lib/kernel/sqlite-driver.js +3435 -172
  101. package/lib/kernel/taxonomy-validator.js +4 -1
  102. package/lib/kernel/windows-private-acl.js +239 -0
  103. package/lib/lefthook-wiring.js +21 -1
  104. package/lib/memory/hygiene.js +191 -0
  105. package/lib/memory/router.js +110 -28
  106. package/lib/memory/usage-evidence.js +4 -0
  107. package/lib/memory-digest.js +106 -15
  108. package/lib/memory-recall-events.js +145 -0
  109. package/lib/memory-recall.js +71 -10
  110. package/lib/merge-rules.js +143 -21
  111. package/lib/npm-publish-workflow.js +465 -0
  112. package/lib/orientation.js +68 -43
  113. package/lib/package-root.js +2 -0
  114. package/lib/plugin-catalog.js +14 -4
  115. package/lib/pr-bundle.js +5 -6
  116. package/lib/pr-monitor/auto-actions.js +169 -28
  117. package/lib/pr-monitor/differ.js +110 -4
  118. package/lib/pr-monitor/events.js +0 -0
  119. package/lib/pr-monitor/flow-monitor.js +1424 -0
  120. package/lib/pr-monitor/gather.js +251 -44
  121. package/lib/pr-monitor/journal.js +18 -39
  122. package/lib/pr-monitor/monitor.js +117 -10
  123. package/lib/pr-monitor/process-identity.js +117 -0
  124. package/lib/pr-monitor/reconcile-executor.js +1129 -470
  125. package/lib/pr-monitor/reconcile.js +0 -0
  126. package/lib/pr-monitor/render-summary.js +293 -0
  127. package/lib/pr-monitor/review-preflight.js +269 -0
  128. package/lib/pr-monitor/shepherd-lease.js +38 -20
  129. package/lib/pr-monitor/verdict.js +438 -0
  130. package/lib/pr-monitor/watch-lifecycle.js +145 -27
  131. package/lib/pr-monitor/watch-owner.js +1414 -0
  132. package/lib/pr-monitor/watch.js +129 -58
  133. package/lib/pr-pull.js +33 -14
  134. package/lib/pr-shepherd.js +51 -11
  135. package/lib/preflight/gates.js +65 -18
  136. package/lib/preflight/runner.js +5 -0
  137. package/lib/project-memory.js +178 -4
  138. package/lib/protected-state-authority.js +1100 -0
  139. package/lib/protected-state-surfaces.js +243 -45
  140. package/lib/release-readiness.js +53 -7
  141. package/lib/review-adapter.js +65 -0
  142. package/lib/shell-utils.js +1 -1
  143. package/lib/skills-sync.js +71 -35
  144. package/lib/smart-merge.js +28 -4
  145. package/lib/symlink-utils.js +74 -26
  146. package/lib/upgrade-safety.js +39 -0
  147. package/lib/using-forge.js +19 -6
  148. package/lib/validation/risk-manifest.js +339 -0
  149. package/lib/workflow/enforce-stage.js +44 -0
  150. package/lib/workflow/plan-authority.js +225 -0
  151. package/package.json +12 -9
  152. package/scripts/commitlint.js +13 -15
  153. package/scripts/doc-asserting-tests.js +158 -0
  154. package/scripts/generate-risk-manifest.js +91 -0
  155. package/scripts/github-context-bridge.sh +10 -0
  156. package/scripts/legacy-claim-repair.js +145 -0
  157. package/scripts/lib/behavioral-eval-runner.js +310 -0
  158. package/scripts/lib/behavioral-eval-runtime.js +457 -0
  159. package/scripts/lib/eval-evidence.js +328 -0
  160. package/scripts/lib/eval-runner.js +81 -41
  161. package/scripts/lib/immutable-eval-corpus.js +309 -0
  162. package/scripts/lib/promotion-evidence-loader.js +94 -0
  163. package/scripts/lib/promotion-scorecard.js +314 -0
  164. package/scripts/npm-release-receipt.js +134 -0
  165. package/scripts/process-tree.js +773 -0
  166. package/scripts/protected-state-check.js +479 -31
  167. package/scripts/run-command-eval.js +29 -1
  168. package/scripts/sync-agent-skills.js +333 -34
  169. package/scripts/sync-d20-audit.js +172 -0
  170. package/scripts/test-full-suite.js +935 -37
  171. package/scripts/test-profile.js +13 -3
  172. package/scripts/test.js +271 -57
  173. package/skills/coverage.json +1 -0
  174. package/skills/review/SKILL.md +6 -11
  175. package/skills/review/evals/scorecard.json +4 -4
  176. package/skills/rollback/SKILL.md +4 -11
  177. package/skills/rollback/evals/scorecard.json +3 -3
  178. package/skills/setup/SKILL.md +18 -0
  179. package/skills/setup/evals/scorecard.json +3 -3
  180. package/skills/shepherd/SKILL.md +39 -16
  181. package/skills/shepherd/evals/scorecard.json +4 -4
  182. package/skills/ship/SKILL.md +4 -12
  183. package/skills/ship/evals/scorecard.json +3 -3
  184. package/skills/validate/SKILL.md +3 -0
  185. package/skills/validate/evals/scorecard.json +1 -1
  186. package/skills/worktree/SKILL.md +6 -1
  187. package/skills/worktree/evals/scorecard.json +2 -2
  188. package/lib/beads-setup.js +0 -538
  189. package/lib/beads-sync-scaffold.js +0 -189
  190. package/lib/pat-setup.js +0 -207
  191. package/lib/pr-monitor/render-sticky.js +0 -206
  192. package/lib/pr-monitor/upsert-sticky.js +0 -169
  193. package/scripts/beads-context.sh +0 -577
  194. package/scripts/beads-migrate-to-dolt.sh +0 -7
  195. package/scripts/beads-upgrade-smoke.sh +0 -284
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -29,15 +29,16 @@ const {
29
29
  } = require('../hook-global-installer');
30
30
  const fs = require('node:fs');
31
31
  const path = require('node:path');
32
- const { sessionStartCapability, userPromptSubmitCapability, sessionEndCapability } = require('../hook-renderer');
33
- const { collectDigestData, buildMemoryDigest, defaultFetchIssues, defaultFetchNotes } = require('../memory-digest');
32
+ const { sessionStartCapability, userPromptSubmitCapability, sessionEndCapability, readAttentionCapability } = require('../hook-renderer');
33
+ const { buildMemoryDigest, buildReadAttentionDigest, 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,12 +133,46 @@ 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'
102
172
  + ' forge hooks inbox-pickup --harness <claude> (machine-facing; emits UserPromptSubmit context)\n'
103
173
  + ' forge hooks shepherd-events --harness <claude> (machine-facing; emits UserPromptSubmit PR-monitor deltas)\n'
104
174
  + ' forge hooks memory-recall --harness <claude> (machine-facing; emits UserPromptSubmit query-relevant memory)\n'
175
+ + ' forge hooks read-attention --harness <claude> (machine-facing; emits path-matched PreToolUse:Read context)\n'
105
176
  + ' forge hooks capture --harness <claude> --trigger <precompact|stop> (machine-facing; captures a session summary on exit)';
106
177
  }
107
178
 
@@ -142,12 +213,23 @@ const CAPTURE_TAGS = ['type:session-summary', CAPTURE_AUTO_TAG];
142
213
  * @returns {string}
143
214
  */
144
215
  function buildCaptureNote(trigger, issues) {
145
- const capped = issues.slice(0, CAPTURE_ISSUE_CAP);
146
- const lines = capped.map(issue => {
216
+ const stableIssues = [...new Map(issues.map(issue => {
217
+ const id = String((issue && issue.id) || '');
147
218
  const title = String((issue && (issue.title || issue.id)) || 'untitled').replace(/\s+/g, ' ').trim();
219
+ return [`${id}\0${title}`, { id, title }];
220
+ })).values()].sort((left, right) => {
221
+ const leftKey = `${left.id}\0${left.title}`;
222
+ const rightKey = `${right.id}\0${right.title}`;
223
+ if (leftKey < rightKey) return -1;
224
+ if (leftKey > rightKey) return 1;
225
+ return 0;
226
+ });
227
+ const capped = stableIssues.slice(0, CAPTURE_ISSUE_CAP);
228
+ const lines = capped.map(issue => {
229
+ const title = issue.title;
148
230
  return `- ${title.length > CAPTURE_TITLE_CAP ? `${title.slice(0, CAPTURE_TITLE_CAP)}…` : title}`;
149
231
  });
150
- const more = issues.length > capped.length ? `\n- …and ${issues.length - capped.length} more` : '';
232
+ const more = stableIssues.length > capped.length ? `\n- …and ${stableIssues.length - capped.length} more` : '';
151
233
  const body = lines.length
152
234
  ? `Session boundary (${trigger}) — in-progress:\n${lines.join('\n')}${more}`
153
235
  : `Session boundary (${trigger}) — no in-progress issues.`;
@@ -176,16 +258,19 @@ function formatSessionStart(harness, text) {
176
258
  *
177
259
  * @param {string[]} rest - args after the `session-start` action.
178
260
  * @param {string} projectRoot
179
- * @param {object} [opts] - injectable digest fetchers ({ fetchNotes, fetchIssues }).
261
+ * @param {object} [opts] - injectable digest fetchers ({ fetchNotes, fetchIssues }) and an
262
+ * optional host `harness` capability ({ hasBgShell: true, runBgShell(argv, { cwd }) }).
180
263
  * @returns {Promise<{ success: boolean, output: string }>}
181
264
  */
182
265
  async function handleSessionStart(rest, projectRoot, opts = {}) {
183
266
  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).
267
+ const capability = sessionStartCapability(harness);
268
+ if (!capability.rendered) return { success: true, output: '', reason: capability.reason };
269
+ try {
270
+ const triggerContext = { projectRoot };
271
+ if (opts.harness !== undefined) triggerContext.harness = opts.harness;
272
+ (opts.fireAndForget || fireAndForget)(triggerContext);
273
+ } catch { /* the automatic trigger must never break session start */ }
189
274
 
190
275
  // The using-forge dispatch bootstrap is injected FIRST so Forge skills auto-trigger from turn
191
276
  // one (the Superpowers mechanism): a reasoning-driven system, not just harness description
@@ -201,7 +286,11 @@ async function handleSessionStart(rest, projectRoot, opts = {}) {
201
286
 
202
287
  let digestText = '';
203
288
  try {
204
- const data = await collectDigestData(projectRoot, opts);
289
+ const data = await collectSessionStartData(
290
+ projectRoot,
291
+ opts,
292
+ opts.sessionStartDeadlineMs || SESSION_START_DEADLINE_MS,
293
+ );
205
294
  const digest = buildMemoryDigest(data, opts);
206
295
  if (!digest.empty) digestText = digest.text;
207
296
  } catch { /* fail-open: a kernel outage must not suppress the dispatch bootstrap */ }
@@ -222,6 +311,41 @@ function formatUserPromptSubmit(harness, text) {
222
311
  return '';
223
312
  }
224
313
 
314
+ function formatPreToolUse(harness, text) {
315
+ if (harness !== 'claude') return '';
316
+ return JSON.stringify({
317
+ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: text },
318
+ });
319
+ }
320
+
321
+ function fetchReadAttentionNotes(projectRoot) {
322
+ return projectMemory.recent(projectRoot, 100).map(entry => ({
323
+ note: typeof entry.value === 'string' ? entry.value : JSON.stringify(entry.value),
324
+ tags: Array.isArray(entry.tags) ? entry.tags : [],
325
+ sourceAgent: entry.sourceAgent,
326
+ timestamp: entry.timestamp,
327
+ }));
328
+ }
329
+
330
+ async function handleReadAttention(rest, projectRoot, opts = {}) {
331
+ try {
332
+ const harness = parseHarness(rest);
333
+ const capability = readAttentionCapability(harness);
334
+ if (!capability.rendered) return { success: true, output: '', reason: capability.reason };
335
+ const readInput = opts.readInput || readHookStdin;
336
+ const payload = JSON.parse(readInput() || '{}');
337
+ const targetPath = payload?.tool_input?.file_path || payload?.tool_input?.path;
338
+ if (!targetPath) return { success: true, output: '' };
339
+ const fetchNotes = opts.fetchNotes || fetchReadAttentionNotes;
340
+ const notes = await fetchNotes(projectRoot, { ...opts, noteLimit: 100 });
341
+ const digest = buildReadAttentionDigest(targetPath, notes, opts);
342
+ if (digest.empty) return { success: true, output: '' };
343
+ return { success: true, output: formatPreToolUse(harness, digest.text) };
344
+ } catch {
345
+ return { success: true, output: '' };
346
+ }
347
+ }
348
+
225
349
  /**
226
350
  * `forge hooks inbox-pickup --harness <h>` — the COMPLIANT comment-back CONTEXT hook. On each
227
351
  * prompt it emits harness-native UserPromptSubmit JSON carrying a COMPACT count+pointer nudge
@@ -243,7 +367,8 @@ function formatUserPromptSubmit(harness, text) {
243
367
  async function handleInboxPickup(rest, projectRoot, opts = {}) {
244
368
  try {
245
369
  const harness = parseHarness(rest);
246
- if (!userPromptSubmitCapability(harness).rendered) return { success: true, output: '' };
370
+ const capability = userPromptSubmitCapability(harness);
371
+ if (!capability.rendered) return { success: true, output: '', reason: capability.reason };
247
372
  const pending = await collectInbox(projectRoot, opts);
248
373
  const nudge = buildInboxNudge(pending);
249
374
  if (nudge.empty) return { success: true, output: '' };
@@ -314,43 +439,161 @@ function handleShepherdEvents(rest, projectRoot, opts = {}) {
314
439
  * loadSeen, saveSeen, scoreFloor, tokenBudget }.
315
440
  * @returns {{ success: boolean, output: string }}
316
441
  */
317
- function handleMemoryRecall(rest, projectRoot, opts = {}) {
442
+ async function runMemoryRecall(rest, projectRoot, opts = {}) {
443
+ const observation = opts.observation || {};
318
444
  try {
319
445
  const harness = parseHarness(rest);
320
- if (!userPromptSubmitCapability(harness).rendered) return { success: true, output: '' };
446
+ observation.harness = harness;
447
+ const capability = userPromptSubmitCapability(harness);
448
+ if (!capability.rendered) {
449
+ observation.outcome = 'unsupported';
450
+ return { success: true, output: '', reason: capability.reason };
451
+ }
321
452
 
322
453
  const railEnabled = opts.railEnabled || memoryRecallRailEnabled;
323
- if (!railEnabled(projectRoot)) return { success: true, output: '' };
454
+ if (!railEnabled(projectRoot)) {
455
+ observation.outcome = 'filtered';
456
+ return { success: true, output: '' };
457
+ }
324
458
 
325
459
  const readInput = opts.readInput || readHookStdin;
326
460
  const { prompt, sessionId } = parseHookInput(readInput());
327
- if (!prompt) return { success: true, output: '' };
461
+ if (!prompt) {
462
+ observation.outcome = 'filtered';
463
+ return { success: true, output: '' };
464
+ }
328
465
 
329
466
  const search = opts.search
330
- || ((root, query, limit) => projectMemory.searchRankedScored(root, query, limit));
467
+ || ((root, query, limit, options) => projectMemory.searchRankedScored(root, query, limit, options));
331
468
  const loadSeen = opts.loadSeen || loadSeenKeys;
332
469
  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({
470
+ const appendShadow = opts.appendShadow || appendShadowLog;
471
+
472
+ // Stopword-aware, unicode-safe tokenization lives HERE (single source); the driver's
473
+ // buildMemoryFtsMatchOr only quotes+ORs whatever tokens it receives. Passing the raw prompt
474
+ // to a token-AND match was the 0-recall bug (kernel issue f71784d3).
475
+ const tokens = meaningfulTokens(prompt);
476
+ const excludeKeys = await loadSeen(projectRoot, sessionId) || [];
477
+ const hits = await search(
478
+ projectRoot,
479
+ tokens.join(' '),
480
+ MEMORY_RECALL_CANDIDATES,
481
+ {
482
+ excludeKeys: excludeKeys.slice(0, 256),
483
+ busyTimeoutMs: Math.max(0, Math.min(
484
+ opts.promptRecallSqliteBusyMs ?? PROMPT_RECALL_SQLITE_BUSY_MS,
485
+ (opts.promptRecallDeadlineMs || PROMPT_RECALL_DEADLINE_MS) - 100,
486
+ )),
487
+ },
488
+ ) || [];
489
+ observation.candidateCount = hits.length;
490
+ observation.eligibleCount = hits.length;
491
+ if (opts.deadline?.expired) {
492
+ observation.outcome = 'timeout';
493
+ return { success: true, output: '', reason: 'timeout' };
494
+ }
495
+ const scoreFloor = typeof opts.scoreFloor === 'number' ? opts.scoreFloor : DEFAULT_SCORE_FLOOR;
496
+ const { lines, entries, injectedKeys } = selectInjection({
337
497
  query: prompt,
338
498
  hits,
339
- scoreFloor: typeof opts.scoreFloor === 'number' ? opts.scoreFloor : DEFAULT_SCORE_FLOOR,
499
+ scoreFloor,
340
500
  tokenBudget: opts.tokenBudget || DEFAULT_TOKEN_BUDGET,
341
501
  excludeKeys,
342
502
  });
343
- if (!lines.length) return { success: true, output: '' };
344
503
 
345
- saveSeen(projectRoot, sessionId, injectedKeys);
346
- const fenced = lines.map(line => fenceUntrusted(line, { source: 'memory' })).join('\n');
504
+ // Step-0 instrument: record every real query (even ones that inject nothing) so the floor is
505
+ // tuned from data. Own try/catch — a logging failure must NEVER fall through to the fail-open
506
+ // outer catch (which would suppress a legitimate injection).
507
+ if (tokens.length) {
508
+ try {
509
+ appendShadow(projectRoot, {
510
+ candidateCount: hits.length,
511
+ injectedCount: injectedKeys.length,
512
+ candidateScoreMin: hits.length ? Math.min(...hits.map(hit => Number(hit?.score) || 0)) : null,
513
+ candidateScoreMax: hits.length ? Math.max(...hits.map(hit => Number(hit?.score) || 0)) : null,
514
+ scoreFloor,
515
+ });
516
+ } catch { /* best-effort: shadow logging never affects the hook result */ }
517
+ }
518
+
519
+ if (!lines.length) {
520
+ observation.outcome = hits.length ? 'filtered' : 'empty';
521
+ return { success: true, output: '' };
522
+ }
523
+
524
+ if (opts.deadline?.expired) return { success: true, output: '', reason: 'timeout' };
525
+ try {
526
+ const pending = saveSeen(projectRoot, sessionId, injectedKeys);
527
+ if (pending && typeof pending.catch === 'function') pending.catch(() => {});
528
+ } catch { /* dedupe persistence never suppresses an injection */ }
529
+ const sections = [
530
+ ['confirmed', 'Confirmed memory (project-local; provenance shown)'],
531
+ ['suggested', 'Suggested memory — verify before relying'],
532
+ ].map(([trust, heading]) => {
533
+ const selectedLines = entries.filter(entry => entry.trust === trust).map(entry => entry.line);
534
+ return selectedLines.length ? `${heading}\n${selectedLines.join('\n')}` : '';
535
+ }).filter(Boolean);
536
+ const fenced = sections.join('\n');
537
+ const selected = hits.filter(hit => injectedKeys.includes(hit.memory_id || hit.key));
538
+ observation.outcome = 'selected';
539
+ observation.selectedIds = injectedKeys;
540
+ observation.sourceMix = selected.reduce((mix, hit) => {
541
+ const sourceAgent = hit.provenance?.source_agent || hit.sourceAgent || 'unknown';
542
+ mix[sourceAgent] = (mix[sourceAgent] || 0) + 1;
543
+ return mix;
544
+ }, {});
545
+ observation.trustMix = selected.reduce((mix, hit) => {
546
+ const trust = hit.trust_status || 'unknown';
547
+ mix[trust] = (mix[trust] || 0) + 1;
548
+ return mix;
549
+ }, {});
550
+ observation.tokenEstimate = Math.ceil(fenced.length / 4);
347
551
  return { success: true, output: formatUserPromptSubmit(harness, fenced) };
348
552
  } catch {
349
553
  // Fail-open: a context hook must never break a prompt.
554
+ observation.outcome = 'error';
350
555
  return { success: true, output: '' };
351
556
  }
352
557
  }
353
558
 
559
+ async function handleMemoryRecall(rest, projectRoot, opts = {}) {
560
+ const harness = parseHarness(rest);
561
+ const startedAt = Date.now();
562
+ const observation = { harness };
563
+ const record = result => {
564
+ const outcome = result.reason === 'timeout'
565
+ ? 'timeout'
566
+ : (observation.outcome || (result.output ? 'selected' : 'empty'));
567
+ const eventObservation = {
568
+ ...observation,
569
+ outcome,
570
+ harness,
571
+ elapsedMs: Date.now() - startedAt,
572
+ };
573
+ try {
574
+ const pending = (opts.recordRecallEvent || opts.launchRecallEvent || launchMemoryRecallEvent)(
575
+ projectRoot,
576
+ eventObservation,
577
+ );
578
+ if (pending && typeof pending.catch === 'function') pending.catch(() => {});
579
+ } catch { /* Operational evidence is best-effort and must never delay or suppress a prompt. */ }
580
+ return result;
581
+ };
582
+ const capability = userPromptSubmitCapability(harness);
583
+ if (!capability.rendered) {
584
+ observation.outcome = 'unsupported';
585
+ return record({ success: true, output: '', reason: capability.reason });
586
+ }
587
+ const deadline = { expired: false };
588
+ const result = await withinDeadline(
589
+ () => runMemoryRecall(rest, projectRoot, { ...opts, deadline, observation }),
590
+ { success: true, output: '', reason: 'timeout' },
591
+ opts.promptRecallDeadlineMs || PROMPT_RECALL_DEADLINE_MS,
592
+ () => { deadline.expired = true; },
593
+ );
594
+ return record(result);
595
+ }
596
+
354
597
  /**
355
598
  * `forge hooks capture --harness <h> --trigger <precompact|stop>` — the CAPTURE-on-exit hook.
356
599
  * PreCompact (before context compaction) and Stop (turn end) fire it; it snapshots a bounded
@@ -374,7 +617,8 @@ function handleMemoryRecall(rest, projectRoot, opts = {}) {
374
617
  async function handleCapture(rest, projectRoot, opts = {}) {
375
618
  try {
376
619
  const harness = parseHarness(rest);
377
- if (!sessionEndCapability(harness).rendered) return { success: true, output: '' };
620
+ const capability = sessionEndCapability(harness);
621
+ if (!capability.rendered) return { success: true, output: '', reason: capability.reason };
378
622
  const trigger = parseTrigger(rest);
379
623
 
380
624
  const fetchIssues = opts.fetchIssues || defaultFetchIssues;
@@ -503,11 +747,12 @@ async function handler(args, flags = {}, projectRoot, opts = {}) {
503
747
  if (action === 'inbox-pickup') return handleInboxPickup(args.slice(1), projectRoot, opts);
504
748
  if (action === 'shepherd-events') return handleShepherdEvents(args.slice(1), projectRoot, opts);
505
749
  if (action === 'memory-recall') return handleMemoryRecall(args.slice(1), projectRoot, opts);
750
+ if (action === 'read-attention') return handleReadAttention(args.slice(1), projectRoot, opts);
506
751
  if (action === 'capture') return handleCapture(args.slice(1), projectRoot, opts);
507
752
  if (action === 'install') return handleInstall(args, flags, opts);
508
753
  return {
509
754
  success: false,
510
- error: `forge hooks supports: install, session-start, inbox-pickup, shepherd-events, capture.\n${usage()}`,
755
+ error: `forge hooks supports: install, session-start, inbox-pickup, shepherd-events, memory-recall, read-attention, capture.\n${usage()}`,
511
756
  };
512
757
  }
513
758
 
@@ -521,4 +766,5 @@ module.exports = {
521
766
  '--dry-run': 'Preview the merge without writing anything',
522
767
  },
523
768
  handler,
769
+ _internal: { appendShadowLog, SHADOW_LOG_MAX_BYTES },
524
770
  };
@@ -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
  }
@@ -4,6 +4,62 @@ const remember = require('./remember');
4
4
  const recall = require('./recall');
5
5
  const insights = require('./insights');
6
6
  const { stripGlobalFlags } = require('../global-flags');
7
+ const projectMemory = require('../project-memory');
8
+ const { MEMORY_REVIEW_ENTRY_LIMIT, reviewMemories, stalenessForMemory } = require('../memory/hygiene');
9
+
10
+ function renderReview(review) {
11
+ const lines = [`Memory hygiene: ${review.findings.length} finding(s), ${review.scanned}/${review.total} scanned.`];
12
+ for (const finding of review.findings) {
13
+ const detail = finding.claim ? finding.claim : `${finding.count} equivalent records`;
14
+ lines.push(`${finding.review_id} ${finding.kind} ${detail}`);
15
+ }
16
+ if (!review.usage.available) lines.push('Usage staleness: unavailable; no demotion applied.');
17
+ else if (review.usage.demoted > 0) lines.push(`Usage staleness: ${review.usage.demoted} memory record(s) demoted after 90 days.`);
18
+ if (review.truncated) lines.push('Results truncated by the bounded review limits.');
19
+ return lines.join('\n');
20
+ }
21
+
22
+ async function reviewHandler(args, flags, projectRoot, opts = {}) {
23
+ const recentMemories = opts.recentMemories || projectMemory.recent;
24
+ const countMemories = opts.countMemories || projectMemory.count;
25
+ const loadUsageStatus = opts.usageProjections
26
+ ? async (root, keys, options) => {
27
+ try {
28
+ const projections = await opts.usageProjections(root, keys, options);
29
+ return projections instanceof Map
30
+ ? { available: true, projections }
31
+ : { available: false, projections: new Map() };
32
+ } catch {
33
+ return { available: false, projections: new Map() };
34
+ }
35
+ }
36
+ : projectMemory.usageProjectionStatus;
37
+ const store = opts.store || projectMemory.resolveStore(projectRoot, opts);
38
+ const sharedOptions = { ...opts, store };
39
+ const entries = await recentMemories(projectRoot, MEMORY_REVIEW_ENTRY_LIMIT, sharedOptions);
40
+ const total = await countMemories(projectRoot, sharedOptions);
41
+ const usageStatus = await loadUsageStatus(projectRoot, entries.map(entry => entry.key), sharedOptions);
42
+ const usageAvailable = usageStatus?.available === true && usageStatus.projections instanceof Map;
43
+ const projections = usageAvailable ? usageStatus.projections : new Map();
44
+ const now = typeof opts.now === 'string' ? opts.now : new Date().toISOString();
45
+ const usageFindings = usageAvailable ? entries.map(entry => {
46
+ if (typeof entry?.key !== 'string' || typeof entry.timestamp !== 'string') return null;
47
+ try {
48
+ const status = stalenessForMemory({ created_at: entry.timestamp }, projections.get(entry.key) || null, { now });
49
+ return status.demote ? { memory_id: projectMemory.memoryUsageIdentity(entry.key), ...status } : null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }).filter(Boolean).map(({ age_days: _ageDays, ...finding }) => finding) : [];
54
+ const review = {
55
+ ...reviewMemories(entries, { total }),
56
+ usage: { available: usageAvailable, demoted: usageFindings.length, findings: usageFindings },
57
+ };
58
+ return {
59
+ success: true,
60
+ output: args.includes('--json') || flags.json ? JSON.stringify(review, null, 2) : renderReview(review),
61
+ };
62
+ }
7
63
 
8
64
  // One memorable surface over the EXISTING memory commands (kernel issue 25362344): every
9
65
  // subcommand delegates to the standalone remember/recall/insights handlers — the same
@@ -29,9 +85,17 @@ const SUBCOMMANDS = {
29
85
  handler: insights.handler,
30
86
  summary: 'Detect recurring evidence patterns and suggest follow-ups (= forge insights)',
31
87
  },
88
+ review: {
89
+ handler: reviewHandler,
90
+ summary: 'Report bounded duplicate and explicit-contradiction findings with stable review ids',
91
+ },
92
+ doctor: {
93
+ handler: reviewHandler,
94
+ summary: 'Alias for memory review',
95
+ },
32
96
  };
33
97
 
34
- const usage = 'Usage: forge memory <add|recall|search|insights> [args]';
98
+ const usage = 'Usage: forge memory <add|recall|search|insights|review|doctor> [args]';
35
99
 
36
100
  function renderHelp() {
37
101
  const width = Math.max(...Object.keys(SUBCOMMANDS).map(name => name.length));
@@ -75,7 +139,7 @@ async function handler(args, flags, projectRoot, opts) {
75
139
  module.exports = {
76
140
  name: 'memory',
77
141
  description:
78
- 'Unified memory surface: forge memory add|recall|search|insights (wraps remember/recall/insights)',
142
+ 'Unified memory surface: add, recall, search, insights, and bounded hygiene review',
79
143
  usage,
80
144
  handler,
81
145
  };