forge-workflow 0.1.0-beta.3 → 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 (196) 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 +21 -1
  5. package/bin/forge.js +16 -369
  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 +9 -4
  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 +117 -17
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/greptile-review-adapter.js +1 -1
  19. package/lib/adapters/pr-state-adapter.js +397 -100
  20. package/lib/agents-config.js +5 -0
  21. package/lib/audit-evidence.js +71 -110
  22. package/lib/capped-jsonl-log.js +236 -0
  23. package/lib/commands/_issue.js +31 -46
  24. package/lib/commands/_manifest.js +1 -1
  25. package/lib/commands/_registry.js +2 -2
  26. package/lib/commands/_resolve-command-opts.js +36 -29
  27. package/lib/commands/claim.js +2 -4
  28. package/lib/commands/clean.js +196 -32
  29. package/lib/commands/dev.js +4 -33
  30. package/lib/commands/hooks.js +358 -13
  31. package/lib/commands/insights.js +8 -3
  32. package/lib/commands/merge.js +600 -40
  33. package/lib/commands/plan.js +23 -115
  34. package/lib/commands/pr.js +1 -1
  35. package/lib/commands/preflight.js +11 -2
  36. package/lib/commands/prime.js +23 -3
  37. package/lib/commands/push.js +41 -51
  38. package/lib/commands/recall.js +60 -16
  39. package/lib/commands/recap.js +6 -1
  40. package/lib/commands/release.js +18 -4
  41. package/lib/commands/serve.js +5 -2
  42. package/lib/commands/setup.js +191 -95
  43. package/lib/commands/shepherd.js +49 -4
  44. package/lib/commands/ship.js +22 -23
  45. package/lib/commands/skill.js +383 -0
  46. package/lib/commands/status.js +54 -33
  47. package/lib/commands/test.js +56 -34
  48. package/lib/commands/worktree.js +247 -43
  49. package/lib/core/runtime-graph.js +89 -15
  50. package/lib/doc-assertions.js +297 -0
  51. package/lib/existing-tdd-gate.js +253 -0
  52. package/lib/forge-context.js +1 -4
  53. package/lib/forge-issues.js +64 -491
  54. package/lib/git-defaults.js +56 -0
  55. package/lib/harness-capability-matrix.js +5 -5
  56. package/lib/hook-renderer.js +147 -16
  57. package/lib/insights.js +96 -80
  58. package/lib/issue-backend.js +42 -3
  59. package/lib/kernel/backing-issue.js +14 -2
  60. package/lib/kernel/broker.js +44 -0
  61. package/lib/kernel/cli-broker-factory.js +12 -1
  62. package/lib/kernel/close-on-merge.js +154 -0
  63. package/lib/kernel/fs-class.js +42 -25
  64. package/lib/kernel/migrations.js +30 -2
  65. package/lib/kernel/schema.js +35 -0
  66. package/lib/kernel/sqlite-driver.js +292 -18
  67. package/lib/lefthook-wiring.js +21 -1
  68. package/lib/memory/router.js +16 -1
  69. package/lib/memory-digest.js +47 -15
  70. package/lib/memory-recall-events.js +145 -0
  71. package/lib/memory-recall.js +212 -0
  72. package/lib/merge-rules.js +8 -4
  73. package/lib/npm-publish-workflow.js +272 -0
  74. package/lib/orientation.js +371 -49
  75. package/lib/plugin-catalog.js +14 -4
  76. package/lib/pr-bundle.js +9 -6
  77. package/lib/pr-monitor/journal.js +18 -2
  78. package/lib/pr-monitor/reconcile-executor.js +842 -0
  79. package/lib/pr-monitor/reconcile-tick.js +138 -0
  80. package/lib/pr-monitor/reconcile.js +0 -0
  81. package/lib/pr-monitor/render-summary.js +196 -0
  82. package/lib/pr-monitor/shepherd-lease.js +252 -0
  83. package/lib/pr-monitor/watch-lifecycle.js +14 -2
  84. package/lib/pr-pull.js +98 -24
  85. package/lib/pr-shepherd.js +34 -8
  86. package/lib/preflight/gates.js +65 -18
  87. package/lib/preflight/runner.js +5 -0
  88. package/lib/project-memory.js +40 -0
  89. package/lib/protected-state-authority.js +305 -0
  90. package/lib/protected-state-surfaces.js +64 -44
  91. package/lib/release-readiness.js +51 -4
  92. package/lib/rules-sync.js +4 -0
  93. package/lib/runtime-health.js +15 -46
  94. package/lib/shell-utils.js +1 -1
  95. package/lib/skill-eval.js +750 -0
  96. package/lib/skills-sync.js +6 -3
  97. package/lib/smart-merge.js +28 -4
  98. package/lib/status/identity.js +46 -0
  99. package/lib/status/presenter.js +0 -35
  100. package/lib/status/snapshot.js +11 -16
  101. package/lib/symlink-utils.js +74 -26
  102. package/lib/upgrade-safety.js +47 -9
  103. package/lib/using-forge.js +328 -0
  104. package/lib/workflow/enforce-stage.js +5 -5
  105. package/lib/workflow/state-manager.js +23 -23
  106. package/package.json +6 -7
  107. package/rules/using-forge.md +24 -0
  108. package/scripts/doc-asserting-tests.js +158 -0
  109. package/scripts/forge-team/index.sh +0 -5
  110. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  111. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  112. package/scripts/lib/behavioral-eval-runner.js +310 -0
  113. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  114. package/scripts/lib/eval-evidence.js +328 -0
  115. package/scripts/lib/eval-runner.js +81 -41
  116. package/scripts/lib/immutable-eval-corpus.js +309 -0
  117. package/scripts/lib/promotion-evidence-loader.js +94 -0
  118. package/scripts/lib/promotion-scorecard.js +314 -0
  119. package/scripts/npm-release-receipt.js +134 -0
  120. package/scripts/process-tree.js +761 -0
  121. package/scripts/protected-state-check.js +47 -22
  122. package/scripts/run-command-eval.js +29 -1
  123. package/scripts/sync-d20-audit.js +172 -0
  124. package/scripts/test-full-suite.js +249 -37
  125. package/scripts/test.js +184 -44
  126. package/skills/claim-safety/SKILL.md +4 -0
  127. package/skills/claim-safety/evals/scorecard.json +41 -0
  128. package/skills/coverage.json +83 -0
  129. package/skills/dev/SKILL.md +4 -0
  130. package/skills/dev/evals/scorecard.json +41 -0
  131. package/skills/gates/SKILL.md +80 -0
  132. package/skills/gates/evals/evals.json +38 -0
  133. package/skills/gates/evals/scorecard.json +41 -0
  134. package/skills/hermes-forge/SKILL.md +1 -0
  135. package/skills/hermes-forge/evals/scorecard.json +41 -0
  136. package/skills/issue-basics/SKILL.md +1 -0
  137. package/skills/issue-basics/evals/scorecard.json +41 -0
  138. package/skills/kernel/SKILL.md +38 -0
  139. package/skills/kernel/evals/scorecard.json +41 -0
  140. package/skills/memory/SKILL.md +16 -1
  141. package/skills/memory/evals/scorecard.json +41 -0
  142. package/skills/parallel-deep-research/SKILL.md +1 -0
  143. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  144. package/skills/plan/SKILL.md +6 -0
  145. package/skills/plan/evals/scorecard.json +41 -0
  146. package/skills/portability/SKILL.md +47 -0
  147. package/skills/portability/evals/evals.json +34 -0
  148. package/skills/portability/evals/scorecard.json +41 -0
  149. package/skills/research/SKILL.md +1 -0
  150. package/skills/research/evals/scorecard.json +41 -0
  151. package/skills/review/SKILL.md +10 -11
  152. package/skills/review/evals/scorecard.json +41 -0
  153. package/skills/rollback/SKILL.md +5 -11
  154. package/skills/rollback/evals/scorecard.json +41 -0
  155. package/skills/setup/SKILL.md +91 -0
  156. package/skills/setup/evals/evals.json +42 -0
  157. package/skills/setup/evals/scorecard.json +41 -0
  158. package/skills/shepherd/SKILL.md +84 -38
  159. package/skills/shepherd/evals/evals.json +21 -9
  160. package/skills/shepherd/evals/scorecard.json +41 -0
  161. package/skills/ship/SKILL.md +10 -12
  162. package/skills/ship/evals/scorecard.json +41 -0
  163. package/skills/smith/SKILL.md +8 -0
  164. package/skills/smith/evals/scorecard.json +41 -0
  165. package/skills/sonarcloud/SKILL.md +1 -0
  166. package/skills/sonarcloud/evals/scorecard.json +41 -0
  167. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  168. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  169. package/skills/status/SKILL.md +3 -0
  170. package/skills/status/evals/scorecard.json +41 -0
  171. package/skills/triage-ready/SKILL.md +2 -0
  172. package/skills/triage-ready/evals/scorecard.json +41 -0
  173. package/skills/using-forge/SKILL.md +104 -0
  174. package/skills/using-forge/evals/scorecard.json +41 -0
  175. package/skills/validate/SKILL.md +4 -0
  176. package/skills/validate/evals/scorecard.json +41 -0
  177. package/skills/verify/SKILL.md +4 -0
  178. package/skills/verify/evals/scorecard.json +41 -0
  179. package/skills/worktree/SKILL.md +92 -0
  180. package/skills/worktree/evals/evals.json +38 -0
  181. package/skills/worktree/evals/scorecard.json +41 -0
  182. package/lib/adapters/beads-issue-adapter.js +0 -127
  183. package/lib/beads-nudge.js +0 -91
  184. package/lib/beads-setup.js +0 -538
  185. package/lib/beads-sync-scaffold.js +0 -189
  186. package/lib/commands/board.js +0 -64
  187. package/lib/pat-setup.js +0 -207
  188. package/lib/pr-monitor/render-sticky.js +0 -192
  189. package/lib/pr-monitor/upsert-sticky.js +0 -169
  190. package/lib/status/beads-snapshot.js +0 -145
  191. package/scripts/beads-context.sh +0 -577
  192. package/scripts/beads-migrate-to-dolt.sh +0 -7
  193. package/scripts/beads-upgrade-smoke.sh +0 -284
  194. package/scripts/forge-team/lib/dashboard.sh +0 -316
  195. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -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.
@@ -2070,6 +2104,105 @@ function searchMemoryRowsRanked(runtime, db, query, limit) {
2070
2104
  ).map(memoryRowToEntry);
2071
2105
  }
2072
2106
 
2107
+ // Relevance-ONLY BM25 recall that exposes the raw bm25 score on each entry. The
2108
+ // per-turn auto-recall hook needs the score to apply a relevance FLOOR (inject nothing
2109
+ // when nothing clears the bar) — ordinal rank can't express "nothing was relevant".
2110
+ // Unlike searchMemoryRowsRanked, a no-match (or empty) query returns [] with NO recency
2111
+ // fallback: the whole point is to avoid surfacing recent-but-irrelevant notes. bm25()
2112
+ // returns more-negative for stronger matches, so rows come back best (lowest) first.
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 = {}) {
2136
+ const capped = Number.isInteger(limit) && limit > 0 ? limit : 20;
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);
2140
+ if (!match) {
2141
+ return [];
2142
+ }
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
2179
+ JOIN kernel_memories_fts ON kernel_memories_fts.rowid = m.rowid
2180
+ WHERE kernel_memories_fts MATCH ?
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
2193
+ LIMIT ?`,
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
+ }
2204
+ }
2205
+
2073
2206
  function closeDatabase(db) {
2074
2207
  if (db && typeof db.close === 'function') {
2075
2208
  db.close();
@@ -2085,12 +2218,17 @@ function createDriver(runtime, configuredDatabasePath) {
2085
2218
  // synchronous project-memory facade writes WITHOUT first running migrations. Lazily
2086
2219
  // ensure the table (idempotent CREATE IF NOT EXISTS, rendered from the same migration)
2087
2220
  // plus a busy_timeout for the second connection the issue backend may hold open.
2088
- function ensureMemorySchema(database) {
2221
+ function ensureMemorySchema(database, busyTimeoutMs) {
2089
2222
  if (memorySchemaEnsured) return;
2090
- execSql(runtime, database, 'PRAGMA busy_timeout=5000;');
2091
- for (const statement of buildMemoryProjectionMigration().apply) {
2092
- execSql(runtime, database, statement);
2093
- }
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
+ }
2094
2232
  // FTS5 recall index (migration 008): create the virtual table + sync triggers
2095
2233
  // idempotently so a synchronous memory write stays indexed without a prior
2096
2234
  // broker.initialize(). When the index is NEWLY created, rebuild once to backfill any
@@ -2101,20 +2239,25 @@ function createDriver(runtime, configuredDatabasePath) {
2101
2239
  // Staleness is detected by TABLE EXISTENCE (sqlite_master), never by count(*): on an
2102
2240
  // external-content FTS5 table `count(*)` returns the CONTENT row count, not the
2103
2241
  // indexed-doc count, so it can never reveal an un-backfilled index.
2104
- const ftsDdl = memoryFtsDdl();
2105
- const ftsExisted = Number(queryOne(
2106
- runtime,
2107
- database,
2108
- "SELECT count(*) AS count FROM sqlite_master WHERE type = 'table' AND name = 'kernel_memories_fts'",
2109
- ).count) > 0;
2110
- execSql(runtime, database, ftsDdl.create);
2111
- for (const trigger of ftsDdl.triggers) {
2112
- execSql(runtime, database, trigger);
2113
- }
2114
- if (!ftsExisted) {
2115
- 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
+ }
2116
2260
  }
2117
- memorySchemaEnsured = true;
2118
2261
  }
2119
2262
 
2120
2263
  function resolveDatabasePath(config) {
@@ -2181,6 +2324,124 @@ function createDriver(runtime, configuredDatabasePath) {
2181
2324
  [`${escaped}%`, limit],
2182
2325
  );
2183
2326
  },
2327
+ // Open PRs under shepherd for one repo (autonomous-shepherd design §3.4): the
2328
+ // reconciler's "open PRs in this repo" read, keyed by git_common_dir so every
2329
+ // worktree shares one view. Parameterized (git_common_dir is a filesystem path —
2330
+ // never interpolate it), covered by idx_pr_common_dir_state_repo_number. Ordered so
2331
+ // the result is deterministic. `context` is part of the broker contract but unused
2332
+ // by this direct SELECT (prefixed `_` for eslint no-unused-vars).
2333
+ async listOpenPrs(gitCommonDir, _context = {}, config = {}) {
2334
+ return allParams(
2335
+ runtime, getDatabase(config),
2336
+ "SELECT * FROM kernel_pr WHERE git_common_dir = ? AND state = 'open' ORDER BY repo ASC, number ASC",
2337
+ [gitCommonDir],
2338
+ );
2339
+ },
2340
+ // --- kernel_pr WRITE path (autonomous-shepherd design §5a). pr rows are DERIVED
2341
+ // reconcile state (reconstructable from GitHub), not audit-critical issue authority,
2342
+ // so they take a DIRECT idempotent upsert — NOT the event-sourced guarded path
2343
+ // (applyAcceptedIssueMutation). All target the physical `kernel_pr` table (matching
2344
+ // the listOpenPrs read) and are parameterized (git_common_dir/branch/head_sha are
2345
+ // externally-influenced values — never interpolate). `context` is part of the broker
2346
+ // contract but unused by these direct writes (prefixed `_` for eslint no-unused-vars).
2347
+ //
2348
+ // Register/refresh a PR row keyed by (git_common_dir, repo, number). Idempotent via
2349
+ // ON CONFLICT on the unique idx_pr_common_dir_repo_number: a re-upsert updates the
2350
+ // mutable columns and coalesces soft links (a later null never clobbers an existing
2351
+ // issue_id/worktree_id). registered_at is set on INSERT only; state defaults 'open'.
2352
+ async upsertPr(row, _context = {}, config = {}) {
2353
+ const id = row.id || randomUUID();
2354
+ const registeredAt = row.registered_at || new Date().toISOString();
2355
+ const state = row.state || 'open';
2356
+ runParams(
2357
+ runtime, getDatabase(config),
2358
+ `INSERT INTO kernel_pr
2359
+ (id, git_common_dir, repo, number, issue_id, worktree_id, branch, head_sha, journal_ptr, state, registered_at)
2360
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2361
+ ON CONFLICT(git_common_dir, repo, number) DO UPDATE SET
2362
+ head_sha = excluded.head_sha,
2363
+ branch = excluded.branch,
2364
+ issue_id = coalesce(excluded.issue_id, kernel_pr.issue_id),
2365
+ worktree_id = coalesce(excluded.worktree_id, kernel_pr.worktree_id),
2366
+ -- journal_ptr is a soft link: coalesce it (like issue_id/worktree_id) so a
2367
+ -- head-only refresh that omits journalPtr never severs the ledger↔journal
2368
+ -- link with NULL. (Codex review, PR #426.)
2369
+ journal_ptr = coalesce(excluded.journal_ptr, kernel_pr.journal_ptr),
2370
+ -- REOPEN semantics: upsertPr is only ever called for PRs GitHub reports as
2371
+ -- OPEN, so re-registering a previously retired row (a reopened PR) must flip
2372
+ -- it back to open and clear retired_at — else listOpenPrs (state='open') would
2373
+ -- keep the reopened PR invisible forever. (Codex review, PR #426.)
2374
+ state = 'open',
2375
+ retired_at = NULL,
2376
+ -- A new commit INVALIDATES the prior verdict: when the head advances to a
2377
+ -- different non-null sha, clear verdict/source/at so a verdict computed
2378
+ -- against the OLD head is never presented as fresh for the new head (and the
2379
+ -- freshest-head guard in updatePrVerdict keeps intact evidence). IS NOT is the
2380
+ -- null-safe distinctness test; a headless refresh (excluded.head_sha NULL)
2381
+ -- never clears. (Codex review, PR #426.)
2382
+ verdict = CASE WHEN excluded.head_sha IS NOT NULL AND excluded.head_sha IS NOT kernel_pr.head_sha THEN NULL ELSE kernel_pr.verdict END,
2383
+ verdict_source = CASE WHEN excluded.head_sha IS NOT NULL AND excluded.head_sha IS NOT kernel_pr.head_sha THEN NULL ELSE kernel_pr.verdict_source END,
2384
+ verdict_at = CASE WHEN excluded.head_sha IS NOT NULL AND excluded.head_sha IS NOT kernel_pr.head_sha THEN NULL ELSE kernel_pr.verdict_at END`,
2385
+ [
2386
+ id,
2387
+ row.git_common_dir,
2388
+ row.repo,
2389
+ row.number,
2390
+ row.issue_id ?? null,
2391
+ row.worktree_id ?? null,
2392
+ row.branch ?? null,
2393
+ row.head_sha ?? null,
2394
+ row.journal_ptr ?? null,
2395
+ state,
2396
+ registeredAt,
2397
+ ],
2398
+ );
2399
+ return { ok: true, id };
2400
+ },
2401
+ // The ONE verdict authority WRITE (design §1.2 rule 2) — FRESHEST-HEAD-SHA
2402
+ // PRECEDENCE enforced in the WHERE so a verdict computed against a SUPERSEDED head is
2403
+ // DISCARDED, not written (kills stale 9d35c14b at the write). A non-local (Actions
2404
+ // backstop) write lands only when its head_sha matches the row's current head (or the
2405
+ // row has none yet); a `local` verdict is computed live against the current head and
2406
+ // is always authoritative, so it bypasses the head match.
2407
+ async updatePrVerdict(key, patch = {}, _context = {}, config = {}) {
2408
+ const headSha = patch.head_sha ?? null;
2409
+ const source = patch.verdict_source ?? null;
2410
+ runParams(
2411
+ runtime, getDatabase(config),
2412
+ `UPDATE kernel_pr SET verdict = ?, verdict_source = ?, verdict_at = ?, head_sha = ?
2413
+ WHERE git_common_dir = ? AND repo = ? AND number = ?
2414
+ AND (head_sha IS NULL OR head_sha = ? OR ? = 'local')`,
2415
+ [
2416
+ patch.verdict ?? null,
2417
+ source,
2418
+ patch.verdict_at ?? null,
2419
+ headSha,
2420
+ key.git_common_dir,
2421
+ key.repo,
2422
+ key.number,
2423
+ headSha,
2424
+ source,
2425
+ ],
2426
+ );
2427
+ return { ok: true };
2428
+ },
2429
+ // Retire a PR row (merged/closed): flip state + stamp retired_at so it drops out of
2430
+ // the open-PR read while the reconcile history is retained.
2431
+ async retirePr(key, patch = {}, _context = {}, config = {}) {
2432
+ runParams(
2433
+ runtime, getDatabase(config),
2434
+ 'UPDATE kernel_pr SET state = ?, retired_at = ? WHERE git_common_dir = ? AND repo = ? AND number = ?',
2435
+ [
2436
+ patch.state ?? 'closed',
2437
+ patch.retired_at ?? new Date().toISOString(),
2438
+ key.git_common_dir,
2439
+ key.repo,
2440
+ key.number,
2441
+ ],
2442
+ );
2443
+ return { ok: true };
2444
+ },
2184
2445
  // --- Event-store primitives (Wave 2) — composed by broker.runGuardedEvent.
2185
2446
  // `context` is part of the broker contract but unused by these direct SQL
2186
2447
  // reads/writes (prefixed `_` for eslint no-unused-vars).
@@ -2193,6 +2454,11 @@ function createDriver(runtime, configuredDatabasePath) {
2193
2454
  async listKernelEvents(entityType, entityId, _context = {}, config = {}) {
2194
2455
  return listKernelEventRows(runtime, getDatabase(config), entityType, entityId);
2195
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
+ },
2196
2462
  async loadKernelEventByIdempotencyKey(idempotencyKey, _context = {}, config = {}) {
2197
2463
  return loadKernelEventByIdempotencyKeyRow(runtime, getDatabase(config), idempotencyKey);
2198
2464
  },
@@ -2313,6 +2579,14 @@ function createDriver(runtime, configuredDatabasePath) {
2313
2579
  ensureMemorySchema(database);
2314
2580
  return searchMemoryRowsRanked(runtime, database, query, limit);
2315
2581
  },
2582
+ // Relevance-only BM25 recall that also returns the raw bm25 `score` per entry, so a
2583
+ // caller can apply a relevance floor. A no-match/empty query returns [] (no recency
2584
+ // fallback). Used by the per-turn memory-recall hook.
2585
+ searchMemoriesRankedScored(query, limit, config = {}) {
2586
+ const database = getDatabase(config);
2587
+ ensureMemorySchema(database, config.busyTimeoutMs);
2588
+ return searchMemoryRowsRankedScored(runtime, database, query, limit, config);
2589
+ },
2316
2590
  // The newest `limit` entries (default recall with no query). `options.agents` scopes
2317
2591
  // the read to a source_agent allow-list (e.g. human `remember` notes only).
2318
2592
  recentMemories(limit, options = {}, config = {}) {
@@ -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,
@@ -242,6 +242,21 @@ function legacyJsonlPath(projectRoot) {
242
242
  return path.join(projectRoot, ...LEGACY_JSONL_RELATIVE);
243
243
  }
244
244
 
245
+ function legacyTags(parsed) {
246
+ const tags = Array.isArray(parsed.tags)
247
+ ? parsed.tags.filter(tag => typeof tag === 'string')
248
+ : [];
249
+ if (
250
+ typeof parsed.type === 'string'
251
+ && /^[a-z0-9][a-z0-9-]{0,63}$/i.test(parsed.type)
252
+ && !tags.some(tag => tag.startsWith('type:'))
253
+ ) {
254
+ tags.push(`type:${parsed.type.toLowerCase()}`);
255
+ }
256
+ if (!tags.some(tag => tag.startsWith('trust:'))) tags.push('trust:suggested');
257
+ return tags;
258
+ }
259
+
245
260
  // A STABLE key for a legacy record that lacks an `id`, derived from its content — so a
246
261
  // re-run (or a failed rename) upserts the same row instead of double-inserting under a
247
262
  // fresh random UUID.
@@ -292,7 +307,7 @@ function migrateJsonlNotesOnce(projectRoot, options = {}) {
292
307
  key: typeof parsed.id === 'string' && parsed.id ? parsed.id : legacyContentKey(parsed),
293
308
  value: parsed.note,
294
309
  sourceAgent: IMPORT_SOURCE_AGENT,
295
- tags: Array.isArray(parsed.tags) ? parsed.tags.filter(tag => typeof tag === 'string') : [],
310
+ tags: legacyTags(parsed),
296
311
  };
297
312
  if (typeof parsed.timestamp === 'string' && parsed.timestamp && !Number.isNaN(Date.parse(parsed.timestamp))) {
298
313
  entry.timestamp = parsed.timestamp;
@@ -22,6 +22,7 @@
22
22
  const { applyBudget, buildSection, estimateTokens } = require('./orientation');
23
23
  const { fenceUntrusted } = require('./untrusted-content');
24
24
  const { collectInbox, inboxSection } = require('./inbox');
25
+ const { memoryTrustStatus } = require('./memory-recall');
25
26
 
26
27
  const DEFAULT_DIGEST_BUDGET_TOKENS = 400;
27
28
  const DEFAULT_NOTE_LIMIT = 5;
@@ -103,7 +104,13 @@ async function collectDigestData(projectRoot, opts = {}) {
103
104
  /** `- [date ]note` for a recall note. */
104
105
  function formatNoteLine(note) {
105
106
  const date = typeof note.timestamp === 'string' && note.timestamp ? `${note.timestamp.slice(0, 10)} ` : '';
106
- return `- ${date}${note.note}`;
107
+ const trust = memoryTrustStatus({
108
+ tags: note.tags,
109
+ sourceAgent: note.sourceAgent,
110
+ value: note.machine ? {} : note.note,
111
+ });
112
+ const sourceAgent = note.sourceAgent || 'unknown';
113
+ return `- [source=${sourceAgent} trust=${trust} updated=${date.trim() || 'unknown'}] ${note.note}`;
107
114
  }
108
115
 
109
116
  /** `- [label] title` for an issue row (title/id defensively resolved). */
@@ -112,18 +119,39 @@ function formatIssueLine(label, issue) {
112
119
  return `- [${label}] ${title}`;
113
120
  }
114
121
 
115
- /** Build the notes section, or null when there are no notes. */
116
- function notesSection(notes) {
117
- if (!notes.length) return null;
118
- return buildSection({
119
- id: 'digest_notes',
120
- title: 'Remembered notes',
121
- content: notes.map(formatNoteLine).join('\n'),
122
- priority: 10,
123
- preserve: false,
124
- // Untrusted: a planted note is DATA, not instructions. Fenced after truncation.
125
- untrustedSource: 'memory',
126
- });
122
+ /** Build separate confirmed/suggested note sections, skipping entries too large to fit. */
123
+ function notesSections(notes, budgetTokens) {
124
+ const eligible = notes
125
+ .map(note => ({ note, line: formatNoteLine(note) }))
126
+ .filter(entry => estimateTokens(entry.line) <= budgetTokens);
127
+ const groups = [
128
+ { id: 'digest_notes', title: 'Confirmed memory', trust: 'confirmed', priority: 10 },
129
+ {
130
+ id: 'digest_suggested_memory',
131
+ title: 'Suggested memory — verify before relying',
132
+ trust: 'suggested',
133
+ priority: 11,
134
+ },
135
+ ];
136
+ return groups.map(group => {
137
+ const content = eligible
138
+ .filter(({ note }) => memoryTrustStatus({
139
+ tags: note.tags,
140
+ sourceAgent: note.sourceAgent,
141
+ value: note.machine ? {} : note.note,
142
+ }) === group.trust)
143
+ .map(entry => entry.line)
144
+ .join('\n');
145
+ if (!content) return null;
146
+ return buildSection({
147
+ id: group.id,
148
+ title: group.title,
149
+ content,
150
+ priority: group.priority,
151
+ preserve: false,
152
+ untrustedSource: 'memory',
153
+ });
154
+ }).filter(Boolean);
127
155
  }
128
156
 
129
157
  /**
@@ -161,13 +189,17 @@ function buildMemoryDigest(data = {}, options = {}) {
161
189
  const ready = Array.isArray(data.ready) ? data.ready : [];
162
190
  const claimed = Array.isArray(data.claimed) ? data.claimed : [];
163
191
  const inbox = Array.isArray(data.inbox) ? data.inbox : [];
192
+ const budgetTokens = options.budgetTokens || DEFAULT_DIGEST_BUDGET_TOKENS;
164
193
 
165
194
  // Inbox (priority 5) is a THIRD section beside notes + issues; a fresh human directive
166
195
  // outranks stale notes (10) and the agent's own issue list (20) under budget pressure.
167
- const sections = [inboxSection(inbox), notesSection(notes), issuesSection(ready, claimed)].filter(Boolean);
196
+ const sections = [
197
+ inboxSection(inbox),
198
+ ...notesSections(notes, budgetTokens),
199
+ issuesSection(ready, claimed),
200
+ ].filter(Boolean);
168
201
  if (!sections.length) return { text: '', empty: true, tokens: 0 };
169
202
 
170
- const budgetTokens = options.budgetTokens || DEFAULT_DIGEST_BUDGET_TOKENS;
171
203
  const budgeted = applyBudget(sections, budgetTokens);
172
204
  const body = budgeted.sections
173
205
  .filter(section => section.content)