forge-workflow 0.1.0-beta.5 → 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 (128) hide show
  1. package/AGENTS.md +4 -0
  2. package/CHANGELOG.md +36 -0
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/bin/forge.js +12 -1
  6. package/docs/guides/MIGRATION.md +3 -3
  7. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  8. package/docs/reference/INSTALL.md +4 -0
  9. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  10. package/docs/reference/RELEASE.md +4 -4
  11. package/docs/reference/github-accounts.md +134 -0
  12. package/docs/reference/shepherd.md +63 -13
  13. package/lib/adapters/pr-state-adapter.js +15 -2
  14. package/lib/base-remote.js +138 -0
  15. package/lib/beta5-compatibility-evidence.js +1093 -0
  16. package/lib/bun-lockfile-proof.js +413 -0
  17. package/lib/bun-workflow-pins.js +461 -0
  18. package/lib/capabilities/index.js +9 -0
  19. package/lib/capabilities/model.js +141 -0
  20. package/lib/capabilities/probes.js +347 -0
  21. package/lib/codex-skills.js +2 -2
  22. package/lib/commands/_manifest.js +1 -0
  23. package/lib/commands/_registry.js +48 -18
  24. package/lib/commands/clean.js +57 -1
  25. package/lib/commands/doctor.js +37 -6
  26. package/lib/commands/gate.js +197 -27
  27. package/lib/commands/github.js +215 -0
  28. package/lib/commands/hooks.js +54 -6
  29. package/lib/commands/memory.js +66 -2
  30. package/lib/commands/merge.js +720 -73
  31. package/lib/commands/plan.js +33 -2
  32. package/lib/commands/pr.js +2 -0
  33. package/lib/commands/preflight.js +10 -2
  34. package/lib/commands/push.js +108 -6
  35. package/lib/commands/recall.js +95 -61
  36. package/lib/commands/release.js +23 -2
  37. package/lib/commands/remember.js +28 -4
  38. package/lib/commands/serve.js +26 -9
  39. package/lib/commands/setup.js +132 -4
  40. package/lib/commands/shepherd.js +578 -72
  41. package/lib/commands/ship.js +15 -69
  42. package/lib/commands/skill.js +8 -0
  43. package/lib/commands/team.js +47 -8
  44. package/lib/commands/test.js +163 -4
  45. package/lib/commands/validate.js +65 -21
  46. package/lib/commands/worktree.js +155 -19
  47. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  48. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  49. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  50. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  51. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  52. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  53. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  54. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  55. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  56. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  57. package/lib/forge-issues.js +78 -0
  58. package/lib/gate-events.js +98 -10
  59. package/lib/github-context.js +308 -0
  60. package/lib/global-flags.js +1 -0
  61. package/lib/hook-renderer.js +29 -1
  62. package/lib/issue-render.js +19 -0
  63. package/lib/kernel/broker.js +723 -31
  64. package/lib/kernel/claim-reconciler.js +238 -0
  65. package/lib/kernel/lease-enforcer.js +9 -4
  66. package/lib/kernel/legacy-claim-repair.js +442 -0
  67. package/lib/kernel/live-claim-projection.js +26 -0
  68. package/lib/kernel/migrations.js +118 -3
  69. package/lib/kernel/readiness-model.js +184 -12
  70. package/lib/kernel/schema.js +49 -1
  71. package/lib/kernel/sqlite-driver.js +3322 -183
  72. package/lib/kernel/taxonomy-validator.js +4 -1
  73. package/lib/kernel/windows-private-acl.js +239 -0
  74. package/lib/memory/hygiene.js +191 -0
  75. package/lib/memory/router.js +94 -27
  76. package/lib/memory/usage-evidence.js +4 -0
  77. package/lib/memory-digest.js +59 -0
  78. package/lib/merge-rules.js +135 -17
  79. package/lib/npm-publish-workflow.js +233 -40
  80. package/lib/package-root.js +2 -0
  81. package/lib/pr-monitor/auto-actions.js +169 -28
  82. package/lib/pr-monitor/differ.js +110 -4
  83. package/lib/pr-monitor/events.js +0 -0
  84. package/lib/pr-monitor/flow-monitor.js +1424 -0
  85. package/lib/pr-monitor/gather.js +251 -44
  86. package/lib/pr-monitor/journal.js +0 -37
  87. package/lib/pr-monitor/monitor.js +117 -10
  88. package/lib/pr-monitor/process-identity.js +117 -0
  89. package/lib/pr-monitor/reconcile-executor.js +1101 -625
  90. package/lib/pr-monitor/reconcile.js +0 -0
  91. package/lib/pr-monitor/render-summary.js +121 -24
  92. package/lib/pr-monitor/review-preflight.js +269 -0
  93. package/lib/pr-monitor/shepherd-lease.js +28 -19
  94. package/lib/pr-monitor/verdict.js +438 -0
  95. package/lib/pr-monitor/watch-lifecycle.js +144 -38
  96. package/lib/pr-monitor/watch-owner.js +1414 -0
  97. package/lib/pr-monitor/watch.js +129 -58
  98. package/lib/pr-shepherd.js +17 -3
  99. package/lib/project-memory.js +145 -3
  100. package/lib/protected-state-authority.js +799 -4
  101. package/lib/protected-state-surfaces.js +181 -3
  102. package/lib/release-readiness.js +2 -3
  103. package/lib/review-adapter.js +65 -0
  104. package/lib/skills-sync.js +65 -32
  105. package/lib/validation/risk-manifest.js +339 -0
  106. package/lib/workflow/enforce-stage.js +44 -0
  107. package/lib/workflow/plan-authority.js +225 -0
  108. package/package.json +8 -4
  109. package/scripts/commitlint.js +13 -15
  110. package/scripts/generate-risk-manifest.js +91 -0
  111. package/scripts/github-context-bridge.sh +10 -0
  112. package/scripts/legacy-claim-repair.js +145 -0
  113. package/scripts/lib/behavioral-eval-runtime.js +3 -2
  114. package/scripts/process-tree.js +14 -2
  115. package/scripts/protected-state-check.js +440 -17
  116. package/scripts/sync-agent-skills.js +333 -34
  117. package/scripts/test-full-suite.js +704 -18
  118. package/scripts/test-profile.js +13 -3
  119. package/scripts/test.js +95 -14
  120. package/skills/coverage.json +1 -0
  121. package/skills/review/SKILL.md +2 -0
  122. package/skills/review/evals/scorecard.json +2 -2
  123. package/skills/setup/SKILL.md +18 -0
  124. package/skills/setup/evals/scorecard.json +3 -3
  125. package/skills/shepherd/SKILL.md +19 -2
  126. package/skills/shepherd/evals/scorecard.json +3 -3
  127. package/skills/validate/SKILL.md +3 -0
  128. package/skills/validate/evals/scorecard.json +1 -1
@@ -13,6 +13,10 @@ const path = require('node:path');
13
13
  const { execFileSync } = require('node:child_process');
14
14
  const { resolveIssueBackend } = require('../issue-backend');
15
15
  const { runIssueOperation } = require('../forge-issues');
16
+ const {
17
+ findPlanWorkFolder,
18
+ reconcilePlanAuthority,
19
+ } = require('../workflow/plan-authority');
16
20
 
17
21
  // Constants for security
18
22
  // Note: cwd is resolved at call-time via getExecOptions() to avoid stale require-time snapshots
@@ -483,7 +487,7 @@ async function registerBranchIssueLinkage(options, branch, issueId) {
483
487
  branch,
484
488
  actor: null,
485
489
  issue_id: issueId,
486
- work_folder: null,
490
+ work_folder: options.workFolder || null,
487
491
  registered_at: new Date().toISOString(),
488
492
  state: 'active',
489
493
  });
@@ -976,11 +980,38 @@ async function executePlan(featureName, options = {}) { // NOSONAR S3776
976
980
  };
977
981
  }
978
982
 
983
+ const workFolder = options.workFolder || findPlanWorkFolder(options.projectRoot || process.cwd(), featureSlug);
984
+
979
985
  // F1: persist the branch->issue linkage so a plan-created branch resolves
980
986
  // to its issue for kernel-authoritative stage state (dev/validate/ship).
981
987
  // Kernel-only, best-effort.
982
988
  if (issueBackend === 'kernel') {
983
- await registerBranchIssueLinkage({ ...options, issueBackend }, branch.branchName, issue.issueId);
989
+ await registerBranchIssueLinkage({ ...options, issueBackend, workFolder }, branch.branchName, issue.issueId);
990
+ await withPlanDriver(options, driver => {
991
+ if (!driver || typeof driver.loadPlanSnapshot !== 'function') return null;
992
+ let existing;
993
+ try {
994
+ existing = driver.loadPlanSnapshot({ issue_id: issue.issueId }, {});
995
+ } catch (error) {
996
+ // Injectable issue runners used by embedding callers may not persist into
997
+ // this driver's store. With no repository artifacts there is nothing to
998
+ // reconcile; preserve that legacy embedding contract.
999
+ if (/^Issue .* not found in the kernel$/i.test(error.message)) {
1000
+ if (!workFolder) return null;
1001
+ throw new Error(`Issue ${issue.issueId} is not available for plan authority persistence`);
1002
+ }
1003
+ throw error;
1004
+ }
1005
+ if (!existing && !workFolder) return null;
1006
+ return reconcilePlanAuthority({
1007
+ driver,
1008
+ issueId: issue.issueId,
1009
+ projectRoot: options.projectRoot || process.cwd(),
1010
+ workFolder,
1011
+ mode: 'plan',
1012
+ repairCommand: `forge plan ${JSON.stringify(featureName)} --issue ${issue.issueId}`,
1013
+ });
1014
+ });
984
1015
  }
985
1016
 
986
1017
  // Build result summary
@@ -81,6 +81,8 @@ async function handler(args, flags, projectRoot, opts) {
81
81
 
82
82
  module.exports = {
83
83
  name: 'pr',
84
+ githubAuth: (args = []) => ['ship', 'merge', 'shepherd']
85
+ .includes(stripGlobalFlags(args).find(arg => !arg.startsWith('-'))),
84
86
  description:
85
87
  'Unified pull-request surface: forge pr ship|preflight|shepherd|merge (wraps ship/preflight/shepherd/merge)',
86
88
  usage,
@@ -88,11 +88,19 @@ function resolveBaseRef(exec) {
88
88
  * @param {{ runAll?: boolean }} [opts]
89
89
  * @returns {{ resolved: boolean, changedFiles: string[], reason?: string, baseRef?: string }}
90
90
  */
91
- function resolveChangeSet(exec = execFileSync, { runAll = false } = {}) {
91
+ function resolveChangeSet(exec = execFileSync, { runAll = false, baseRef: explicitBaseRef = null } = {}) {
92
92
  if (runAll) {
93
93
  return { resolved: true, changedFiles: [], reason: 'whole-tree scope (--all)' };
94
94
  }
95
- const baseRef = resolveBaseRef(exec);
95
+ const baseRef = explicitBaseRef || resolveBaseRef(exec);
96
+ if (explicitBaseRef && !gitVerifyRef(exec, explicitBaseRef)) {
97
+ return {
98
+ resolved: false,
99
+ baseRef: explicitBaseRef,
100
+ changedFiles: [],
101
+ reason: `explicit base branch ${explicitBaseRef} is unavailable`,
102
+ };
103
+ }
96
104
  if (!baseRef) {
97
105
  return {
98
106
  resolved: false,
@@ -4,7 +4,7 @@ const { execFileSync, spawnSync } = require('node:child_process');
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
  const forgeToken = require('../../scripts/check-forge-token');
7
- const { QUICK_LANE_ENV_VAR, QUICK_LANE_VALUE } = require('../../scripts/test');
7
+ const { QUICK_LANE_ENV_VAR, QUICK_LANE_VALUE, resolveFullSuiteTimeoutMs } = require('../../scripts/test');
8
8
  const { fireAndForget } = require('../pr-monitor/reconcile-executor');
9
9
 
10
10
  const isWindows = process.platform === 'win32';
@@ -127,8 +127,8 @@ function maybeTriggerShepherdAfterPush({
127
127
  try {
128
128
  trigger({ projectRoot });
129
129
  return { armed: true };
130
- } catch (err) {
131
- return { armed: false, reason: err.message };
130
+ } catch {
131
+ return { armed: false, reason: 'shepherd-launch-failed' };
132
132
  }
133
133
  }
134
134
 
@@ -191,6 +191,94 @@ function runLint(spawnFn, pkgManager, projectRoot) {
191
191
  return lintResult.status === 0;
192
192
  }
193
193
 
194
+ /**
195
+ * Reports the kill signal a timed-out test run is terminated with. Shared by
196
+ * the spawn options and the timeout detector so the two cannot drift.
197
+ */
198
+ const TEST_RUN_KILL_SIGNAL = 'SIGKILL';
199
+
200
+ /**
201
+ * Reads the terminating signal under either field name.
202
+ *
203
+ * `node:child_process.spawnSync` reports `signal`; native `Bun.spawnSync`
204
+ * reports `signalCode`. Read both rather than assuming one.
205
+ *
206
+ * @param {Object} testResult - spawnSync result object
207
+ * @returns {string|null} Signal name, or null when none was reported
208
+ */
209
+ function terminationSignalOf(testResult) {
210
+ return testResult.signal || testResult.signalCode || null;
211
+ }
212
+
213
+ /**
214
+ * Reports whether a test run was killed by its own wall-clock budget.
215
+ *
216
+ * Requires AFFIRMATIVE evidence. Verified against pinned Bun 1.3.12 on Windows:
217
+ * a `node:child_process` spawnSync timeout returns `status: null`,
218
+ * `signal: <killSignal>`, AND an error whose `code` is `ETIMEDOUT`. Native
219
+ * `Bun.spawnSync` instead sets `exitedDueToTimeout: true` with `signalCode`.
220
+ * Those two markers are the only proof of a timeout, and they are checked
221
+ * BEFORE the generic error branch — testing `error` first swallowed the timeout
222
+ * diagnostic in exactly the case it was written for.
223
+ *
224
+ * A bare kill signal with no marker is deliberately NOT treated as a timeout:
225
+ * an OOM kill or an operator `kill -9` produces the identical status/signal
226
+ * shape, and since a genuine budget kill always carries a marker, inferring
227
+ * from the signal alone buys nothing and costs a misdiagnosis (it would claim
228
+ * the budget elapsed and send the user to raise FORGE_TEST_TIMEOUT_MS).
229
+ *
230
+ * @param {Object} testResult - spawnSync result object
231
+ * @returns {boolean} True when the run hit its budget
232
+ */
233
+ function isTimeoutTermination(testResult) {
234
+ if (testResult.exitedDueToTimeout === true) return true;
235
+ return Boolean(testResult.error) && testResult.error.code === 'ETIMEDOUT';
236
+ }
237
+
238
+ /**
239
+ * Explain a test run that never produced an exit status.
240
+ *
241
+ * `spawnSync` reports `status: null` when the child was killed by a signal
242
+ * (timeout kill included) or never started at all. Without this the push
243
+ * aborted with no summary at all, which is indistinguishable from a genuine
244
+ * test failure.
245
+ *
246
+ * @param {Object} testResult - spawnSync result object
247
+ * @param {number} timeoutMs - Wall-clock budget applied to the run
248
+ * @param {function} log - Logger function
249
+ */
250
+ function reportTestRunTermination(testResult, timeoutMs, log) {
251
+ const signal = terminationSignalOf(testResult);
252
+
253
+ if (isTimeoutTermination(testResult)) {
254
+ log(
255
+ `Test run timed out after ${Math.round(timeoutMs / 1000)}s `
256
+ + `(killed by ${signal || 'timeout'}) — push aborted.`,
257
+ );
258
+ log('Raise the budget with FORGE_TEST_TIMEOUT_MS if this machine is slower than the default.');
259
+ return;
260
+ }
261
+
262
+ if (testResult.error) {
263
+ log(`Test run could not complete: ${testResult.error.message}`);
264
+ return;
265
+ }
266
+
267
+ if (signal) {
268
+ // Signalled without timeout evidence: report the fact and the likely
269
+ // causes. Do NOT blame the budget — that sends the user after the wrong
270
+ // lever and hides the real failure.
271
+ log(
272
+ `Test run was terminated by ${signal} before finishing — push aborted. `
273
+ + `The run had a ${Math.round(timeoutMs / 1000)}s budget but reported no timeout, `
274
+ + 'so this is most likely an external kill or an out-of-memory kill.',
275
+ );
276
+ return;
277
+ }
278
+
279
+ log('Test run ended without an exit status — push aborted.');
280
+ }
281
+
194
282
  /**
195
283
  * Run tests unless in quick mode, logging warnings for first push.
196
284
  * @param {function} spawnFn - spawnSync or mock
@@ -199,9 +287,10 @@ function runLint(spawnFn, pkgManager, projectRoot) {
199
287
  * @param {string} projectRoot - Absolute path to project root
200
288
  * @param {boolean} quickMode - Whether to skip tests
201
289
  * @param {function} log - Logger function
290
+ * @param {NodeJS.ProcessEnv} [env] - Environment used to resolve the test budget
202
291
  * @returns {boolean|undefined} True if tests passed, undefined if skipped
203
292
  */
204
- function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log) {
293
+ function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log, env = process.env) {
205
294
  if (quickMode) {
206
295
  log('Tests skipped (--quick) — CI will run full suite on GitHub');
207
296
  const branch = getCurrentBranch(execFn);
@@ -211,12 +300,25 @@ function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log) {
211
300
  return undefined;
212
301
  }
213
302
 
303
+ // `forge push` runs the package-level `test` script, i.e. the whole suite.
304
+ // Budget it from the shared full-suite budget in scripts/test.js — which is
305
+ // sized as ~2x the measured healthy full-suite runtime and overridable with
306
+ // FORGE_TEST_TIMEOUT_MS — instead of an arbitrary local cap: the old fixed
307
+ // 120s killed a healthy full run mid-suite and pushed nothing. Never
308
+ // reintroduce a local constant here; change the shared budget with a fresh
309
+ // measurement instead.
310
+ const timeoutMs = resolveFullSuiteTimeoutMs(env);
214
311
  const testResult = spawnFn(pkgManager, ['run', 'test'], {
215
312
  stdio: 'inherit',
216
313
  shell: isWindows,
217
314
  cwd: projectRoot,
218
- timeout: 120000,
315
+ timeout: timeoutMs,
316
+ killSignal: TEST_RUN_KILL_SIGNAL,
219
317
  });
318
+ if (testResult.status === null || testResult.status === undefined) {
319
+ reportTestRunTermination(testResult, timeoutMs, log);
320
+ return false;
321
+ }
220
322
  return testResult.status === 0;
221
323
  }
222
324
 
@@ -266,7 +368,7 @@ module.exports = {
266
368
  }
267
369
 
268
370
  // Step 3: Tests (skip in quick mode)
269
- const testsPassed = runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log);
371
+ const testsPassed = runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log, deps?.env || process.env);
270
372
  if (!quickMode && !testsPassed) {
271
373
  return { success: false, quickMode, lintPassed: true, testsPassed: false, pushed: false };
272
374
  }
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const memoryRouter = require('../memory/router');
4
+ const projectMemory = require('../project-memory');
5
+ const { types: { isProxy } } = require('node:util');
4
6
  const { stripGlobalFlags } = require('../global-flags');
5
7
  const { fenceUntrusted } = require('../untrusted-content');
6
8
  const { applyBudget, buildSection, estimateTokens } = require('../orientation');
@@ -15,10 +17,8 @@ const RECALL_CONTENT_BUDGET = 1100;
15
17
  // `--type`): `--type` is a reserved GLOBAL flag hard-validated to workflow classifications.
16
18
  const TYPE_TAG_PREFIX = 'type:';
17
19
 
18
- // When a `--kind` filter is active the tag filter runs in the command layer (the store is
19
- // not reimplemented), so scan a generous window of recent notes before filtering rather than
20
+ // Kind filtering is pushed into the Kernel read so limits and capped metadata stay truthful.
20
21
  // the small default page — otherwise the type match could fall outside the default limit.
21
- const TYPE_FILTER_SCAN = 1000;
22
22
 
23
23
  /**
24
24
  * Separate the optional positional query from `--kind <type>`, `--limit N`, and `--json`.
@@ -80,6 +80,13 @@ function withType(entry) {
80
80
  return type ? { ...entry, type } : entry;
81
81
  }
82
82
 
83
+ function privateCommandOption(options, field) {
84
+ if (!options || typeof options !== 'object' || isProxy(options)) return undefined;
85
+ let descriptor;
86
+ try { descriptor = Object.getOwnPropertyDescriptor(options, field); } catch { return undefined; }
87
+ return descriptor && Object.hasOwn(descriptor, 'value') ? descriptor.value : undefined;
88
+ }
89
+
83
90
  function formatEntry(entry) {
84
91
  const date = entry.timestamp ? entry.timestamp.slice(0, 10) : '';
85
92
  const trust = memoryTrustStatus({
@@ -106,48 +113,32 @@ function formatEntry(entry) {
106
113
  return `- ${label}${marker}${typeMarker}${note}${tagSuffix}`;
107
114
  }
108
115
 
109
- async function handler(args, flags, projectRoot) {
110
- const { query, limit, type, json } = parseArgs(args);
111
- // `--all` is a GLOBAL boolean flag: in production bin/forge.js strips it from
112
- // args and sets flags.all; on a direct handler call it may still be in args.
113
- const all = Boolean(flags && flags.all) || args.includes('--all');
114
-
115
- // A `--type` filter scans a generous recent window, then keeps only matching notes — the
116
- // read stays entirely in the existing store (no schema change). `--limit` is re-applied
117
- // AFTER filtering so it caps the typed result set, not the pre-filter scan.
118
- const recallLimit = type ? Math.max(limit ?? 0, TYPE_FILTER_SCAN) : limit;
119
- const result = memoryRouter.recall(projectRoot, { query, limit: recallLimit, all });
120
- let notes = result.notes.map(withType);
121
- let { total, capped } = result;
122
- const { scope } = result;
123
- if (type) {
124
- notes = notes.filter(entry => entry.type === type);
125
- total = notes.length;
126
- if (limit && notes.length > limit) {
127
- notes = notes.slice(0, limit);
128
- capped = true;
129
- } else {
130
- capped = false;
116
+ function createUsageRecorder(projectRoot, recallStore, commandOpts) {
117
+ const invocationId = privateCommandOption(commandOpts, 'invocationId');
118
+ const usageStore = privateCommandOption(commandOpts, 'usageStore');
119
+ const onUsageEvidence = privateCommandOption(commandOpts, 'onUsageEvidence');
120
+ const invocationStartedAt = privateCommandOption(commandOpts, 'invocationStartedAt')
121
+ || privateCommandOption(commandOpts, 'now')
122
+ || new Date().toISOString();
123
+ const evidenceCallback = typeof onUsageEvidence === 'function' && !isProxy(onUsageEvidence)
124
+ ? onUsageEvidence
125
+ : undefined;
126
+ return selected => {
127
+ const observation = projectMemory.recordRecallUsage(projectRoot, selected, {
128
+ invocationId,
129
+ usageStore: usageStore || recallStore,
130
+ invocationStartedAt,
131
+ });
132
+ if (evidenceCallback) {
133
+ // Observability is deliberately aggregate-only: callers never receive query, note,
134
+ // path, secret, or raw durable identifiers.
135
+ try { evidenceCallback(observation); } catch { /* advisory test seam */ }
131
136
  }
132
- }
133
-
134
- if (json) {
135
- // Object (not a bare array) so programmatic consumers see the total and whether the
136
- // result was truncated (raise --limit to page further).
137
- return {
138
- success: true,
139
- output: `${JSON.stringify({ notes, total, capped, scope }, null, 2)}\n`,
140
- };
141
- }
142
-
143
- if (notes.length === 0) {
144
- const reason = query
145
- ? `No notes match "${query}".`
146
- : 'No notes remembered yet. Use "forge remember <note>" to add one.';
147
- return { success: true, output: reason };
148
- }
137
+ };
138
+ }
149
139
 
150
- const sections = notes
140
+ function buildRecallSections(notes) {
141
+ return notes
151
142
  .map((entry, index) => {
152
143
  const content = formatEntry(entry);
153
144
  if (estimateTokens(content) > RECALL_CONTENT_BUDGET) return null;
@@ -162,29 +153,35 @@ async function handler(args, flags, projectRoot) {
162
153
  content,
163
154
  priority: index,
164
155
  preserve: false,
165
- data: { trust },
156
+ data: { trust, memoryId: entry.id },
166
157
  });
167
158
  })
168
159
  .filter(Boolean);
160
+ }
161
+
162
+ function humanRecallHeader(query, capped, rendered, noteCount, total, scope) {
163
+ const noun = scope === 'all' && !query ? 'stored memory record(s)' : 'remembered note(s)';
164
+ if (query) {
165
+ return capped || rendered < noteCount
166
+ ? `Top ${rendered} note(s) matching "${query}" (raise --limit for more):`
167
+ : `${rendered} note(s) matching "${query}":`;
168
+ }
169
+ if (capped || rendered < noteCount) {
170
+ return `Showing ${rendered} of ${total} ${noun} (newest first):`;
171
+ }
172
+ return `${rendered} ${noun}:`;
173
+ }
174
+
175
+ function renderHumanRecall(notes, result, recordUsage) {
176
+ const sections = buildRecallSections(notes);
169
177
  const budgeted = applyBudget(sections, RECALL_CONTENT_BUDGET).sections
170
178
  .filter(section => section.content);
171
179
  for (const section of budgeted) {
172
180
  section.content = fenceUntrusted(section.content, { source: 'memory' });
173
181
  }
182
+ recordUsage(budgeted.map(section => ({ id: section.data.memoryId })));
174
183
  const rendered = budgeted.length;
175
- const noun = scope === 'all' && !query ? 'stored memory record(s)' : 'remembered note(s)';
176
- let header;
177
- if (query) {
178
- // BM25 returns at most `limit`; when full, signal it is the TOP-N, not the whole set.
179
- header = capped || rendered < notes.length
180
- ? `Top ${rendered} note(s) matching "${query}" (raise --limit for more):`
181
- : `${rendered} note(s) matching "${query}":`;
182
- } else if (capped || rendered < notes.length) {
183
- // Never a bare full dump: show the rendered count and the true total.
184
- header = `Showing ${rendered} of ${total} ${noun} (newest first):`;
185
- } else {
186
- header = `${rendered} ${noun}:`;
187
- }
184
+ const header = humanRecallHeader(result.query, result.capped, rendered, notes.length, result.total, result.scope);
188
185
  const confirmed = budgeted.filter(section => section.data.trust === 'confirmed');
189
186
  const suggested = budgeted.filter(section => section.data.trust === 'suggested');
190
187
  const groups = [
@@ -195,10 +192,47 @@ async function handler(args, flags, projectRoot) {
195
192
  title,
196
193
  ...entries.map(entry => entry.content),
197
194
  ]);
198
- return {
199
- success: true,
200
- output: [header, ...body].join('\n'),
201
- };
195
+ return { success: true, output: [header, ...body].join('\n') };
196
+ }
197
+
198
+ function emptyRecall(query) {
199
+ const reason = query
200
+ ? `No notes match "${query}".`
201
+ : 'No notes remembered yet. Use "forge remember <note>" to add one.';
202
+ return { success: true, output: reason };
203
+ }
204
+
205
+ async function handler(args, flags, projectRoot, commandOpts = {}) {
206
+ const { query, limit, type, json } = parseArgs(args);
207
+ // `--all` is a GLOBAL boolean flag: in production bin/forge.js strips it from
208
+ // args and sets flags.all; on a direct handler call it may still be in args.
209
+ const all = Boolean(flags && flags.all) || args.includes('--all');
210
+
211
+ // A `--type` filter scans a generous recent window, then keeps only matching notes — the
212
+ // read stays entirely in the existing store (no schema change). `--limit` is re-applied
213
+ // AFTER filtering so it caps the typed result set, not the pre-filter scan.
214
+ // Resolve once for the complete recall: selection, count, and advisory evidence share
215
+ // one cached driver/connection rather than each opening or migrating independently.
216
+ const recallStore = projectMemory.resolveStore(projectRoot);
217
+ const result = memoryRouter.recall(projectRoot, { query, limit, all, kind: type }, { store: recallStore });
218
+ const notes = result.notes.map(withType);
219
+ const recordUsage = createUsageRecorder(projectRoot, recallStore, commandOpts);
220
+
221
+ if (json) {
222
+ // Object (not a bare array) so programmatic consumers see the total and whether the
223
+ // result was truncated (raise --limit to page further).
224
+ recordUsage(notes);
225
+ return {
226
+ success: true,
227
+ output: `${JSON.stringify({ notes, total: result.total, capped: result.capped, scope: result.scope }, null, 2)}\n`,
228
+ };
229
+ }
230
+
231
+ if (notes.length === 0) {
232
+ return emptyRecall(query);
233
+ }
234
+
235
+ return renderHumanRecall(notes, result, recordUsage);
202
236
  }
203
237
 
204
238
  module.exports = {
@@ -9,12 +9,13 @@ const {
9
9
  const { runIssueOperation: defaultRunIssueOperation } = require('../forge-issues');
10
10
  const { normalizeArgs, normalizeIssueResult, withResolvedIssueBackend } = require('./_issue');
11
11
  const { generateNpmPublishWorkflow } = require('../npm-publish-workflow');
12
+ const { updateBunWorkflowPins } = require('../bun-workflow-pins');
12
13
 
13
14
  // `forge release <id>` releases a claimed issue; `forge release check` runs the
14
15
  // release-readiness gate. The two share the top-level verb, so this command
15
16
  // dispatches `check` to the gate and routes everything else through the shared
16
17
  // issue dispatch (resolve backend → runIssueOperation('release') → normalize).
17
- const usage = 'Usage: forge release <id> | forge release check --target 0.1.0 [--json] | forge release regen-audit | forge release generate-npm-workflow';
18
+ const usage = 'Usage: forge release <id> | forge release check --target 0.1.0 [--json] | forge release regen-audit | forge release generate-npm-workflow --expect-head <full-sha> | forge release update-bun-pins --expect-head <full-sha>';
18
19
 
19
20
  async function runReleaseIssue(args, projectRoot, opts = {}) {
20
21
  const resolved = withResolvedIssueBackend(projectRoot, opts);
@@ -45,23 +46,42 @@ function parseReleaseArgs(args = []) {
45
46
  return false;
46
47
  }
47
48
  const previous = args[index - 1];
48
- return previous !== '--target';
49
+ return previous !== '--target' && previous !== '--expect-head';
49
50
  });
50
51
 
51
52
  return {
52
53
  subcommand,
53
54
  target: readOption(args, '--target', SUPPORTED_TARGET),
55
+ expectedHead: readOption(args, '--expect-head', null),
54
56
  json: args.includes('--json'),
55
57
  };
56
58
  }
57
59
 
58
60
  async function handler(args, _flags, projectRoot, opts = {}) {
59
61
  const parsed = parseReleaseArgs(args);
62
+ if (parsed.subcommand === 'update-bun-pins') {
63
+ const updater = opts.updateBunWorkflowPins || updateBunWorkflowPins;
64
+ const updated = await updater(projectRoot, {
65
+ env: opts.env,
66
+ kernelDeps: opts.kernelDeps,
67
+ expectedHead: parsed.expectedHead,
68
+ resolveHead: opts.resolveHead,
69
+ });
70
+ return updated.success
71
+ ? {
72
+ success: true,
73
+ updated,
74
+ output: `Pinned ${updated.paths.length} workflows to Bun ${updated.version}.\n`,
75
+ }
76
+ : updated;
77
+ }
60
78
 
61
79
  if (parsed.subcommand === 'generate-npm-workflow') {
62
80
  const generated = await generateNpmPublishWorkflow(projectRoot, {
63
81
  env: opts.env,
64
82
  kernelDeps: opts.kernelDeps,
83
+ expectedHead: parsed.expectedHead,
84
+ resolveHead: opts.resolveHead,
65
85
  });
66
86
  return generated.success
67
87
  ? {
@@ -111,6 +131,7 @@ module.exports = {
111
131
  usage,
112
132
  flags: {
113
133
  '--target <version>': 'Release target to check',
134
+ '--expect-head <full-sha>': 'Required exact HEAD for protected workflow generation',
114
135
  '--json': 'Emit the readiness report as JSON',
115
136
  },
116
137
  handler,
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('node:crypto');
3
4
  const memoryRouter = require('../memory/router');
4
5
  const { stripGlobalFlags } = require('../global-flags');
5
6
 
@@ -22,6 +23,7 @@ const STRUCTURED_FIELDS = [
22
23
  // `--kind` (NOT `--type`): `--type` is a reserved GLOBAL flag that bin/forge.js hard-validates
23
24
  // against workflow classifications (critical|standard|…), so it can never carry a note type.
24
25
  const TYPE_TAG_PREFIX = 'type:';
26
+ const CONTENT_HASH_TAG_PREFIX = 'content-hash:';
25
27
 
26
28
  /**
27
29
  * Split positional note words from `--kind`, `--tag <label>`, the structured field flags,
@@ -105,15 +107,37 @@ async function handler(args, _flags, projectRoot) {
105
107
  }
106
108
 
107
109
  // The type rides as a reserved tag so it is stored and filterable without a schema change.
108
- const allTags = type ? [...tags, `${TYPE_TAG_PREFIX}${type}`] : tags;
109
- const entry = memoryRouter.append(projectRoot, body, { tags: allTags });
110
+ const contentHash = type === 'session-summary'
111
+ ? crypto.createHash('sha256').update(JSON.stringify({
112
+ body,
113
+ tags: [...tags].sort((left, right) => left.localeCompare(right)),
114
+ type,
115
+ }), 'utf8').digest('hex')
116
+ : null;
117
+ const metadata = contentHash ? { kind: 'session-summary', content_hash: contentHash } : undefined;
118
+ const hashTag = contentHash ? `${CONTENT_HASH_TAG_PREFIX}${contentHash}` : null;
119
+ const allTags = [
120
+ ...tags,
121
+ ...(type ? [`${TYPE_TAG_PREFIX}${type}`] : []),
122
+ ...(hashTag ? [hashTag] : []),
123
+ ];
124
+ let entry;
125
+ if (hashTag) {
126
+ const matches = memoryRouter.recall(projectRoot, {
127
+ query: contentHash,
128
+ limit: 2,
129
+ all: true,
130
+ }).notes;
131
+ entry = matches.find(note => Array.isArray(note.tags) && note.tags.includes(hashTag));
132
+ }
133
+ if (!entry) entry = memoryRouter.append(projectRoot, body, { tags: allTags });
110
134
 
111
135
  if (json) {
112
- const payload = type ? { ...entry, type } : entry;
136
+ const payload = type ? { ...entry, type, metadata } : entry;
113
137
  return { success: true, output: `${JSON.stringify(payload, null, 2)}\n` };
114
138
  }
115
139
 
116
- const userTags = entry.tags.filter(tag => !tag.startsWith(TYPE_TAG_PREFIX));
140
+ const userTags = entry.tags.filter(tag => !tag.startsWith(TYPE_TAG_PREFIX) && !tag.startsWith(CONTENT_HASH_TAG_PREFIX));
117
141
  const tagSuffix = userTags.length > 0 ? ` [${userTags.join(', ')}]` : '';
118
142
  const typePrefix = type ? `(${type}) ` : '';
119
143
  return {