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
@@ -39,7 +39,14 @@ const DEFAULT_IGNORE_GLOBS = Object.freeze(['tmp/*', 'spike/*', 'wip/*', 'throwa
39
39
  const AUTO_STUB_LABEL = 'auto-stub';
40
40
  // Branch prefixes stripped before deriving a title / probing for an encoded issue id.
41
41
  const BRANCH_PREFIX = /^(feat|feature|fix|bugfix|hotfix|chore|refactor|docs|test|spike|wip)\//i;
42
- // An issue key encoded in a branch slug, e.g. `feat/kap-7-foo` -> `kap-7`.
42
+ // A full Kernel issue id (UUID) named anywhere in the branch, e.g.
43
+ // `feat/18f1988e-...-close-on-merge`. Kernel ids ARE UUIDs, so this is the shape a
44
+ // real branch->issue reference actually takes; kept identical to the UUID_RE that
45
+ // resolveActiveIssueId (lib/workflow/enforce-stage.js) matches, so every resolver
46
+ // agrees on which issue a branch names.
47
+ const ENCODED_UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
48
+ // Legacy Beads-era issue key encoded in a branch slug, e.g. `feat/kap-7-foo` -> `kap-7`.
49
+ // Still honored for stores imported via `forge migrate --from beads`.
43
50
  const ENCODED_ISSUE_ID = /^([a-z][a-z0-9]*-\d+)\b/i;
44
51
  // Regex metacharacters escaped when compiling an ignore glob.
45
52
  const GLOB_METACHARS = '.+^${}()|[]\\';
@@ -145,12 +152,17 @@ function deriveTitle(branch) {
145
152
  }
146
153
 
147
154
  /**
148
- * Extract an issue id encoded in the branch name (e.g. `feat/kap-7-foo` -> `kap-7`).
155
+ * Extract an issue id named in the branch: a Kernel UUID anywhere in the name
156
+ * (`feat/18f1988e-...-foo`), else a legacy Beads key at the head of the slug
157
+ * (`feat/kap-7-foo` -> `kap-7`). The caller VERIFIES the id exists before linking,
158
+ * so a branch that merely looks like an id never binds to a phantom issue.
149
159
  *
150
160
  * @param {string} branch
151
161
  * @returns {string|null}
152
162
  */
153
163
  function extractEncodedIssueId(branch) {
164
+ const uuid = ENCODED_UUID.exec(String(branch));
165
+ if (uuid) return uuid[0];
154
166
  const match = ENCODED_ISSUE_ID.exec(stripPrefix(branch));
155
167
  return match ? match[1] : null;
156
168
  }
@@ -782,6 +782,12 @@ async function buildIssueMutationEvent(driver, operation, args, context, config)
782
782
  const payload = operation === 'comment'
783
783
  ? buildCommentPayload(issueId, args)
784
784
  : buildUpdatePayload(flags);
785
+ // `close` already means the terminal `done` transition. Treat an explicit
786
+ // `--status done` as that same default close intent so it does not get checked
787
+ // as the generic (and illegal) open -> done update transition below.
788
+ if (operation === 'close' && payload.status === 'done') {
789
+ delete payload.status;
790
+ }
785
791
  // update/close validate only the supplied taxonomy fields (no mandatory title);
786
792
  // comment carries no taxonomy fields, so it is exempt.
787
793
  if (operation !== 'comment') {
@@ -1177,6 +1183,16 @@ function createLocalBroker(options = {}) {
1177
1183
  return driver.importIssues(kernel, options, {}, config);
1178
1184
  },
1179
1185
 
1186
+ // Bulk activity read for `forge insights` (Slice C2). Read-only SELECT over
1187
+ // kernel_events, newest first, bounded by an optional `since` ISO cutoff + `limit`.
1188
+ // Additive: deliberately NOT registered in GUARDED_DRIVER_METHODS (the write-path
1189
+ // contract), so existing driver stubs that never call it stay valid. Imported beads
1190
+ // interactions live here as `beads.interaction.<kind>` events. Creates/migrates nothing.
1191
+ async listRecentEvents(options = {}, context = {}) {
1192
+ requireDriverMethod(driver, 'listRecentKernelEvents');
1193
+ return driver.listRecentKernelEvents(options, context, getConfig());
1194
+ },
1195
+
1180
1196
  // PR reconcile-ledger read (autonomous-shepherd design §3.4). Read-only SELECT of
1181
1197
  // the open `pr` rows for a repo (keyed by git_common_dir); creates/migrates nothing.
1182
1198
  // Consumed later by prime and the reconciler to enumerate PRs under shepherd.
@@ -113,7 +113,18 @@ async function buildMigratedKernelIssueDeps(options = {}) {
113
113
  databasePath: deps.kernelDatabasePath,
114
114
  driver: deps.kernelDriver,
115
115
  });
116
- await ensureKernelMigrated(broker);
116
+ const close = async () => {
117
+ if (typeof deps.kernelDriver.close === 'function') {
118
+ await deps.kernelDriver.close();
119
+ }
120
+ };
121
+ broker.close = close;
122
+ try {
123
+ await ensureKernelMigrated(broker);
124
+ } catch (error) {
125
+ try { await close(); } catch { /* preserve the migration failure */ }
126
+ throw error;
127
+ }
117
128
  return {
118
129
  useKernelBroker: true,
119
130
  kernelBroker: broker,
@@ -0,0 +1,154 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Close-on-merge linkage (kernel issue 18f1988e).
5
+ *
6
+ * The branch->issue linkage backbone shipped (kernel_worktrees rows written by
7
+ * `forge worktree create` and the auto-file rail), but nothing ever consumed it:
8
+ * a merged PR closed no kernel issue, so finished work stayed open forever. A
9
+ * hygiene sweep on 2026-07-25 closed 104 such issues, 61 of them auto-stubs
10
+ * minted on branch push whose merged PR closed nothing.
11
+ *
12
+ * This module is the consumer. Given a branch whose PR is known to be MERGED, it
13
+ * comments the merge evidence onto the linked kernel issue and closes it.
14
+ *
15
+ * Safety properties (each covered by test/kernel/close-on-merge.test.js):
16
+ * - LINKED ONLY. The issue comes from the kernel_worktrees linkage registry via
17
+ * resolveActiveIssueId — the same authoritative resolver `forge ship` stage
18
+ * state uses. An issue is never guessed from the branch/PR title, so a merge
19
+ * can only ever close the issue the branch was actually bound to.
20
+ * - IDEMPOTENT. The issue's status is read back first; a terminal issue
21
+ * (`done` / `cancelled` — the kernel's real terminal vocabulary) is a pure
22
+ * no-op that writes neither a comment nor a close. Running clean twice
23
+ * closes once.
24
+ * - NEVER CLOSES BLIND. An unreadable status is a skip, not an assumption.
25
+ * - BEST EFFORT. Every failure path — no kernel, a throwing driver, a rejected
26
+ * mutation — resolves to `{ closed: false, reason }`. Nothing throws, so
27
+ * `forge clean` still removes worktrees when tracking is broken.
28
+ *
29
+ * Writes go through the supported `runIssueOperation` seam (lib/forge-issues.js),
30
+ * the same path `forge inbox ack` uses, so kernel events/provenance are recorded
31
+ * exactly as a hand-run `forge comment` / `forge close` would be.
32
+ *
33
+ * @module kernel/close-on-merge
34
+ */
35
+
36
+ const { resolveActiveIssueId } = require('../workflow/enforce-stage');
37
+ const { isWorkableStatus } = require('./readiness-model');
38
+
39
+ /**
40
+ * Render the merge-evidence comment posted onto the issue before it is closed.
41
+ * Stays honest when the PR could not be resolved: `forge clean` also detects a
42
+ * merge by git patch-equivalence, with no GitHub data to cite.
43
+ *
44
+ * @param {object} params
45
+ * @param {string} params.branch - Merged branch name
46
+ * @param {{number: number, title?: string, mergeCommitOid?: string}|null} [params.pr]
47
+ * @returns {string} Comment body
48
+ */
49
+ function buildMergeEvidence({ branch, pr }) {
50
+ const lines = ['Closed automatically: the branch backing this issue was merged.', '', `branch: ${branch}`];
51
+ if (pr && pr.number) {
52
+ lines.push(`pull request: #${pr.number}${pr.title ? ` — ${pr.title}` : ''}`);
53
+ if (pr.mergeCommitOid) lines.push(`merge commit: ${pr.mergeCommitOid}`);
54
+ } else {
55
+ lines.push('pull request: no pull request resolved (merge detected from git history)');
56
+ }
57
+ return lines.join('\n');
58
+ }
59
+
60
+ /**
61
+ * Render the `--reason` recorded on the close event.
62
+ *
63
+ * @param {object} params
64
+ * @param {string} params.branch
65
+ * @param {{number: number}|null} [params.pr]
66
+ * @returns {string}
67
+ */
68
+ function buildCloseReason({ branch, pr }) {
69
+ return pr && pr.number ? `merged in PR #${pr.number}` : `branch ${branch} merged`;
70
+ }
71
+
72
+ /**
73
+ * Read an issue's current status through the supported read path.
74
+ *
75
+ * @param {Function} runIssueOperation
76
+ * @param {string} issueId
77
+ * @param {string} projectRoot
78
+ * @returns {Promise<string|null>} the status, or null when it could not be read.
79
+ */
80
+ async function readIssueStatus(runIssueOperation, issueId, projectRoot) {
81
+ const result = await runIssueOperation('show', [issueId, '--json'], projectRoot);
82
+ if (!result || result.ok !== true || !result.data) return null;
83
+ const status = result.data.status;
84
+ return typeof status === 'string' && status ? status : null;
85
+ }
86
+
87
+ /** @returns {boolean} whether a mutation envelope reports success. */
88
+ function mutationSucceeded(result) {
89
+ return Boolean(result && (result.ok === true || result.success === true));
90
+ }
91
+
92
+ /**
93
+ * Comment the merge evidence onto the linked kernel issue and close it.
94
+ * See the module doc for the full contract. NEVER throws.
95
+ *
96
+ * @param {object} options
97
+ * @param {string} options.branch - The merged branch.
98
+ * @param {string} [options.projectRoot] - Repo root threaded to the issue runner.
99
+ * @param {{number: number, title?: string, mergeCommitOid?: string}|null} [options.pr]
100
+ * Merge evidence from GitHub, when available.
101
+ * @param {object} options.driver - Kernel driver exposing the worktree linkage registry.
102
+ * @param {Function} options.runIssueOperation - (operation, args, projectRoot) => envelope.
103
+ * @param {Function} [options.resolveIssueId] - Override the branch->issue resolver (tests).
104
+ * @returns {Promise<{closed: boolean, issueId?: string|null, reason?: string,
105
+ * commented?: boolean, status?: string, error?: string}>}
106
+ */
107
+ async function closeLinkedIssueOnMerge(options = {}) {
108
+ const { branch, projectRoot, pr = null, driver, runIssueOperation } = options;
109
+ try {
110
+ if (!branch || typeof branch !== 'string') return { closed: false, reason: 'no-branch' };
111
+ if (!driver || typeof runIssueOperation !== 'function') {
112
+ return { closed: false, reason: 'unavailable' };
113
+ }
114
+
115
+ // Linked only — never a title/heuristic match.
116
+ const resolveIssueId = options.resolveIssueId || resolveActiveIssueId;
117
+ const issueId = await resolveIssueId(driver, branch);
118
+ if (!issueId) return { closed: false, reason: 'not-linked' };
119
+
120
+ // Idempotency + never-close-blind: read the current status first.
121
+ const status = await readIssueStatus(runIssueOperation, issueId, projectRoot);
122
+ if (!status) return { closed: false, reason: 'read-failed', issueId };
123
+ if (!isWorkableStatus(status)) {
124
+ return { closed: false, reason: 'already-closed', issueId, status };
125
+ }
126
+
127
+ // Evidence first, so it survives even if the close is rejected.
128
+ const commentResult = await runIssueOperation(
129
+ 'comment',
130
+ [issueId, buildMergeEvidence({ branch, pr })],
131
+ projectRoot,
132
+ );
133
+ const commented = mutationSucceeded(commentResult);
134
+
135
+ const closeResult = await runIssueOperation(
136
+ 'close',
137
+ [issueId, '--reason', buildCloseReason({ branch, pr })],
138
+ projectRoot,
139
+ );
140
+ if (!mutationSucceeded(closeResult)) {
141
+ return { closed: false, reason: 'close-failed', issueId, commented };
142
+ }
143
+ return { closed: true, issueId, commented };
144
+ } catch (error) {
145
+ // Best-effort: cleanup must never break on tracking.
146
+ return { closed: false, reason: 'error', error: error && error.message };
147
+ }
148
+ }
149
+
150
+ module.exports = {
151
+ closeLinkedIssueOnMerge,
152
+ buildMergeEvidence,
153
+ buildCloseReason,
154
+ };
@@ -284,7 +284,7 @@ function driveLetterOf(absPath) {
284
284
 
285
285
  // Bounded exec options for the Windows drive probes. The timeout is the BLOCKER-1
286
286
  // fix: execFileSync's try/catch only catches throws, never hangs, so an
287
- // unresponsive `net use` / PowerShell (e.g. a wedged network redirector) would
287
+ // an unresponsive `net use` probe (e.g. a wedged network redirector) would
288
288
  // hang the whole process indefinitely. With a timeout, a hang becomes a throw
289
289
  // that the gatherSignals catch turns into driveType='unknown' → warn (never
290
290
  // refuse), symmetric with the G1 fail-safe. killSignal forces the child dead.
@@ -310,18 +310,21 @@ function parseNetUseDriveType(stdout, driveLetter) {
310
310
  return null;
311
311
  }
312
312
 
313
- // PURE (M5): a non-empty PowerShell DisplayRoot means the PSDrive is backed by a
314
- // network/remote root. Returns 'network' when non-blank, else null.
315
- function parseDisplayRoot(stdout) {
316
- return String(stdout || '').trim() ? 'network' : null;
317
- }
318
-
319
313
  // Windows: detect whether a drive letter is a mapped network drive.
320
- // FAIL-SAFE FALLBACK (locked decision G1, option a): try `net use`, then a
321
- // PowerShell DisplayRoot fallback; on ANY probe failure (including a timeout-
322
- // induced throw, per BLOCKER 1) the caller treats the result as 'unknown'.
323
- // Probes use arg ARRAYS (no shell) so they remain injection-free.
314
+ // The conventional local C: drive is fixed without a subprocess. `net use` is
315
+ // authoritative for other mapped drive letters, while a no-match remains
316
+ // unknown because it does not prove that an SMB/cloud redirector is absent. The unknown
317
+ // result is fail-open (warn), while UNC and cloud path signals still refuse.
318
+ // Do not launch a second shell process for every kernel child. Under a heavily
319
+ // sharded suite those redundant fallback probes contend for host resources and
320
+ // can leave a shard blocked indefinitely.
321
+ // A probe failure still propagates to the gatherer, which maps it to the
322
+ // fail-safe `unknown`/warn classification.
323
+ // The probe uses an arg ARRAY (no shell) so it remains injection-free.
324
324
  function defaultProbeDriveType(driveLetter, deps = {}) {
325
+ const normalizedDriveLetter = String(driveLetter || '').trim().toUpperCase();
326
+ if (normalizedDriveLetter === 'C:') return 'fixed';
327
+
325
328
  const exec = deps.execFileSync || execFileSync;
326
329
 
327
330
  // Primary: `net use` lists mapped drive letters (and their remote paths).
@@ -330,17 +333,9 @@ function defaultProbeDriveType(driveLetter, deps = {}) {
330
333
  return 'network';
331
334
  }
332
335
 
333
- // Secondary: PowerShell DisplayRoot — non-empty for network-backed PSDrives.
334
- const psLetter = String(driveLetter).replace(':', '');
335
- const psCmd =
336
- `(Get-PSDrive -Name '${psLetter}' -ErrorAction SilentlyContinue).DisplayRoot`;
337
- const psOut = exec('powershell', ['-NoProfile', '-Command', psCmd], WIN_PROBE_EXEC_OPTIONS);
338
- if (parseDisplayRoot(psOut) === 'network') {
339
- return 'network';
340
- }
341
-
342
- // Letter exists, no network backing detected → fixed.
343
- return 'fixed';
336
+ // `net use` completed successfully without listing this non-system letter.
337
+ // A no-match is not evidence that an SMB/cloud redirector is absent.
338
+ return 'unknown';
344
339
  }
345
340
 
346
341
  // Linux: read /proc/mounts, return fs type of the longest mountpoint prefixing absPath.
@@ -443,27 +438,49 @@ function isUnsafeFsOverrideActive(env = {}) {
443
438
  return value === '1' || value === 'true';
444
439
  }
445
440
 
441
+ // The gate runs on every broker instance, and one command builds several
442
+ // (`forge status` and `forge prime` each build four), so an un-memoized advisory
443
+ // printed four times per command. The advisory describes the process's database
444
+ // path, not the broker, so it is emitted once per (tier, class, path) per
445
+ // process. Keyed rather than a single flag so a second path — or a path that
446
+ // reclassifies — is still reported. Warnings only: `refuse` still throws on
447
+ // every call, so deduping can never turn a refusal into a pass.
448
+ const warnedFilesystemKeys = new Set();
449
+
450
+ function resetFilesystemWarningMemo() {
451
+ warnedFilesystemKeys.clear();
452
+ }
453
+
446
454
  function assertFilesystemSafeForKernel(dbPath, deps = {}) {
447
455
  const env = deps.env || process.env;
456
+ // console.warn writes to stderr, which is the contract here: `--json`
457
+ // consumers parse stdout, so an advisory on stdout would corrupt the envelope.
448
458
  const warn = deps.warn || (msg => console.warn(msg));
449
459
  const classify = deps.classifyFilesystem || classifyFilesystem;
450
460
 
451
461
  const classification = classify(dbPath, deps);
452
462
  const remediation = REMEDIATION[classification.remediationKey] || REMEDIATION.unknown;
453
463
 
464
+ const warnOnce = message => {
465
+ const key = `${classification.riskTier}|${classification.class}|${dbPath}`;
466
+ if (warnedFilesystemKeys.has(key)) return;
467
+ warnedFilesystemKeys.add(key);
468
+ warn(message);
469
+ };
470
+
454
471
  if (classification.riskTier === 'safe') {
455
472
  return classification;
456
473
  }
457
474
 
458
475
  if (classification.riskTier === 'warn') {
459
- warn(`forge kernel filesystem warning (${classification.class}):\n${remediation}`);
476
+ warnOnce(`forge kernel filesystem warning (${classification.class}):\n${remediation}`);
460
477
  return classification;
461
478
  }
462
479
 
463
480
  // refuse
464
481
  const overrideActive = isUnsafeFsOverrideActive(env);
465
482
  if (overrideActive) {
466
- warn(
483
+ warnOnce(
467
484
  `forge kernel filesystem REFUSE downgraded by FORGE_KERNEL_ALLOW_UNSAFE_FS ` +
468
485
  `(${classification.class}) — override active:\n${remediation}`,
469
486
  );
@@ -487,9 +504,9 @@ module.exports = {
487
504
  gatherSignals,
488
505
  classifyFilesystem,
489
506
  assertFilesystemSafeForKernel,
507
+ resetFilesystemWarningMemo,
490
508
  isUnsafeFsOverrideActive,
491
509
  defaultProbeDriveType,
492
510
  parseNetUseDriveType,
493
- parseDisplayRoot,
494
511
  REMEDIATION,
495
512
  };
@@ -17,6 +17,7 @@ const { buildMemoryProjectionMigration, memoryFtsDdl } = require('./migrations')
17
17
  const { rankForPriorityLabel } = require('./taxonomy-validator');
18
18
  const { isLeaseExpired } = require('./lease-enforcer');
19
19
  const { CONFLICT_SIGNAL, classifyConflictSignal } = require('./conflict-signal');
20
+ const { normalizeRecallHit } = require('../memory-recall');
20
21
 
21
22
  const BUILTIN_SQLITE_RUNTIME_ORDER = Object.freeze(['bun:sqlite', 'node:sqlite']);
22
23
  let probeCounter = 0;
@@ -800,6 +801,28 @@ function listKernelEventRows(runtime, db, entityType, entityId) {
800
801
  );
801
802
  }
802
803
 
804
+ // Bulk activity read (Slice C2, additive + read-only) across ALL entities, newest first,
805
+ // for `forge insights`. `since` is an optional ISO cutoff (created_at >= since); `limit`
806
+ // bounds the row count (default 1000). Imported beads interactions live here as
807
+ // `beads.interaction.<kind>` events, so insights derives interaction patterns from this
808
+ // instead of the retired legacy interactions log. Creates/migrates nothing.
809
+ function listRecentKernelEventRows(runtime, db, since, limit) {
810
+ const params = [];
811
+ let where = '';
812
+ if (since) {
813
+ where = 'WHERE created_at >= ?';
814
+ params.push(since);
815
+ }
816
+ const cap = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.floor(Number(limit)) : 1000;
817
+ params.push(cap);
818
+ return allParams(
819
+ runtime,
820
+ db,
821
+ `SELECT * FROM kernel_events ${where} ORDER BY created_at DESC LIMIT ?`,
822
+ params,
823
+ );
824
+ }
825
+
803
826
  // Look up the committed event for an idempotency key (the duplicate-replay probe).
804
827
  // The broker calls this unconditionally inside a Promise.all even for keyless
805
828
  // events, so guard a falsy key up front rather than binding undefined.
@@ -2049,6 +2072,17 @@ function buildMemoryFtsMatch(query) {
2049
2072
  return tokens.map(token => `"${token}"`).join(' AND ');
2050
2073
  }
2051
2074
 
2075
+ // Like buildMemoryFtsMatch but OR-joins the tokens: a natural-language prompt matches a note
2076
+ // containing ANY of its keywords, not EVERY one. Used ONLY by the relevance-only SCORED read
2077
+ // (the per-turn recall hook) — a raw prompt ("why is my forge push taking so long") token-ANDed
2078
+ // required every word in one note and matched nothing (0% recall). Each token is double-quoted
2079
+ // for FTS5 safety (tokens may be non-Latin unicode). Returns '' when the query has no tokens.
2080
+ function buildMemoryFtsMatchOr(query) {
2081
+ const tokens = String(query ?? '').match(/[\p{L}\p{N}]+/gu);
2082
+ if (!tokens || tokens.length === 0) return '';
2083
+ return tokens.map(token => `"${token}"`).join(' OR ');
2084
+ }
2085
+
2052
2086
  // BM25 top-N recall over the kernel_memories_fts index (migration 008). Joins the FTS
2053
2087
  // rowid back to the memory row and orders by bm25 (lower = better match). An empty/tokenless
2054
2088
  // query falls back to recent entries so `recall` never returns a bare full dump.
@@ -2076,22 +2110,97 @@ function searchMemoryRowsRanked(runtime, db, query, limit) {
2076
2110
  // Unlike searchMemoryRowsRanked, a no-match (or empty) query returns [] with NO recency
2077
2111
  // fallback: the whole point is to avoid surfacing recent-but-irrelevant notes. bm25()
2078
2112
  // returns more-negative for stronger matches, so rows come back best (lowest) first.
2079
- function searchMemoryRowsRankedScored(runtime, db, query, limit) {
2113
+ function confirmedMemorySql(alias) {
2114
+ return `(EXISTS (
2115
+ SELECT 1 FROM json_each(${alias}.tags_json)
2116
+ WHERE lower(json_each.value) = 'trust:confirmed'
2117
+ ) OR (${alias}.source_agent = 'forge remember'
2118
+ AND json_type(${alias}.value_json) = 'text'
2119
+ AND NOT EXISTS (
2120
+ SELECT 1 FROM json_each(${alias}.tags_json)
2121
+ WHERE lower(json_each.value) LIKE 'trust:%'
2122
+ OR lower(json_each.value) = 'forge:auto-capture'
2123
+ )))`;
2124
+ }
2125
+
2126
+ function projectMemoryScopeSql(alias) {
2127
+ return `(${alias}.scope IS NULL OR ${alias}.scope = 'project' OR ${alias}.scope = ?)`;
2128
+ }
2129
+
2130
+ function suggestedFreshnessCutoff(now) {
2131
+ const timestamp = Date.parse(now || new Date().toISOString());
2132
+ return new Date(timestamp - (7 * 24 * 60 * 60 * 1000)).toISOString();
2133
+ }
2134
+
2135
+ function searchMemoryRowsRankedScored(runtime, db, query, limit, options = {}) {
2080
2136
  const capped = Number.isInteger(limit) && limit > 0 ? limit : 20;
2081
- const match = buildMemoryFtsMatch(query);
2137
+ // keyword-OR (NOT the token-AND of searchMemoryRowsRanked): this relevance-only read backs
2138
+ // the per-turn recall hook, where a natural-language prompt must match on ANY keyword.
2139
+ const match = buildMemoryFtsMatchOr(query);
2082
2140
  if (!match) {
2083
2141
  return [];
2084
2142
  }
2085
- return allParams(
2086
- runtime,
2087
- db,
2088
- `SELECT m.*, bm25(kernel_memories_fts) AS __score FROM kernel_memories m
2143
+ const projectId = options.projectId;
2144
+ if (typeof projectId !== 'string' || !projectId) return [];
2145
+ const cutoff = suggestedFreshnessCutoff(options.now);
2146
+ const excludeKeys = Array.isArray(options.excludeKeys)
2147
+ ? [...new Set(options.excludeKeys.filter(key => typeof key === 'string'))].slice(0, 256)
2148
+ : [];
2149
+ const seenSql = excludeKeys.length > 0
2150
+ ? `AND m.key NOT IN (${excludeKeys.map(() => '?').join(', ')})`
2151
+ : '';
2152
+ const confirmed = confirmedMemorySql('m');
2153
+ const supersederConfirmed = confirmedMemorySql('s');
2154
+ // Expand eligible supersession edges once. A correlated json_each scan repeated the
2155
+ // entire memory table for every FTS candidate and dominated the 1,000-row prompt path.
2156
+ // Recall temporarily waits less than the connection default so a real lock cannot outlive
2157
+ // the prompt hook; the finally block restores the caller's normal connection behavior.
2158
+ const previousBusyTimeout = Number(queryOne(runtime, db, 'PRAGMA busy_timeout;').timeout) || 0;
2159
+ const requestedBusyTimeout = Number(options.busyTimeoutMs);
2160
+ const busyTimeout = Number.isFinite(requestedBusyTimeout) && requestedBusyTimeout >= 0
2161
+ ? Math.floor(requestedBusyTimeout)
2162
+ : Math.min(previousBusyTimeout, 2_500);
2163
+ if (busyTimeout !== previousBusyTimeout) {
2164
+ execSql(runtime, db, `PRAGMA busy_timeout=${busyTimeout};`);
2165
+ }
2166
+ try {
2167
+ return allParams(
2168
+ runtime,
2169
+ db,
2170
+ `WITH eligible_superseders AS MATERIALIZED (
2171
+ SELECT superseded.value AS memory_key,
2172
+ CASE WHEN ${supersederConfirmed} THEN 1 ELSE 0 END AS is_confirmed
2173
+ FROM kernel_memories s,
2174
+ json_each(COALESCE(s.supersedes_json, '[]')) superseded
2175
+ WHERE ${projectMemoryScopeSql('s')}
2176
+ AND (${supersederConfirmed} OR s.updated_at >= ?)
2177
+ )
2178
+ SELECT m.*, bm25(kernel_memories_fts) AS __score FROM kernel_memories m
2089
2179
  JOIN kernel_memories_fts ON kernel_memories_fts.rowid = m.rowid
2090
2180
  WHERE kernel_memories_fts MATCH ?
2091
- ORDER BY bm25(kernel_memories_fts)
2181
+ AND ${projectMemoryScopeSql('m')}
2182
+ AND (${confirmed} OR m.updated_at >= ?)
2183
+ ${seenSql}
2184
+ AND NOT EXISTS (
2185
+ SELECT 1
2186
+ FROM eligible_superseders superseder
2187
+ WHERE superseder.memory_key = m.key
2188
+ AND (superseder.is_confirmed = 1 OR NOT ${confirmed})
2189
+ )
2190
+ ORDER BY bm25(kernel_memories_fts),
2191
+ CASE WHEN ${confirmed} THEN 0 ELSE 1 END,
2192
+ m.source_agent ASC, m.updated_at DESC, m.key ASC
2092
2193
  LIMIT ?`,
2093
- [match, capped],
2094
- ).map(row => ({ ...memoryRowToEntry(row), score: row.__score }));
2194
+ [projectId, cutoff, match, projectId, cutoff, ...excludeKeys, capped],
2195
+ ).map(row => normalizeRecallHit(
2196
+ { ...memoryRowToEntry(row), score: row.__score },
2197
+ projectId,
2198
+ ));
2199
+ } finally {
2200
+ if (busyTimeout !== previousBusyTimeout) {
2201
+ execSql(runtime, db, `PRAGMA busy_timeout=${previousBusyTimeout};`);
2202
+ }
2203
+ }
2095
2204
  }
2096
2205
 
2097
2206
  function closeDatabase(db) {
@@ -2109,12 +2218,17 @@ function createDriver(runtime, configuredDatabasePath) {
2109
2218
  // synchronous project-memory facade writes WITHOUT first running migrations. Lazily
2110
2219
  // ensure the table (idempotent CREATE IF NOT EXISTS, rendered from the same migration)
2111
2220
  // plus a busy_timeout for the second connection the issue backend may hold open.
2112
- function ensureMemorySchema(database) {
2221
+ function ensureMemorySchema(database, busyTimeoutMs) {
2113
2222
  if (memorySchemaEnsured) return;
2114
- execSql(runtime, database, 'PRAGMA busy_timeout=5000;');
2115
- for (const statement of buildMemoryProjectionMigration().apply) {
2116
- execSql(runtime, database, statement);
2117
- }
2223
+ const requestedBusyTimeout = Number(busyTimeoutMs);
2224
+ const busyTimeout = Number.isFinite(requestedBusyTimeout) && requestedBusyTimeout >= 0
2225
+ ? Math.floor(requestedBusyTimeout)
2226
+ : 5_000;
2227
+ execSql(runtime, database, `PRAGMA busy_timeout=${busyTimeout};`);
2228
+ try {
2229
+ for (const statement of buildMemoryProjectionMigration().apply) {
2230
+ execSql(runtime, database, statement);
2231
+ }
2118
2232
  // FTS5 recall index (migration 008): create the virtual table + sync triggers
2119
2233
  // idempotently so a synchronous memory write stays indexed without a prior
2120
2234
  // broker.initialize(). When the index is NEWLY created, rebuild once to backfill any
@@ -2125,20 +2239,25 @@ function createDriver(runtime, configuredDatabasePath) {
2125
2239
  // Staleness is detected by TABLE EXISTENCE (sqlite_master), never by count(*): on an
2126
2240
  // external-content FTS5 table `count(*)` returns the CONTENT row count, not the
2127
2241
  // indexed-doc count, so it can never reveal an un-backfilled index.
2128
- const ftsDdl = memoryFtsDdl();
2129
- const ftsExisted = Number(queryOne(
2130
- runtime,
2131
- database,
2132
- "SELECT count(*) AS count FROM sqlite_master WHERE type = 'table' AND name = 'kernel_memories_fts'",
2133
- ).count) > 0;
2134
- execSql(runtime, database, ftsDdl.create);
2135
- for (const trigger of ftsDdl.triggers) {
2136
- execSql(runtime, database, trigger);
2137
- }
2138
- if (!ftsExisted) {
2139
- execSql(runtime, database, ftsDdl.rebuild);
2242
+ const ftsDdl = memoryFtsDdl();
2243
+ const ftsExisted = Number(queryOne(
2244
+ runtime,
2245
+ database,
2246
+ "SELECT count(*) AS count FROM sqlite_master WHERE type = 'table' AND name = 'kernel_memories_fts'",
2247
+ ).count) > 0;
2248
+ execSql(runtime, database, ftsDdl.create);
2249
+ for (const trigger of ftsDdl.triggers) {
2250
+ execSql(runtime, database, trigger);
2251
+ }
2252
+ if (!ftsExisted) {
2253
+ execSql(runtime, database, ftsDdl.rebuild);
2254
+ }
2255
+ memorySchemaEnsured = true;
2256
+ } finally {
2257
+ if (busyTimeout !== 5_000) {
2258
+ execSql(runtime, database, 'PRAGMA busy_timeout=5000;');
2259
+ }
2140
2260
  }
2141
- memorySchemaEnsured = true;
2142
2261
  }
2143
2262
 
2144
2263
  function resolveDatabasePath(config) {
@@ -2335,6 +2454,11 @@ function createDriver(runtime, configuredDatabasePath) {
2335
2454
  async listKernelEvents(entityType, entityId, _context = {}, config = {}) {
2336
2455
  return listKernelEventRows(runtime, getDatabase(config), entityType, entityId);
2337
2456
  },
2457
+ // Additive bulk read for `forge insights` (Slice C2). Read-only; NOT part of the
2458
+ // GUARDED_DRIVER_METHODS write-path contract, so existing driver stubs stay valid.
2459
+ async listRecentKernelEvents({ since = null, limit = null } = {}, _context = {}, config = {}) {
2460
+ return listRecentKernelEventRows(runtime, getDatabase(config), since, limit);
2461
+ },
2338
2462
  async loadKernelEventByIdempotencyKey(idempotencyKey, _context = {}, config = {}) {
2339
2463
  return loadKernelEventByIdempotencyKeyRow(runtime, getDatabase(config), idempotencyKey);
2340
2464
  },
@@ -2460,8 +2584,8 @@ function createDriver(runtime, configuredDatabasePath) {
2460
2584
  // fallback). Used by the per-turn memory-recall hook.
2461
2585
  searchMemoriesRankedScored(query, limit, config = {}) {
2462
2586
  const database = getDatabase(config);
2463
- ensureMemorySchema(database);
2464
- return searchMemoryRowsRankedScored(runtime, database, query, limit);
2587
+ ensureMemorySchema(database, config.busyTimeoutMs);
2588
+ return searchMemoryRowsRankedScored(runtime, database, query, limit, config);
2465
2589
  },
2466
2590
  // The newest `limit` entries (default recall with no query). `options.agents` scopes
2467
2591
  // the read to a source_agent allow-list (e.g. human `remember` notes only).
@@ -55,6 +55,20 @@ pre-push:
55
55
  run: npm test --if-present
56
56
  `;
57
57
 
58
+ // The same config MINUS the TDD pre-commit job, written when the project already has its own
59
+ // pre-commit TDD/coupling gate (kernel 5b425a85 / 2699b234). Stacking a second gate on the same
60
+ // commit is the reported bug, so Forge defers on pre-commit but still wires pre-push.
61
+ const FORGE_USER_LEFTHOOK_YML_NO_TDD = `# Forge git hooks — installed by \`forge setup\` / \`forge init\`.
62
+ # Your project already has its own pre-commit TDD/coupling gate, so Forge did NOT add a second
63
+ # one here. To use Forge's gate instead, remove yours and run: forge gate enable rail.tdd_intent
64
+ # Edit freely to add your own checks — Forge only replaces a fully-commented stub.
65
+
66
+ pre-push:
67
+ commands:
68
+ tests:
69
+ run: npm test --if-present
70
+ `;
71
+
58
72
  /**
59
73
  * A fresh `lefthook install` (run by lefthook's own npm postinstall) drops a stock
60
74
  * EXAMPLE lefthook.yml with every hook commented out. That disposable stub used to
@@ -221,10 +235,13 @@ npm test --if-present || exit 1
221
235
  * `skipped`) rather than destroyed.
222
236
  *
223
237
  * @param {string} projectRoot - Absolute path to the project root.
238
+ * @param {{ skipHooks?: string[] }} [options] - Hook names to leave alone entirely. `forge setup`
239
+ * passes `['pre-commit']` when the project already has its own pre-commit TDD/coupling gate,
240
+ * so Forge defers instead of stacking a second one (kernel 5b425a85 / 2699b234).
224
241
  * @returns {{ installed: boolean, method?: string, hooksDir?: string,
225
242
  * written?: string[], skipped?: string[], reason?: string }}
226
243
  */
227
- function installNativeGitHooks(projectRoot) {
244
+ function installNativeGitHooks(projectRoot, options = {}) {
228
245
  const hooksDir = resolveGitHooksDir(projectRoot);
229
246
  if (!hooksDir) {
230
247
  return { installed: false, reason: 'not-a-git-repo' };
@@ -235,9 +252,11 @@ function installNativeGitHooks(projectRoot) {
235
252
  return { installed: false, reason: `hooks-dir-unwritable: ${error.message}` };
236
253
  }
237
254
 
255
+ const skipHooks = new Set(Array.isArray(options.skipHooks) ? options.skipHooks : []);
238
256
  const written = [];
239
257
  const skipped = [];
240
258
  for (const [name, body] of Object.entries(NATIVE_HOOK_BODIES)) {
259
+ if (skipHooks.has(name)) continue;
241
260
  const outcome = writeNativeHook(path.join(hooksDir, name), body);
242
261
  (outcome === 'written' ? written : skipped).push(name);
243
262
  }
@@ -406,6 +425,7 @@ function verifyHooksActive(projectRoot) {
406
425
  module.exports = {
407
426
  FORGE_NATIVE_HOOK_SENTINEL,
408
427
  FORGE_USER_LEFTHOOK_YML,
428
+ FORGE_USER_LEFTHOOK_YML_NO_TDD,
409
429
  forgeShouldWriteLefthookConfig,
410
430
  resolveGitHooksDir,
411
431
  installNativeGitHooks,