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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/AGENTS.md +14 -7
  2. package/CHANGELOG.md +43 -1
  3. package/README.md +6 -2
  4. package/bin/forge-cmd.js +20 -0
  5. package/bin/forge.js +16 -374
  6. package/docs/INDEX.md +1 -1
  7. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  8. package/docs/guides/MIGRATION.md +4 -4
  9. package/docs/guides/SETUP.md +16 -16
  10. package/docs/reference/COMMANDS.md +8 -5
  11. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  12. package/docs/reference/RELEASE.md +5 -3
  13. package/docs/reference/TOOLCHAIN.md +8 -0
  14. package/docs/reference/protected-state-surfaces.md +4 -4
  15. package/docs/reference/shepherd.md +54 -25
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/pr-state-adapter.js +344 -142
  19. package/lib/audit-evidence.js +71 -110
  20. package/lib/capped-jsonl-log.js +236 -0
  21. package/lib/commands/_registry.js +2 -2
  22. package/lib/commands/clean.js +196 -32
  23. package/lib/commands/dev.js +4 -33
  24. package/lib/commands/hooks.js +223 -25
  25. package/lib/commands/insights.js +8 -3
  26. package/lib/commands/merge.js +600 -40
  27. package/lib/commands/pr.js +1 -1
  28. package/lib/commands/preflight.js +11 -2
  29. package/lib/commands/prime.js +21 -8
  30. package/lib/commands/push.js +41 -51
  31. package/lib/commands/recall.js +60 -16
  32. package/lib/commands/recap.js +6 -1
  33. package/lib/commands/release.js +17 -2
  34. package/lib/commands/setup.js +191 -94
  35. package/lib/commands/shepherd.js +13 -1
  36. package/lib/commands/ship.js +22 -23
  37. package/lib/commands/skill.js +119 -11
  38. package/lib/commands/status.js +17 -1
  39. package/lib/commands/test.js +24 -34
  40. package/lib/commands/worktree.js +220 -42
  41. package/lib/core/runtime-graph.js +1 -1
  42. package/lib/doc-assertions.js +297 -0
  43. package/lib/existing-tdd-gate.js +253 -0
  44. package/lib/forge-context.js +1 -4
  45. package/lib/forge-issues.js +56 -32
  46. package/lib/git-defaults.js +56 -0
  47. package/lib/harness-capability-matrix.js +3 -3
  48. package/lib/hook-renderer.js +93 -4
  49. package/lib/insights.js +96 -80
  50. package/lib/kernel/backing-issue.js +14 -2
  51. package/lib/kernel/broker.js +16 -0
  52. package/lib/kernel/cli-broker-factory.js +12 -1
  53. package/lib/kernel/close-on-merge.js +154 -0
  54. package/lib/kernel/fs-class.js +42 -25
  55. package/lib/kernel/sqlite-driver.js +153 -29
  56. package/lib/lefthook-wiring.js +21 -1
  57. package/lib/memory/router.js +16 -1
  58. package/lib/memory-digest.js +47 -15
  59. package/lib/memory-recall-events.js +145 -0
  60. package/lib/memory-recall.js +71 -10
  61. package/lib/merge-rules.js +8 -4
  62. package/lib/npm-publish-workflow.js +272 -0
  63. package/lib/orientation.js +68 -43
  64. package/lib/plugin-catalog.js +14 -4
  65. package/lib/pr-bundle.js +5 -6
  66. package/lib/pr-monitor/journal.js +18 -2
  67. package/lib/pr-monitor/reconcile-executor.js +224 -41
  68. package/lib/pr-monitor/render-summary.js +196 -0
  69. package/lib/pr-monitor/shepherd-lease.js +10 -1
  70. package/lib/pr-monitor/watch-lifecycle.js +13 -1
  71. package/lib/pr-pull.js +33 -14
  72. package/lib/pr-shepherd.js +34 -8
  73. package/lib/preflight/gates.js +65 -18
  74. package/lib/preflight/runner.js +5 -0
  75. package/lib/project-memory.js +33 -1
  76. package/lib/protected-state-authority.js +305 -0
  77. package/lib/protected-state-surfaces.js +64 -44
  78. package/lib/release-readiness.js +51 -4
  79. package/lib/shell-utils.js +1 -1
  80. package/lib/skills-sync.js +6 -3
  81. package/lib/smart-merge.js +28 -4
  82. package/lib/symlink-utils.js +74 -26
  83. package/lib/upgrade-safety.js +39 -0
  84. package/lib/using-forge.js +19 -6
  85. package/package.json +6 -7
  86. package/scripts/doc-asserting-tests.js +158 -0
  87. package/scripts/lib/behavioral-eval-runner.js +310 -0
  88. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  89. package/scripts/lib/eval-evidence.js +328 -0
  90. package/scripts/lib/eval-runner.js +81 -41
  91. package/scripts/lib/immutable-eval-corpus.js +309 -0
  92. package/scripts/lib/promotion-evidence-loader.js +94 -0
  93. package/scripts/lib/promotion-scorecard.js +314 -0
  94. package/scripts/npm-release-receipt.js +134 -0
  95. package/scripts/process-tree.js +761 -0
  96. package/scripts/protected-state-check.js +47 -22
  97. package/scripts/run-command-eval.js +29 -1
  98. package/scripts/sync-d20-audit.js +172 -0
  99. package/scripts/test-full-suite.js +249 -37
  100. package/scripts/test.js +176 -43
  101. package/skills/review/SKILL.md +4 -11
  102. package/skills/review/evals/scorecard.json +3 -3
  103. package/skills/rollback/SKILL.md +4 -11
  104. package/skills/rollback/evals/scorecard.json +3 -3
  105. package/skills/shepherd/SKILL.md +20 -14
  106. package/skills/shepherd/evals/scorecard.json +2 -2
  107. package/skills/ship/SKILL.md +4 -12
  108. package/skills/ship/evals/scorecard.json +3 -3
  109. package/skills/worktree/SKILL.md +6 -1
  110. package/skills/worktree/evals/scorecard.json +2 -2
  111. package/lib/beads-setup.js +0 -538
  112. package/lib/beads-sync-scaffold.js +0 -189
  113. package/lib/pat-setup.js +0 -207
  114. package/lib/pr-monitor/render-sticky.js +0 -206
  115. package/lib/pr-monitor/upsert-sticky.js +0 -169
  116. package/scripts/beads-context.sh +0 -577
  117. package/scripts/beads-migrate-to-dolt.sh +0 -7
  118. package/scripts/beads-upgrade-smoke.sh +0 -284
  119. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -37,7 +37,9 @@ const crypto = require('node:crypto');
37
37
  const { resolveGitCommonDir } = require('../kernel/broker');
38
38
 
39
39
  /** A wedged owner whose heartbeat is older than this (ms) is reclaimable. */
40
- const STALE_MS = 30000;
40
+ // Must exceed the longest synchronous daemon read (`gh pr list`, 30s) with
41
+ // enough margin that a blocked event loop cannot be reclaimed mid-read.
42
+ const STALE_MS = 90000;
41
43
  const LOCK_FILE_MODE = 0o600;
42
44
 
43
45
  /**
@@ -229,6 +231,12 @@ function release(projectRoot, { gitCommonDir, token, pid = process.pid } = {}) {
229
231
  }
230
232
  }
231
233
 
234
+ /** True only while this exact pid+token still owns the shared lease. */
235
+ function owns(projectRoot, { gitCommonDir, token, pid = process.pid } = {}) {
236
+ const held = readLock(lockFilePath(projectRoot, { gitCommonDir }));
237
+ return Boolean(held && ownsLock(held, { token, pid }));
238
+ }
239
+
232
240
  module.exports = {
233
241
  STALE_MS,
234
242
  lockFilePath,
@@ -240,4 +248,5 @@ module.exports = {
240
248
  stopHeartbeat,
241
249
  updateWatchers,
242
250
  release,
251
+ owns,
243
252
  };
@@ -20,6 +20,7 @@ const path = require('node:path');
20
20
  const { spawn, execFileSync } = require('node:child_process');
21
21
 
22
22
  const journal = require('./journal');
23
+ const brokerMod = require('../kernel/broker');
23
24
 
24
25
  /** Absolute path to the forge CLI entrypoint (this file is lib/pr-monitor/). */
25
26
  function forgeBin() {
@@ -66,7 +67,18 @@ function startPrWatcherDetached(opts = {}) {
66
67
 
67
68
  const slug = resolveSlug({ cwd, exec: opts.exec });
68
69
  if (slug) {
69
- const dir = journalMod.journalDir({ root: cwd, repo: slug, pr: prNumber });
70
+ let gitCommonDir = opts.gitCommonDir;
71
+ if (!gitCommonDir) {
72
+ try {
73
+ const resolveGitCommonDir = opts.resolveGitCommonDir || brokerMod.resolveGitCommonDir;
74
+ gitCommonDir = resolveGitCommonDir(cwd, { warn: () => {} });
75
+ } catch {
76
+ /* unavailable common-dir keeps the legacy per-root journal fallback */
77
+ }
78
+ }
79
+ const dir = journalMod.journalDir({
80
+ root: cwd, gitCommonDir, repo: slug, pr: prNumber,
81
+ });
70
82
  if (journalMod.watcherRunning(dir)) return { started: false, reason: 'already-running' };
71
83
  }
72
84
 
package/lib/pr-pull.js CHANGED
@@ -43,6 +43,11 @@ const LOG_FETCH_MULTIPLIER = 3;
43
43
  // re-review or a late reviewer comment inside the window means the PR is still
44
44
  // in flight. Mirrors the merge-rules `settle_min` idea (default 10 minutes).
45
45
  const DEFAULT_SETTLE_WINDOW_MS = 600000;
46
+ const FULL_HEAD_SHA = /^[0-9a-f]{40}$/i;
47
+
48
+ function normalizeHeadSha(value) {
49
+ return typeof value === 'string' && FULL_HEAD_SHA.test(value) ? value.toLowerCase() : null;
50
+ }
46
51
 
47
52
  /**
48
53
  * Bot logins whose review threads ARE actionable fixes (their comments tell you
@@ -320,7 +325,7 @@ function isSkipped(check) {
320
325
  /** A check still in flight (not green, not failed) — e.g. IN_PROGRESS/QUEUED or
321
326
  * a status context with no conclusion yet. */
322
327
  function isPending(check) {
323
- return !isGreen(check) && !isFailed(check);
328
+ return !isSkipped(check) && !isGreen(check) && !isFailed(check);
324
329
  }
325
330
 
326
331
  /**
@@ -865,12 +870,14 @@ function buildVerdictContext(input) {
865
870
  /** Rank 1: UNKNOWN (fail-closed) — unreadable input/required set, missing head
866
871
  * oid, or a torn read (head moved during the gather). */
867
872
  function rankUnknown(v) {
868
- const torn = Boolean(v.headOidStart) && Boolean(v.headOidEnd) && v.headOidStart !== v.headOidEnd;
873
+ const startValid = normalizeHeadSha(v.headOidStart) !== null;
874
+ const endValid = normalizeHeadSha(v.headOidEnd) !== null;
875
+ const torn = startValid && endValid && v.headOidStart !== v.headOidEnd;
869
876
  v.evidence.tornRead = torn;
870
877
  if (v.requiredClass.unreadable && !v.evidence.unreadable.includes('requiredChecks')) {
871
878
  v.evidence.unreadable.push('requiredChecks');
872
879
  }
873
- if (!v.headOidStart && !v.evidence.unreadable.includes('headOid')) {
880
+ if ((!startValid || !endValid) && !v.evidence.unreadable.includes('headOid')) {
874
881
  v.evidence.unreadable.push('headOid');
875
882
  }
876
883
  return (v.evidence.unreadable.length > 0 || torn) ? 'UNKNOWN' : null;
@@ -1139,10 +1146,13 @@ async function gatherPrSnapshot(ctx) {
1139
1146
 
1140
1147
  // readState is the one REQUIRED read — if it throws, the whole gather fails.
1141
1148
  const state = await adapter.readState(pr);
1149
+ const headOidStart = normalizeHeadSha(state.headSha);
1150
+ if (!headOidStart) unreadable.push('headOid');
1151
+ if (state.providerEvidenceReadable === false) unreadable.push('providerEvidence');
1152
+
1142
1153
  const requiredSet = await safeRead(
1143
1154
  'requiredChecks',
1144
- // `pr` lets the adapter fall back to the rollup `isRequired` set when branch
1145
- // protection is unreadable (the Actions token can't read protection).
1155
+ // `pr` is included for diagnostic rollup evidence when protection is unreadable.
1146
1156
  () => adapter.readRequiredChecks({ owner, repo, base, pr }),
1147
1157
  { degraded, fallback: null },
1148
1158
  );
@@ -1169,16 +1179,26 @@ async function gatherPrSnapshot(ctx) {
1169
1179
  // Branch divergence (behind base → needs an update/rebase).
1170
1180
  const div = await safeRead(
1171
1181
  'divergence',
1172
- () => callIfPresent(adapter, 'readDivergence', { baseRef: ctx.baseRef, cwd }, { behind: 0 }),
1173
- { degraded, fallback: { behind: 0 } },
1182
+ () => callIfPresent(
1183
+ adapter,
1184
+ 'readDivergence',
1185
+ { baseRef: ctx.baseRef, cwd, headRef: state.headSha },
1186
+ { behind: 0 },
1187
+ ),
1188
+ { degraded, unreadable, fallback: { behind: 0 } },
1174
1189
  );
1175
1190
  const behind = div.behind || 0;
1176
1191
 
1177
1192
  // Predicted merge conflicts (which files) — optional adapter capability.
1178
1193
  const conflicts = await safeRead(
1179
1194
  'conflicts',
1180
- () => callIfPresent(adapter, 'detectConflicts', { baseRef: ctx.baseRef, cwd }, null),
1181
- { degraded, fallback: null },
1195
+ () => callIfPresent(
1196
+ adapter,
1197
+ 'detectConflicts',
1198
+ { baseRef: ctx.baseRef, cwd, headRef: state.headSha },
1199
+ null,
1200
+ ),
1201
+ { degraded, unreadable, fallback: null },
1182
1202
  );
1183
1203
 
1184
1204
  // Bot STATUS COMMENTS (Sonar/Vercel/Netlify/Codecov quality-gate + deployment
@@ -1214,7 +1234,8 @@ async function gatherPrSnapshot(ctx) {
1214
1234
  const headPushKnown = headPushTimeMs != null;
1215
1235
  // Torn-read guard: re-read the head oid at the END of the gather.
1216
1236
  const endState = await safeRead('headEnd', () => adapter.readState(pr), { degraded, unreadable });
1217
- const headOidEnd = endState ? endState.headSha : state.headSha;
1237
+ const headOidEnd = normalizeHeadSha(endState && endState.headSha);
1238
+ if (!headOidEnd && !unreadable.includes('headOid')) unreadable.push('headOid');
1218
1239
 
1219
1240
  // Actionable NON-HUMAN direct comments (mechanism-detected, suppression-list
1220
1241
  // filtered) and an AUTHOR-AGNOSTIC unresolved-thread count — both independent
@@ -1224,7 +1245,7 @@ async function gatherPrSnapshot(ctx) {
1224
1245
  const unresolvedThreadCount = countUnresolvedThreads(threads);
1225
1246
 
1226
1247
  const { verdict, evidence } = computeVerdict({
1227
- headOidStart: state.headSha,
1248
+ headOidStart,
1228
1249
  headOidEnd,
1229
1250
  mergeStateStatus: state.mergeStateStatus,
1230
1251
  mergeable: state.mergeable,
@@ -1245,9 +1266,7 @@ async function gatherPrSnapshot(ctx) {
1245
1266
  unreadable,
1246
1267
  });
1247
1268
 
1248
- // Record which source answered the required-checks read (`protection` |
1249
- // `rollup` | null) so the source is visible in the verdict evidence — the
1250
- // rollup source is the CI path where branch protection is unreadable.
1269
+ // Record the authoritative required-check source (`protection` or null).
1251
1270
  evidence.requiredSource = adapter.lastRequiredSource || null;
1252
1271
 
1253
1272
  return {
@@ -32,6 +32,7 @@ const { fenceUntrusted } = require('./untrusted-content');
32
32
 
33
33
  /** Non-erroring terminal states a pass can settle into. */
34
34
  const TERMINAL_STATES = ['MERGE_READY', 'ESCALATE', 'PENDING', 'MERGED', 'CLOSED', 'NEEDS_REVIEW'];
35
+ const MERGE_READY_PROVIDER_STATES = new Set(['CLEAN', 'HAS_HOOKS', 'UNSTABLE']);
35
36
 
36
37
  // Review threads are classified BY MECHANISM, not by a bot-name list: a GitHub
37
38
  // review THREAD is opened by a reviewer (a human OR any bot) and stays open until
@@ -72,9 +73,11 @@ function actionableComments(threads, _self) {
72
73
  );
73
74
  }
74
75
 
75
- const SUCCESS_CONCLUSIONS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
76
+ const SUCCESS_CONCLUSIONS = new Set(['SUCCESS']);
76
77
 
77
78
  function isGreen(check) {
79
+ if (!Object.prototype.hasOwnProperty.call(check || {}, 'status')
80
+ || String(check.status || '').toUpperCase() !== 'COMPLETED') return false;
78
81
  const c = String(check.conclusion || '').toUpperCase();
79
82
  return SUCCESS_CONCLUSIONS.has(c);
80
83
  }
@@ -388,13 +391,18 @@ async function runShepherdPass(ctx) {
388
391
 
389
392
  // Every read goes through the auth guard so 401/403-scope/rate-limit map to
390
393
  // the documented PENDING/HARD_STOP states instead of escaping as a generic
391
- // failure. Non-auth errors still propagate.
394
+ // failure. Other read errors are surfaced below as ESCALATE results.
392
395
 
393
396
  // --- Read PR/CI state FIRST so a merged/closed PR is detected as terminal
394
397
  // even when the branch-protection (required-checks) read would fail with an
395
398
  // auth/scope error — the scheduler must always get the terminal signal for a
396
399
  // landed/closed PR. ---
397
- const stateRead = await guardAuth(() => adapter.readState(pr), actions);
400
+ let stateRead;
401
+ try {
402
+ stateRead = await guardAuth(() => adapter.readState(pr), actions);
403
+ } catch (err) {
404
+ return result('ESCALATE', { actions, reason: `PR provider state is unreadable: ${err.message}` });
405
+ }
398
406
  if (stateRead.outcome) return stateRead.outcome;
399
407
  const startState = stateRead.value;
400
408
  const startSha = startState.headSha;
@@ -402,13 +410,24 @@ async function runShepherdPass(ctx) {
402
410
  // --- Lifecycle: a merged/closed PR is terminal. ---
403
411
  const lifecycle = lifecycleOutcome(startState.state, actions);
404
412
  if (lifecycle) return lifecycle;
413
+ if (startState.providerEvidenceReadable === false) {
414
+ return result('ESCALATE', {
415
+ actions,
416
+ reason: 'PR lifecycle, draft, head, or check-rollup evidence is malformed or incomplete.',
417
+ });
418
+ }
405
419
 
406
420
  // --- Required-checks set (only matters for non-terminal PRs); this is where
407
421
  // auth/scope fails fast. ---
408
- const requiredRead = await guardAuth(
409
- () => adapter.readRequiredChecks({ owner, repo, base }),
410
- actions,
411
- );
422
+ let requiredRead;
423
+ try {
424
+ requiredRead = await guardAuth(
425
+ () => adapter.readRequiredChecks({ owner, repo, base }),
426
+ actions,
427
+ );
428
+ } catch (err) {
429
+ return result('ESCALATE', { actions, reason: `Required-check policy is unreadable: ${err.message}` });
430
+ }
412
431
  if (requiredRead.outcome) return requiredRead.outcome;
413
432
  const required = requiredRead.value;
414
433
 
@@ -422,7 +441,7 @@ async function runShepherdPass(ctx) {
422
441
  }
423
442
 
424
443
  const divergenceRead = await guardAuth(
425
- () => adapter.readDivergence({ baseRef, cwd }),
444
+ () => adapter.readDivergence({ baseRef, cwd, headRef: startSha }),
426
445
  actions,
427
446
  );
428
447
  if (divergenceRead.outcome) return divergenceRead.outcome;
@@ -472,6 +491,13 @@ async function runShepherdPass(ctx) {
472
491
 
473
492
  // --- Terminal: all required green + not behind → merge-ready handoff. ---
474
493
  if (allRequiredGreen) {
494
+ const providerMergeState = String(startState.mergeStateStatus || '').toUpperCase();
495
+ if (!MERGE_READY_PROVIDER_STATES.has(providerMergeState)) {
496
+ return result('ESCALATE', {
497
+ actions,
498
+ reason: `Provider merge state ${providerMergeState || 'UNKNOWN'} does not authorize merge readiness.`,
499
+ });
500
+ }
475
501
  return result('MERGE_READY', {
476
502
  actions,
477
503
  reason: 'All required checks are green and the branch is up to date. Handing off to the human to merge in the GitHub UI — the shepherd never merges.',
@@ -17,10 +17,11 @@
17
17
  */
18
18
 
19
19
  const path = require('node:path');
20
+ const crypto = require('node:crypto');
20
21
  const { spawnSync } = require('node:child_process');
21
- const { execFileSync } = require('node:child_process');
22
22
  const nodeFs = require('node:fs');
23
- const { getAffectedTestFiles } = require('../commands/test');
23
+ const { getTestCandidatesForChangedFile } = require('../commands/test');
24
+ const { selectDocAssertingTests } = require('../doc-assertions');
24
25
 
25
26
  const IS_WINDOWS = process.platform === 'win32';
26
27
 
@@ -37,6 +38,36 @@ function statusSummary(result, okMsg, failMsg) {
37
38
  : { ok: false, summary: failMsg };
38
39
  }
39
40
 
41
+ function compareStringsByCodePoint(left, right) {
42
+ if (left < right) return -1;
43
+ if (left > right) return 1;
44
+ return 0;
45
+ }
46
+
47
+ function normalizeChangedFiles(files) {
48
+ if (!Array.isArray(files)) return Object.freeze([]);
49
+ return Object.freeze([...new Set(
50
+ files.map((file) => String(file).replaceAll('\\', '/')).filter((file) => file !== ''),
51
+ )].sort(compareStringsByCodePoint));
52
+ }
53
+
54
+ function fingerprintChangedFiles(files) {
55
+ return crypto.createHash('sha256').update(JSON.stringify(files)).digest('hex');
56
+ }
57
+
58
+ function selectAffectedTests(projectRoot, changedFiles, fs = nodeFs) {
59
+ const tests = new Set();
60
+ for (const file of changedFiles) {
61
+ for (const candidate of getTestCandidatesForChangedFile(file)) {
62
+ if (fs.existsSync(path.join(projectRoot, candidate))) tests.add(candidate);
63
+ }
64
+ }
65
+ for (const candidate of selectDocAssertingTests(changedFiles, projectRoot, fs)) {
66
+ tests.add(candidate);
67
+ }
68
+ return [...tests].sort(compareStringsByCodePoint);
69
+ }
70
+
40
71
  /**
41
72
  * Gate 1 — ESLint on the change blast radius (or the whole tree with --all).
42
73
  * `files === null` means "lint everything" (`eslint .`).
@@ -152,38 +183,50 @@ function runSonar(files, { projectRoot, spawn = spawnSync } = {}) {
152
183
  */
153
184
  function runAffectedTests({
154
185
  projectRoot,
155
- changedFiles: _changedFiles,
186
+ changedFiles,
156
187
  spawn = spawnSync,
157
188
  resolveTests,
158
189
  } = {}) {
190
+ const inputs = Array.isArray(changedFiles) && Object.isFrozen(changedFiles)
191
+ ? changedFiles
192
+ : normalizeChangedFiles(changedFiles);
193
+ const inputFingerprint = fingerprintChangedFiles(inputs);
159
194
  const resolver = typeof resolveTests === 'function'
160
195
  ? resolveTests
161
- // strict:true so a failed `git diff` THROWS here instead of returning [] —
162
- // otherwise a git error would masquerade as "no affected tests" (fast-lane
163
- // green), a fail-OPEN. The catch below turns that throw into a closed gate.
164
- : () => getAffectedTestFiles(projectRoot, execFileSync, nodeFs, { strict: true });
196
+ : (files) => selectAffectedTests(projectRoot, files);
165
197
  let targets;
166
198
  try {
167
- targets = resolver() || [];
199
+ targets = resolver(inputs) || [];
168
200
  } catch (err) {
169
201
  // A resolver ERROR must NOT masquerade as "no affected tests" (green).
170
202
  // We could not determine what to run, so fail closed — never a vacuous pass.
171
203
  const reason = err && err.message ? err.message : String(err);
172
- return { ok: false, summary: `affected-test resolution failed — fail-closed (${reason})` };
204
+ return {
205
+ ok: false,
206
+ summary: `affected-test resolution failed — fail-closed (${reason})`,
207
+ inputFingerprint,
208
+ };
173
209
  }
174
210
  if (targets.length === 0) {
175
- return { ok: true, summary: 'no affected tests resolved (fast lane)' };
211
+ return {
212
+ ok: true,
213
+ summary: 'no affected tests resolved (fast lane)',
214
+ inputFingerprint,
215
+ };
176
216
  }
177
217
  const result = spawn(
178
218
  'bun',
179
219
  ['test', '--timeout', '15000', ...targets],
180
220
  { cwd: projectRoot, stdio: 'inherit', shell: IS_WINDOWS },
181
221
  );
182
- return statusSummary(
183
- result,
184
- `${targets.length} affected test file(s) passed`,
185
- 'affected tests failed',
186
- );
222
+ return {
223
+ ...statusSummary(
224
+ result,
225
+ `${targets.length} affected test file(s) passed`,
226
+ 'affected tests failed',
227
+ ),
228
+ inputFingerprint,
229
+ };
187
230
  }
188
231
 
189
232
  /**
@@ -198,15 +241,18 @@ function runAffectedTests({
198
241
  * @returns {{ name: string, run: () => Promise<{ok:boolean, summary?:string}> }[]}
199
242
  */
200
243
  function buildGates({ projectRoot, changedFiles = [], runAll = false, deps = {} }) {
244
+ const normalizedChangedFiles = normalizeChangedFiles(changedFiles);
245
+ const inputFingerprint = fingerprintChangedFiles(normalizedChangedFiles);
201
246
  const eslint = deps.eslint || ((files) => runEslint(files, { projectRoot }));
202
247
  const structural = deps.structural || (() => runStructural({ projectRoot }));
203
248
  const sonar = deps.sonar || ((files) => runSonar(files, { projectRoot }));
204
- const affected = deps.affected || (() => runAffectedTests({ projectRoot, changedFiles }));
249
+ const affected = deps.affected
250
+ || ((files) => runAffectedTests({ projectRoot, changedFiles: files }));
205
251
 
206
252
  // Under --all, scope BOTH lint and sonar to the whole tree (null). Otherwise
207
253
  // sonar would receive changedFiles=[] and report a vacuous "no changed files"
208
254
  // pass while lint scanned everything — a fail-open hole on the remedy path.
209
- const scanTargets = runAll ? null : changedFiles;
255
+ const scanTargets = runAll ? null : normalizedChangedFiles;
210
256
 
211
257
  // Affected-tests maps from the change set, which is empty under --all. Running
212
258
  // the WHOLE suite here defeats preflight's fast purpose (and hangs on Windows),
@@ -216,8 +262,9 @@ function buildGates({ projectRoot, changedFiles = [], runAll = false, deps = {}
216
262
  ok: true,
217
263
  skipped: true,
218
264
  summary: 'whole-tree mode (--all): affected-test mapping N/A — run the full suite (CI does)',
265
+ inputFingerprint,
219
266
  })
220
- : async () => affected();
267
+ : async () => affected(normalizedChangedFiles);
221
268
 
222
269
  return [
223
270
  { name: 'lint', run: async () => eslint(scanTargets) },
@@ -19,6 +19,7 @@
19
19
  * @typedef {Object} GateOutcome
20
20
  * @property {boolean} ok - Whether the gate passed.
21
21
  * @property {string} [summary] - Short human-readable result line.
22
+ * @property {string} [inputFingerprint] - Exact input snapshot tested by the gate.
22
23
  *
23
24
  * @typedef {Object} Gate
24
25
  * @property {string} name
@@ -30,6 +31,7 @@
30
31
  * @property {boolean} skipped
31
32
  * @property {string} summary
32
33
  * @property {number} [durationMs]
34
+ * @property {string} [inputFingerprint]
33
35
  */
34
36
 
35
37
  /**
@@ -66,6 +68,9 @@ async function executeGate(gate) {
66
68
  skipped,
67
69
  summary: outcome?.summary || '',
68
70
  durationMs,
71
+ ...(typeof outcome?.inputFingerprint === 'string'
72
+ ? { inputFingerprint: outcome.inputFingerprint }
73
+ : {}),
69
74
  };
70
75
  }
71
76
 
@@ -1,7 +1,11 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
3
5
  const { resolveKernelDatabasePath } = require('./kernel/cli-broker-factory');
6
+ const { resolveGitCommonDir } = require('./kernel/broker');
4
7
  const { createBuiltinSQLiteDriver } = require('./kernel/sqlite-driver');
8
+ const { normalizeRecallHit } = require('./memory-recall');
5
9
 
6
10
  // Project memory is a Forge read model persisted in the kernel store (kernel_memories),
7
11
  // written DIRECTLY rather than through the issue CAS/guarded-event path. The store seam
@@ -30,6 +34,24 @@ function resolveStore(projectRoot, options = {}) {
30
34
  return options.store ?? defaultStore(projectRoot, options);
31
35
  }
32
36
 
37
+ function resolveProjectId(projectRoot, options = {}) {
38
+ const platform = options.platform || process.platform;
39
+ const pathImpl = platform === 'win32' ? path.win32 : path.posix;
40
+ const commonDir = options.gitCommonDir || resolveGitCommonDir(projectRoot, options);
41
+ const absolute = pathImpl.resolve(projectRoot, commonDir);
42
+ const realpath = options.realpath || fs.realpathSync.native;
43
+ let canonical;
44
+ try {
45
+ canonical = realpath(absolute);
46
+ } catch {
47
+ canonical = absolute;
48
+ }
49
+ canonical = canonical.replaceAll('\\', '/');
50
+ return platform === 'win32'
51
+ ? canonical.toLowerCase()
52
+ : canonical;
53
+ }
54
+
33
55
  function assertEntryObject(entry) {
34
56
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
35
57
  throw new TypeError('project memory entry must be an object');
@@ -144,7 +166,16 @@ function searchRanked(projectRoot, query, limit, options = {}) {
144
166
  // apply a relevance floor. A no-match/empty query returns [] (no recency fallback). The
145
167
  // per-turn memory-recall hook uses this to inject nothing unless a note clearly matches.
146
168
  function searchRankedScored(projectRoot, query, limit, options = {}) {
147
- return resolveStore(projectRoot, options).searchMemoriesRankedScored(query, limit);
169
+ const projectId = resolveProjectId(projectRoot, options);
170
+ const searchOptions = {
171
+ projectId,
172
+ excludeKeys: options.excludeKeys || [],
173
+ ...(options.now ? { now: options.now } : {}),
174
+ ...(options.busyTimeoutMs !== undefined ? { busyTimeoutMs: options.busyTimeoutMs } : {}),
175
+ };
176
+ return resolveStore(projectRoot, options)
177
+ .searchMemoriesRankedScored(query, limit, searchOptions)
178
+ .map(hit => normalizeRecallHit(hit, projectId));
148
179
  }
149
180
 
150
181
  // Close and forget every cached default store. The CLI process is short-lived (the OS
@@ -170,5 +201,6 @@ module.exports = {
170
201
  count,
171
202
  searchRanked,
172
203
  searchRankedScored,
204
+ resolveProjectId,
173
205
  closeAll,
174
206
  };