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,11 +29,16 @@ const crypto = require('node:crypto');
29
29
  const fs = require('node:fs');
30
30
  const path = require('node:path');
31
31
  const projectMemory = require('../project-memory');
32
+ const {
33
+ assertBackend,
34
+ createMemoryBackendRegistry,
35
+ } = require('../../packages/memory');
32
36
 
33
37
  /** Supported PUBLIC backends, default first. */
34
38
  const MEMORY_BACKENDS = ['local', 'graphiti'];
35
39
  const DEFAULT_MEMORY_BACKEND = 'local';
36
40
  const ENV_VAR = 'FORGE_MEMORY_BACKEND';
41
+ const registeredMemoryBackends = new Map();
37
42
 
38
43
  /** Default recall cap — newest-N, so `recall` with no query never dumps the whole store. */
39
44
  const DEFAULT_RECALL_LIMIT = 20;
@@ -126,7 +131,7 @@ function resolveMemoryBackend({
126
131
  if (!value) return DEFAULT_MEMORY_BACKEND;
127
132
 
128
133
  const normalized = value.toLowerCase();
129
- if (MEMORY_BACKENDS.includes(normalized)) return normalized;
134
+ if (MEMORY_BACKENDS.includes(normalized) || registeredMemoryBackends.has(normalized)) return normalized;
130
135
 
131
136
  warn(
132
137
  `Unknown memory backend "${value}" from ${source}; `
@@ -135,6 +140,22 @@ function resolveMemoryBackend({
135
140
  return DEFAULT_MEMORY_BACKEND;
136
141
  }
137
142
 
143
+ function registerMemoryBackend(name, adapter) {
144
+ const normalized = typeof name === 'string' ? name.trim().toLowerCase() : '';
145
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(normalized) || MEMORY_BACKENDS.includes(normalized)) {
146
+ throw new TypeError('custom memory backend must have a unique lowercase name');
147
+ }
148
+ if (registeredMemoryBackends.has(normalized)) {
149
+ throw new Error(`custom memory backend "${normalized}" is already registered`);
150
+ }
151
+ assertBackend(adapter, normalized);
152
+ registeredMemoryBackends.set(normalized, adapter);
153
+ }
154
+
155
+ function unregisterMemoryBackend(name) {
156
+ registeredMemoryBackends.delete(String(name || '').trim().toLowerCase());
157
+ }
158
+
138
159
  /**
139
160
  * Strict validation for the resolved backend. Unlike `resolveMemoryBackend`
140
161
  * (which soft-falls-back), this THROWS a clear, actionable error when the
@@ -145,6 +166,17 @@ function resolveMemoryBackend({
145
166
  */
146
167
  function assertMemoryConfigValid({ deps = {}, env = process.env, projectRoot, config } = {}) {
147
168
  const memory = config || readMemoryConfig(projectRoot);
169
+ const signal = collectBackendSignal({ deps, env, projectRoot, config: memory });
170
+ const requested = signal.value?.toLowerCase();
171
+ if (requested && !MEMORY_BACKENDS.includes(requested) && !registeredMemoryBackends.has(requested)) {
172
+ const error = new Error(
173
+ `memory backend "${signal.value}" from ${signal.source} is not registered. `
174
+ + `Available backends: ${[...MEMORY_BACKENDS, ...registeredMemoryBackends.keys()]
175
+ .sort((left, right) => left.localeCompare(right)).join(', ')}.`,
176
+ );
177
+ error.backend = signal.value;
178
+ throw error;
179
+ }
148
180
  const backend = resolveMemoryBackend({ deps, env, projectRoot, config: memory, warn: () => {} });
149
181
 
150
182
  if (backend !== 'graphiti') {
@@ -182,19 +214,32 @@ function assertMemoryConfigValid({ deps = {}, env = process.env, projectRoot, co
182
214
  * never-hang guarantee — the emitter, not this seam, owns preventing that.
183
215
  *
184
216
  * @param {object} [emitter] - optional `{ emit(entry) }` injected by callers.
185
- * @param {object} entry - the persisted local entry.
217
+ * @param {Function} entryProvider - returns the persisted local entry.
186
218
  */
187
- function fireAndForgetGraphitiEmit(emitter, entry) {
188
- if (!emitter || typeof emitter.emit !== 'function') return;
189
- try {
190
- // Do not await: fire-and-forget. If it returns a promise, swallow rejection.
191
- const maybePromise = emitter.emit(entry);
192
- if (maybePromise && typeof maybePromise.then === 'function') {
193
- maybePromise.then(() => {}, () => {});
194
- }
195
- } catch {
196
- // Hard fallback: the local write already succeeded. Never surface emit errors.
197
- }
219
+ // The implementation now lives in the adapter below so synchronous failures can
220
+ // be represented in the operation receipt while rejected promises stay consumed.
221
+
222
+ function graphitiAdapter(emitter, entryProvider) {
223
+ const unavailable = () => [];
224
+ const freeze = (value, seen = new WeakSet()) => {
225
+ if (!value || typeof value !== 'object' || seen.has(value)) return value;
226
+ seen.add(value);
227
+ for (const child of Object.values(value)) freeze(child, seen);
228
+ return Object.freeze(value);
229
+ };
230
+ return {
231
+ add() {
232
+ if (!emitter || typeof emitter.emit !== 'function') return;
233
+ const maybePromise = emitter.emit(freeze(structuredClone(entryProvider())));
234
+ if (maybePromise && typeof maybePromise.then === 'function') {
235
+ maybePromise.then(() => {}, () => {});
236
+ }
237
+ },
238
+ recall: unavailable,
239
+ search: unavailable,
240
+ capture: unavailable,
241
+ digest: unavailable,
242
+ };
198
243
  }
199
244
 
200
245
  /**
@@ -242,6 +287,21 @@ function legacyJsonlPath(projectRoot) {
242
287
  return path.join(projectRoot, ...LEGACY_JSONL_RELATIVE);
243
288
  }
244
289
 
290
+ function legacyTags(parsed) {
291
+ const tags = Array.isArray(parsed.tags)
292
+ ? parsed.tags.filter(tag => typeof tag === 'string')
293
+ : [];
294
+ if (
295
+ typeof parsed.type === 'string'
296
+ && /^[a-z0-9][a-z0-9-]{0,63}$/i.test(parsed.type)
297
+ && !tags.some(tag => tag.startsWith('type:'))
298
+ ) {
299
+ tags.push(`type:${parsed.type.toLowerCase()}`);
300
+ }
301
+ if (!tags.some(tag => tag.startsWith('trust:'))) tags.push('trust:suggested');
302
+ return tags;
303
+ }
304
+
245
305
  // A STABLE key for a legacy record that lacks an `id`, derived from its content — so a
246
306
  // re-run (or a failed rename) upserts the same row instead of double-inserting under a
247
307
  // fresh random UUID.
@@ -292,7 +352,7 @@ function migrateJsonlNotesOnce(projectRoot, options = {}) {
292
352
  key: typeof parsed.id === 'string' && parsed.id ? parsed.id : legacyContentKey(parsed),
293
353
  value: parsed.note,
294
354
  sourceAgent: IMPORT_SOURCE_AGENT,
295
- tags: Array.isArray(parsed.tags) ? parsed.tags.filter(tag => typeof tag === 'string') : [],
355
+ tags: legacyTags(parsed),
296
356
  };
297
357
  if (typeof parsed.timestamp === 'string' && parsed.timestamp && !Number.isNaN(Date.parse(parsed.timestamp))) {
298
358
  entry.timestamp = parsed.timestamp;
@@ -321,20 +381,38 @@ function migrateJsonlNotesOnce(projectRoot, options = {}) {
321
381
  * @param {object} [options] - { tags, deps, env, config, graphitiEmitter, store }
322
382
  * @returns {{ id: string, note: string, timestamp: string, tags: string[] }}
323
383
  */
324
- function append(projectRoot, note, options = {}) {
384
+ function appendWithReceipt(projectRoot, note, options = {}) {
325
385
  migrateJsonlNotesOnce(projectRoot, options);
326
386
  const backend = resolveMemoryBackend({ ...options, projectRoot });
327
- const written = projectMemory.write(projectRoot, {
328
- key: crypto.randomUUID(),
329
- value: note,
330
- sourceAgent: REMEMBER_SOURCE_AGENT,
331
- tags: Array.isArray(options.tags) ? options.tags : [],
332
- }, options);
333
- const entry = toNote(written);
387
+ let entry;
388
+ const local = {
389
+ add() {
390
+ const written = projectMemory.write(projectRoot, {
391
+ key: crypto.randomUUID(),
392
+ value: note,
393
+ sourceAgent: REMEMBER_SOURCE_AGENT,
394
+ tags: Array.isArray(options.tags) ? options.tags : [],
395
+ }, options);
396
+ entry = toNote(written);
397
+ return entry;
398
+ },
399
+ recall: () => [],
400
+ search: () => [],
401
+ capture: () => null,
402
+ digest: () => null,
403
+ };
404
+ const registry = createMemoryBackendRegistry({ local });
334
405
  if (backend === 'graphiti') {
335
- fireAndForgetGraphitiEmit(options.graphitiEmitter, entry);
406
+ registry.register('graphiti', graphitiAdapter(options.graphitiEmitter, () => entry));
407
+ } else if (backend !== 'local') {
408
+ registry.register(backend, registeredMemoryBackends.get(backend));
336
409
  }
337
- return entry;
410
+ registry.select(backend);
411
+ return registry.add(note);
412
+ }
413
+
414
+ function append(projectRoot, note, options = {}) {
415
+ return appendWithReceipt(projectRoot, note, options).value;
338
416
  }
339
417
 
340
418
  /**
@@ -348,7 +426,7 @@ function append(projectRoot, note, options = {}) {
348
426
  * widens it to every stored memory.
349
427
  *
350
428
  * @param {string} projectRoot
351
- * @param {object} [selection] - { query, limit, all }
429
+ * @param {object} [selection] - { query, limit, all, kind }
352
430
  * @param {object} [options] - forwarded to the project-memory read paths (e.g. a store).
353
431
  * @returns {{ notes: object[], total: number, capped: boolean, query: string, limit: number, scope: string }}
354
432
  */
@@ -358,15 +436,16 @@ function recall(projectRoot, selection = {}, options = {}) {
358
436
  const limit = Number.isInteger(requested) && requested > 0 ? requested : DEFAULT_RECALL_LIMIT;
359
437
  const query = String(selection.query || '').trim();
360
438
  const includeAll = Boolean(selection.all);
439
+ const kind = typeof selection.kind === 'string' ? selection.kind.trim() : '';
440
+ const agents = includeAll ? undefined : HUMAN_MEMORY_AGENTS;
441
+ const readOptions = { ...options, agents, kind };
361
442
 
362
443
  if (query) {
363
- const notes = projectMemory.searchRanked(projectRoot, query, limit, options).map(toNote);
444
+ const notes = projectMemory.searchRanked(projectRoot, query, limit, readOptions).map(toNote);
364
445
  // BM25 returns at most `limit`; a full result set signals there may be more.
365
446
  return { notes, total: notes.length, capped: notes.length >= limit, query, limit, scope: 'all' };
366
447
  }
367
448
 
368
- const agents = includeAll ? undefined : HUMAN_MEMORY_AGENTS;
369
- const readOptions = { ...options, agents };
370
449
  const notes = projectMemory.recent(projectRoot, limit, readOptions).map(toNote);
371
450
  const total = projectMemory.count(projectRoot, readOptions);
372
451
  return { notes, total, capped: total > notes.length, query: '', limit, scope: includeAll ? 'all' : 'remembered' };
@@ -380,8 +459,11 @@ module.exports = {
380
459
  readMemoryConfig,
381
460
  resolveMemoryBackend,
382
461
  assertMemoryConfigValid,
462
+ registerMemoryBackend,
463
+ unregisterMemoryBackend,
383
464
  migrateJsonlNotesOnce,
384
465
  toNote,
385
466
  append,
467
+ appendWithReceipt,
386
468
  recall,
387
469
  };
@@ -0,0 +1,4 @@
1
+ 'use strict';
2
+
3
+ // Compatibility shim: the durable usage contract belongs to @forge/memory.
4
+ module.exports = require('../../packages/memory');
@@ -22,11 +22,14 @@
22
22
  const { applyBudget, buildSection, estimateTokens } = require('./orientation');
23
23
  const { fenceUntrusted } = require('./untrusted-content');
24
24
  const { collectInbox, inboxSection } = require('./inbox');
25
+ const { memoryTrustStatus } = require('./memory-recall');
25
26
 
26
27
  const DEFAULT_DIGEST_BUDGET_TOKENS = 400;
27
28
  const DEFAULT_NOTE_LIMIT = 5;
28
29
  const DEFAULT_ISSUE_LIMIT = 5;
29
30
  const DIGEST_HEADER = 'Forge memory (auto-injected at session start):';
31
+ const READ_ATTENTION_HEADER = 'Forge path memory (untrusted context; not authority):';
32
+ const SESSION_SUMMARY_REMINDER = 'Before ending this session, persist only durable learnings with `forge remember --session-summary --what <text> --why <text> --learned <text>`.';
30
33
 
31
34
  /** Run an async producer, returning `fallback` on any throw/rejection (never propagates). */
32
35
  async function safe(producer, fallback) {
@@ -103,7 +106,13 @@ async function collectDigestData(projectRoot, opts = {}) {
103
106
  /** `- [date ]note` for a recall note. */
104
107
  function formatNoteLine(note) {
105
108
  const date = typeof note.timestamp === 'string' && note.timestamp ? `${note.timestamp.slice(0, 10)} ` : '';
106
- return `- ${date}${note.note}`;
109
+ const trust = memoryTrustStatus({
110
+ tags: note.tags,
111
+ sourceAgent: note.sourceAgent,
112
+ value: note.machine ? {} : note.note,
113
+ });
114
+ const sourceAgent = note.sourceAgent || 'unknown';
115
+ return `- [source=${sourceAgent} trust=${trust} updated=${date.trim() || 'unknown'}] ${note.note}`;
107
116
  }
108
117
 
109
118
  /** `- [label] title` for an issue row (title/id defensively resolved). */
@@ -112,18 +121,39 @@ function formatIssueLine(label, issue) {
112
121
  return `- [${label}] ${title}`;
113
122
  }
114
123
 
115
- /** Build the notes section, or null when there are no notes. */
116
- function notesSection(notes) {
117
- if (!notes.length) return null;
118
- return buildSection({
119
- id: 'digest_notes',
120
- title: 'Remembered notes',
121
- content: notes.map(formatNoteLine).join('\n'),
122
- priority: 10,
123
- preserve: false,
124
- // Untrusted: a planted note is DATA, not instructions. Fenced after truncation.
125
- untrustedSource: 'memory',
126
- });
124
+ /** Build separate confirmed/suggested note sections, skipping entries too large to fit. */
125
+ function notesSections(notes, budgetTokens) {
126
+ const eligible = notes
127
+ .map(note => ({ note, line: formatNoteLine(note) }))
128
+ .filter(entry => estimateTokens(entry.line) <= budgetTokens);
129
+ const groups = [
130
+ { id: 'digest_notes', title: 'Confirmed memory', trust: 'confirmed', priority: 10 },
131
+ {
132
+ id: 'digest_suggested_memory',
133
+ title: 'Suggested memory verify before relying',
134
+ trust: 'suggested',
135
+ priority: 11,
136
+ },
137
+ ];
138
+ return groups.map(group => {
139
+ const content = eligible
140
+ .filter(({ note }) => memoryTrustStatus({
141
+ tags: note.tags,
142
+ sourceAgent: note.sourceAgent,
143
+ value: note.machine ? {} : note.note,
144
+ }) === group.trust)
145
+ .map(entry => entry.line)
146
+ .join('\n');
147
+ if (!content) return null;
148
+ return buildSection({
149
+ id: group.id,
150
+ title: group.title,
151
+ content,
152
+ priority: group.priority,
153
+ preserve: false,
154
+ untrustedSource: 'memory',
155
+ });
156
+ }).filter(Boolean);
127
157
  }
128
158
 
129
159
  /**
@@ -148,6 +178,59 @@ function issuesSection(ready, claimed) {
148
178
  });
149
179
  }
150
180
 
181
+ function sessionSummarySection(claimed) {
182
+ if (!claimed.length) return null;
183
+ return buildSection({
184
+ id: 'session_summary_nudge',
185
+ title: 'Session learning reminder',
186
+ content: SESSION_SUMMARY_REMINDER,
187
+ priority: 1,
188
+ preserve: true,
189
+ });
190
+ }
191
+
192
+ function normalizePath(value) {
193
+ return String(value || '').replaceAll('\\', '/').replace(/^\.\//, '').toLowerCase();
194
+ }
195
+
196
+ function notePaths(note) {
197
+ const explicit = [note?.path, ...(Array.isArray(note?.paths) ? note.paths : [])];
198
+ const tagged = (Array.isArray(note?.tags) ? note.tags : [])
199
+ .filter(tag => typeof tag === 'string' && tag.startsWith('path:'))
200
+ .map(tag => tag.slice('path:'.length));
201
+ return [...explicit, ...tagged].map(normalizePath).filter(Boolean);
202
+ }
203
+
204
+ function matchesPath(targetPath, candidatePath) {
205
+ const target = normalizePath(targetPath);
206
+ const candidate = normalizePath(candidatePath);
207
+ return target === candidate || target.endsWith(`/${candidate}`);
208
+ }
209
+
210
+ function buildReadAttentionDigest(targetPath, notes = [], options = {}) {
211
+ const budgetTokens = options.budgetTokens || 160;
212
+ const matched = (Array.isArray(notes) ? notes : []).filter(note =>
213
+ notePaths(note).some(candidate => matchesPath(targetPath, candidate)));
214
+ if (!matched.length) return { text: '', empty: true, tokens: 0 };
215
+
216
+ const lines = matched.map(formatNoteLine);
217
+ const section = buildSection({
218
+ id: 'read_attention',
219
+ title: 'Path-matched memory',
220
+ content: lines.join('\n'),
221
+ priority: 1,
222
+ preserve: false,
223
+ untrustedSource: 'path-memory',
224
+ });
225
+ const budgeted = applyBudget([section], Math.max(1, budgetTokens - estimateTokens(READ_ATTENTION_HEADER) - 20));
226
+ const content = budgeted.sections[0]?.content;
227
+ if (!content) return { text: '', empty: true, tokens: 0 };
228
+ const text = `${READ_ATTENTION_HEADER}\n\n${fenceUntrusted(content, { source: 'path-memory' })}`;
229
+ const tokens = estimateTokens(text);
230
+ if (tokens > budgetTokens) return { text: '', empty: true, tokens: 0 };
231
+ return { text, empty: false, tokens };
232
+ }
233
+
151
234
  /**
152
235
  * Assemble the bounded digest text. PURE. Never exceeds `budgetTokens` (delegated to
153
236
  * applyBudget). Empty inputs → { text: '', empty: true } so the caller injects nothing.
@@ -161,13 +244,18 @@ function buildMemoryDigest(data = {}, options = {}) {
161
244
  const ready = Array.isArray(data.ready) ? data.ready : [];
162
245
  const claimed = Array.isArray(data.claimed) ? data.claimed : [];
163
246
  const inbox = Array.isArray(data.inbox) ? data.inbox : [];
247
+ const budgetTokens = options.budgetTokens || DEFAULT_DIGEST_BUDGET_TOKENS;
164
248
 
165
249
  // Inbox (priority 5) is a THIRD section beside notes + issues; a fresh human directive
166
250
  // outranks stale notes (10) and the agent's own issue list (20) under budget pressure.
167
- const sections = [inboxSection(inbox), notesSection(notes), issuesSection(ready, claimed)].filter(Boolean);
251
+ const sections = [
252
+ sessionSummarySection(claimed),
253
+ inboxSection(inbox),
254
+ ...notesSections(notes, budgetTokens),
255
+ issuesSection(ready, claimed),
256
+ ].filter(Boolean);
168
257
  if (!sections.length) return { text: '', empty: true, tokens: 0 };
169
258
 
170
- const budgetTokens = options.budgetTokens || DEFAULT_DIGEST_BUDGET_TOKENS;
171
259
  const budgeted = applyBudget(sections, budgetTokens);
172
260
  const body = budgeted.sections
173
261
  .filter(section => section.content)
@@ -185,7 +273,10 @@ function buildMemoryDigest(data = {}, options = {}) {
185
273
  module.exports = {
186
274
  DEFAULT_DIGEST_BUDGET_TOKENS,
187
275
  DIGEST_HEADER,
276
+ READ_ATTENTION_HEADER,
277
+ SESSION_SUMMARY_REMINDER,
188
278
  buildMemoryDigest,
279
+ buildReadAttentionDigest,
189
280
  collectDigestData,
190
281
  extractIssues,
191
282
  // exported for focused reuse / tests
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+
3
+ const { randomUUID } = require('node:crypto');
4
+ const { spawn } = require('node:child_process');
5
+
6
+ const { resolveKernelDatabasePath } = require('./kernel/cli-broker-factory');
7
+ const { createBuiltinSQLiteDriver } = require('./kernel/sqlite-driver');
8
+ const projectMemory = require('./project-memory');
9
+
10
+ const EVENT_TYPE = 'memory.recall.observed';
11
+ const OUTCOMES = new Set(['selected', 'empty', 'filtered', 'unsupported', 'timeout', 'error']);
12
+ const MAX_COUNT = 1_000_000;
13
+ const MAX_SELECTED_IDS = 20;
14
+ const MAX_MIX_KEYS = 10;
15
+ const EVENT_WRITER = "const { recordMemoryRecallPayload } = require(process.argv[1]); recordMemoryRecallPayload(process.argv[2], JSON.parse(process.argv[3])).catch(() => {});";
16
+
17
+ function boundedInteger(value) {
18
+ if (!Number.isFinite(value)) return 0;
19
+ return Math.min(MAX_COUNT, Math.max(0, Math.trunc(value)));
20
+ }
21
+
22
+ function boundedString(value, maxLength) {
23
+ return String(value ?? '').slice(0, maxLength);
24
+ }
25
+
26
+ function boundedMix(mix) {
27
+ if (!mix || typeof mix !== 'object' || Array.isArray(mix)) return {};
28
+ return Object.fromEntries(
29
+ Object.entries(mix)
30
+ .slice(0, MAX_MIX_KEYS)
31
+ .map(([label, count]) => [boundedString(label, 64), boundedInteger(count)])
32
+ .filter(([label, count]) => label && count > 0)
33
+ );
34
+ }
35
+
36
+ function buildRecallEventPayload(observation = {}) {
37
+ const selectedIds = Array.isArray(observation.selectedIds)
38
+ ? observation.selectedIds
39
+ .slice(0, MAX_SELECTED_IDS)
40
+ .map(id => boundedString(id, 128))
41
+ .filter(Boolean)
42
+ : [];
43
+ return {
44
+ outcome: OUTCOMES.has(observation.outcome) ? observation.outcome : 'error',
45
+ counts: {
46
+ candidates: boundedInteger(observation.candidateCount),
47
+ eligible: boundedInteger(observation.eligibleCount),
48
+ selected: selectedIds.length,
49
+ },
50
+ selected_ids: selectedIds,
51
+ source_mix: boundedMix(observation.sourceMix),
52
+ trust_mix: boundedMix(observation.trustMix),
53
+ token_estimate: boundedInteger(observation.tokenEstimate),
54
+ elapsed_ms: boundedInteger(observation.elapsedMs),
55
+ harness: boundedString(observation.harness || 'unknown', 32),
56
+ };
57
+ }
58
+
59
+ function normalizeRecallEventPayload(payload = {}) {
60
+ return buildRecallEventPayload({
61
+ outcome: payload.outcome,
62
+ candidateCount: payload.counts?.candidates,
63
+ eligibleCount: payload.counts?.eligible,
64
+ selectedIds: payload.selected_ids,
65
+ sourceMix: payload.source_mix,
66
+ trustMix: payload.trust_mix,
67
+ tokenEstimate: payload.token_estimate,
68
+ elapsedMs: payload.elapsed_ms,
69
+ harness: payload.harness,
70
+ });
71
+ }
72
+
73
+ async function recordMemoryRecallEvent(projectRoot, observation = {}, options = {}) {
74
+ return recordMemoryRecallPayload(projectRoot, buildRecallEventPayload(observation), options);
75
+ }
76
+
77
+ async function recordMemoryRecallPayload(projectRoot, payload, options = {}) {
78
+ let store = options.store;
79
+ let ownsStore = false;
80
+ try {
81
+ const safePayload = normalizeRecallEventPayload(payload);
82
+ const projectId = options.projectId || projectMemory.resolveProjectId(projectRoot, options);
83
+ if (!store) {
84
+ store = createBuiltinSQLiteDriver({
85
+ databasePath: resolveKernelDatabasePath({
86
+ projectRoot,
87
+ gitCommonDir: options.gitCommonDir,
88
+ databasePath: options.databasePath,
89
+ }),
90
+ });
91
+ ownsStore = true;
92
+ }
93
+ const id = (options.randomUUID || randomUUID)();
94
+ const createdAt = options.now || new Date().toISOString();
95
+ await store.insertKernelEvent({
96
+ id,
97
+ entity_type: 'project',
98
+ entity_id: projectId,
99
+ event_type: EVENT_TYPE,
100
+ idempotency_key: id,
101
+ expected_revision: 0,
102
+ actor: 'forge',
103
+ origin: 'hook',
104
+ payload: safePayload,
105
+ created_at: createdAt,
106
+ });
107
+ return { recorded: true, eventId: id };
108
+ } catch (error) {
109
+ return {
110
+ recorded: false,
111
+ reason: error && error.message ? error.message : 'telemetry unavailable',
112
+ };
113
+ } finally {
114
+ if (ownsStore && store && typeof store.close === 'function') {
115
+ try {
116
+ store.close();
117
+ } catch {
118
+ // Best-effort evidence must never block recall.
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ function launchMemoryRecallEvent(projectRoot, observation = {}, options = {}) {
125
+ try {
126
+ const child = (options.spawn || spawn)(
127
+ process.execPath,
128
+ ['-e', EVENT_WRITER, __filename, projectRoot, JSON.stringify(buildRecallEventPayload(observation))],
129
+ { detached: true, stdio: 'ignore', windowsHide: true },
130
+ );
131
+ if (child && typeof child.on === 'function') child.on('error', () => {});
132
+ if (child && typeof child.unref === 'function') child.unref();
133
+ return { launched: true };
134
+ } catch {
135
+ return { launched: false };
136
+ }
137
+ }
138
+
139
+ module.exports = {
140
+ EVENT_TYPE,
141
+ buildRecallEventPayload,
142
+ launchMemoryRecallEvent,
143
+ recordMemoryRecallEvent,
144
+ recordMemoryRecallPayload,
145
+ };
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { fenceUntrusted } = require('./untrusted-content');
4
+
3
5
  /**
4
6
  * @module memory-recall
5
7
  *
@@ -50,6 +52,50 @@ function estimateTokens(text) {
50
52
  return Math.ceil(String(text || '').length / 4);
51
53
  }
52
54
 
55
+ function normalizeMemoryType(entry) {
56
+ const tags = Array.isArray(entry?.tags) ? entry.tags : [];
57
+ const tagged = tags.find(tag => /^type:[a-z0-9][a-z0-9-]{0,63}$/i.test(tag));
58
+ if (tagged) return tagged.slice('type:'.length).toLowerCase();
59
+ const category = entry?.value && typeof entry.value === 'object'
60
+ ? entry.value.category
61
+ : null;
62
+ if (typeof category === 'string' && /^[a-z0-9][a-z0-9-]{0,63}$/i.test(category)) {
63
+ return category.toLowerCase();
64
+ }
65
+ return entry?.value && typeof entry.value === 'object' ? 'machine-record' : 'note';
66
+ }
67
+
68
+ function memoryTrustStatus(entry) {
69
+ const tags = Array.isArray(entry?.tags) ? entry.tags : [];
70
+ const normalizedTags = tags.map(tag => String(tag).toLowerCase());
71
+ if (normalizedTags.includes('trust:confirmed')) return 'confirmed';
72
+ if (normalizedTags.some(tag => tag.startsWith('trust:')
73
+ || tag === 'forge:auto-capture')) return 'suggested';
74
+ if (entry?.sourceAgent === 'forge remember (imported)') return 'suggested';
75
+ if (entry?.value && typeof entry.value === 'object') return 'suggested';
76
+ if (entry?.sourceAgent === 'forge remember' && typeof entry.value === 'string') return 'confirmed';
77
+ return 'suggested';
78
+ }
79
+
80
+ function normalizeRecallHit(entry, projectId) {
81
+ if (!entry) return null;
82
+ if (entry.memory_id) return entry;
83
+ const sourceRefs = Array.isArray(entry.beadsRefs) ? entry.beadsRefs : [];
84
+ return {
85
+ memory_id: entry.key,
86
+ type: normalizeMemoryType(entry),
87
+ content: typeof entry.value === 'string' ? entry.value : JSON.stringify(entry.value),
88
+ scope: !entry.scope || entry.scope === 'project' ? projectId : entry.scope,
89
+ trust_status: memoryTrustStatus(entry),
90
+ provenance: {
91
+ source_agent: entry.sourceAgent || '',
92
+ source_refs: sourceRefs,
93
+ },
94
+ updated_at: entry.timestamp,
95
+ score: entry.score,
96
+ };
97
+ }
98
+
53
99
  /**
54
100
  * Parse the JSON payload Claude Code delivers on a UserPromptSubmit hook's stdin. Never
55
101
  * throws — any malformed input yields an empty prompt so the hook fails open.
@@ -99,7 +145,7 @@ function meaningfulTokens(query) {
99
145
  *
100
146
  * @param {object} args
101
147
  * @param {string} args.query — the submitted prompt
102
- * @param {Array<{key:string, value:string, score:number}>} args.hits — bm25-ordered
148
+ * @param {Array<{memory_id:string, content:string, score:number}>} args.hits — bm25-ordered
103
149
  * (best/lowest score first), already relevance-only (token-AND matched)
104
150
  * @param {number} [args.scoreFloor] — keep only hits with score <= floor (more negative =
105
151
  * stronger). Omit/null to rely on the FTS match alone. The VALUE is corpus-dependent and
@@ -116,28 +162,40 @@ function selectInjection({ query, hits, scoreFloor = null, tokenBudget = DEFAULT
116
162
 
117
163
  const exclude = new Set(excludeKeys || []);
118
164
  const lines = [];
165
+ const entries = [];
119
166
  const injectedKeys = [];
167
+ const usedSections = new Set();
120
168
  let spent = 0;
121
169
 
122
170
  for (const hit of hits || []) {
123
- if (!hit || typeof hit.key !== 'string') continue;
124
- if (exclude.has(hit.key)) continue;
171
+ const memoryId = hit?.memory_id ?? hit?.key;
172
+ if (typeof memoryId !== 'string') continue;
173
+ if (exclude.has(memoryId)) continue;
125
174
  // Relevance floor: below the bar contributes nothing. bm25 is more-negative-is-better.
126
175
  if (typeof scoreFloor === 'number' && !(typeof hit.score === 'number' && hit.score <= scoreFloor)) {
127
176
  continue;
128
177
  }
129
- const body = String(hit.value == null ? '' : hit.value);
130
- const cost = estimateTokens(body);
178
+ const body = String((hit.content ?? hit.value) == null ? '' : (hit.content ?? hit.value));
179
+ const trust = hit.trust_status === 'confirmed' ? 'confirmed' : 'suggested';
180
+ const source = String(hit.provenance?.source_agent || hit.sourceAgent || 'unknown').slice(0, 80);
181
+ const updated = String(hit.updated_at || hit.timestamp || 'unknown').slice(0, 40);
182
+ const labeled = `[trust=${trust} source=${source} updated=${updated}] ${body}`;
183
+ const line = fenceUntrusted(labeled, { source: 'memory' });
184
+ const heading = trust === 'confirmed'
185
+ ? 'Confirmed memory (project-local; provenance shown)'
186
+ : 'Suggested memory — verify before relying';
187
+ const cost = estimateTokens(line) + (usedSections.has(trust) ? 0 : estimateTokens(`${heading}\n`));
131
188
  if (spent + cost > tokenBudget) {
132
- // Budget exhausted; stop rather than skip-and-continue so the strongest fit.
133
- break;
189
+ continue;
134
190
  }
135
- lines.push(body);
136
- injectedKeys.push(hit.key);
191
+ lines.push(line);
192
+ entries.push({ trust, line });
193
+ injectedKeys.push(memoryId);
194
+ usedSections.add(trust);
137
195
  spent += cost;
138
196
  }
139
197
 
140
- return { lines, injectedKeys };
198
+ return { lines, entries, injectedKeys };
141
199
  }
142
200
 
143
201
  module.exports = {
@@ -145,6 +203,9 @@ module.exports = {
145
203
  DEFAULT_TOKEN_BUDGET,
146
204
  DEFAULT_SCORE_FLOOR,
147
205
  estimateTokens,
206
+ memoryTrustStatus,
207
+ normalizeMemoryType,
208
+ normalizeRecallHit,
148
209
  parseHookInput,
149
210
  meaningfulTokens,
150
211
  selectInjection,