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

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 (155) hide show
  1. package/.forge/hooks/check-tdd.js +79 -5
  2. package/.forge/hooks/forge-native-hook.js +194 -8
  3. package/AGENTS.md +2 -1
  4. package/CHANGELOG.md +28 -0
  5. package/QUICKSTART.md +6 -2
  6. package/README.md +3 -1
  7. package/bin/forge-cmd.js +1 -1
  8. package/bin/forge.js +95 -19
  9. package/docs/guides/SETUP.md +4 -1
  10. package/docs/guides/SUPPORT.md +5 -0
  11. package/docs/reference/COMMANDS.md +12 -1
  12. package/docs/reference/shepherd.md +114 -3
  13. package/lib/activation/ensure-forge-home.js +135 -0
  14. package/lib/adapters/beads-kernel-compat.js +67 -0
  15. package/lib/adapters/greptile-review-adapter.js +1 -1
  16. package/lib/adapters/pr-state-adapter.js +103 -8
  17. package/lib/adoption-profiles.js +17 -4
  18. package/lib/agents-config.js +5 -0
  19. package/lib/beads-detect.js +60 -0
  20. package/lib/commands/_aliases.js +248 -0
  21. package/lib/commands/_issue.js +65 -41
  22. package/lib/commands/_manifest.js +3 -1
  23. package/lib/commands/_registry.js +14 -0
  24. package/lib/commands/_resolve-command-opts.js +36 -60
  25. package/lib/commands/claim.js +2 -4
  26. package/lib/commands/gate.js +19 -2
  27. package/lib/commands/hooks.js +294 -12
  28. package/lib/commands/init.js +26 -20
  29. package/lib/commands/memory.js +81 -0
  30. package/lib/commands/migrate.js +0 -161
  31. package/lib/commands/plan.js +71 -123
  32. package/lib/commands/pr.js +88 -0
  33. package/lib/commands/prime.js +8 -1
  34. package/lib/commands/push.js +66 -0
  35. package/lib/commands/recall.js +67 -12
  36. package/lib/commands/recap.js +18 -4
  37. package/lib/commands/release.js +15 -3
  38. package/lib/commands/remember.js +86 -20
  39. package/lib/commands/serve.js +5 -2
  40. package/lib/commands/setup.js +135 -73
  41. package/lib/commands/shepherd.js +102 -4
  42. package/lib/commands/ship.js +40 -4
  43. package/lib/commands/skill.js +275 -0
  44. package/lib/commands/status.js +37 -32
  45. package/lib/commands/test.js +32 -0
  46. package/lib/commands/worktree.js +87 -5
  47. package/lib/core/runtime-graph.js +122 -17
  48. package/lib/forge-issues.js +13 -464
  49. package/lib/gate-events.js +54 -55
  50. package/lib/global-flags.js +30 -0
  51. package/lib/grounding/context-events.js +230 -0
  52. package/lib/grounding/read-first.js +112 -0
  53. package/lib/harness-capability-matrix.js +2 -2
  54. package/lib/hook-renderer.js +144 -12
  55. package/lib/issue-backend.js +42 -3
  56. package/lib/kernel/backing-issue.js +7 -1
  57. package/lib/kernel/broker.js +28 -0
  58. package/lib/kernel/migrations.js +30 -2
  59. package/lib/kernel/owned-kernel.js +43 -0
  60. package/lib/kernel/schema.js +35 -0
  61. package/lib/kernel/sqlite-driver.js +187 -1
  62. package/lib/memory-recall.js +151 -0
  63. package/lib/orientation.js +303 -6
  64. package/lib/pr-bundle.js +6 -2
  65. package/lib/pr-monitor/auto-actions.js +175 -0
  66. package/lib/pr-monitor/digest.js +206 -0
  67. package/lib/pr-monitor/reconcile-executor.js +659 -0
  68. package/lib/pr-monitor/reconcile-tick.js +138 -0
  69. package/lib/pr-monitor/reconcile.js +0 -0
  70. package/lib/pr-monitor/render-sticky.js +57 -8
  71. package/lib/pr-monitor/shepherd-lease.js +243 -0
  72. package/lib/pr-monitor/upsert-sticky.js +169 -0
  73. package/lib/pr-monitor/watch-lifecycle.js +1 -1
  74. package/lib/pr-pull.js +113 -17
  75. package/lib/project-memory.js +8 -0
  76. package/lib/release-readiness.js +17 -1
  77. package/lib/rules-sync.js +4 -0
  78. package/lib/runtime-health.js +15 -46
  79. package/lib/skill-eval.js +750 -0
  80. package/lib/status/identity.js +46 -0
  81. package/lib/status/presenter.js +0 -35
  82. package/lib/status/snapshot.js +11 -16
  83. package/lib/upgrade-safety.js +52 -1
  84. package/lib/using-forge.js +315 -0
  85. package/lib/workflow/enforce-stage.js +64 -7
  86. package/lib/workflow/state-manager.js +23 -23
  87. package/package.json +2 -2
  88. package/rules/using-forge.md +24 -0
  89. package/scripts/forge-team/index.sh +0 -5
  90. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  91. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  92. package/scripts/pr-auto-actions.js +93 -0
  93. package/scripts/pr-verdict-label.js +50 -0
  94. package/scripts/test.js +8 -1
  95. package/skills/claim-safety/SKILL.md +4 -0
  96. package/skills/claim-safety/evals/scorecard.json +41 -0
  97. package/skills/coverage.json +83 -0
  98. package/skills/dev/SKILL.md +4 -0
  99. package/skills/dev/evals/scorecard.json +41 -0
  100. package/skills/gates/SKILL.md +80 -0
  101. package/skills/gates/evals/evals.json +38 -0
  102. package/skills/gates/evals/scorecard.json +41 -0
  103. package/skills/hermes-forge/SKILL.md +1 -0
  104. package/skills/hermes-forge/evals/scorecard.json +41 -0
  105. package/skills/issue-basics/SKILL.md +1 -0
  106. package/skills/issue-basics/evals/scorecard.json +41 -0
  107. package/skills/kernel/SKILL.md +38 -0
  108. package/skills/kernel/evals/scorecard.json +41 -0
  109. package/skills/memory/SKILL.md +16 -1
  110. package/skills/memory/evals/scorecard.json +41 -0
  111. package/skills/parallel-deep-research/SKILL.md +1 -0
  112. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  113. package/skills/plan/SKILL.md +6 -0
  114. package/skills/plan/evals/scorecard.json +41 -0
  115. package/skills/portability/SKILL.md +47 -0
  116. package/skills/portability/evals/evals.json +34 -0
  117. package/skills/portability/evals/scorecard.json +41 -0
  118. package/skills/research/SKILL.md +1 -0
  119. package/skills/research/evals/scorecard.json +41 -0
  120. package/skills/review/SKILL.md +6 -0
  121. package/skills/review/evals/scorecard.json +41 -0
  122. package/skills/rollback/SKILL.md +1 -0
  123. package/skills/rollback/evals/scorecard.json +41 -0
  124. package/skills/setup/SKILL.md +91 -0
  125. package/skills/setup/evals/evals.json +42 -0
  126. package/skills/setup/evals/scorecard.json +41 -0
  127. package/skills/shepherd/SKILL.md +76 -36
  128. package/skills/shepherd/evals/evals.json +21 -9
  129. package/skills/shepherd/evals/scorecard.json +41 -0
  130. package/skills/ship/SKILL.md +6 -0
  131. package/skills/ship/evals/scorecard.json +41 -0
  132. package/skills/smith/SKILL.md +8 -0
  133. package/skills/smith/evals/scorecard.json +41 -0
  134. package/skills/sonarcloud/SKILL.md +1 -0
  135. package/skills/sonarcloud/evals/scorecard.json +41 -0
  136. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  137. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  138. package/skills/status/SKILL.md +3 -0
  139. package/skills/status/evals/scorecard.json +41 -0
  140. package/skills/triage-ready/SKILL.md +2 -0
  141. package/skills/triage-ready/evals/scorecard.json +41 -0
  142. package/skills/using-forge/SKILL.md +104 -0
  143. package/skills/using-forge/evals/scorecard.json +41 -0
  144. package/skills/validate/SKILL.md +4 -0
  145. package/skills/validate/evals/scorecard.json +41 -0
  146. package/skills/verify/SKILL.md +4 -0
  147. package/skills/verify/evals/scorecard.json +41 -0
  148. package/skills/worktree/SKILL.md +87 -0
  149. package/skills/worktree/evals/evals.json +38 -0
  150. package/skills/worktree/evals/scorecard.json +41 -0
  151. package/lib/adapters/beads-issue-adapter.js +0 -127
  152. package/lib/commands/board.js +0 -64
  153. package/lib/status/beads-snapshot.js +0 -145
  154. package/scripts/forge-team/lib/dashboard.sh +0 -316
  155. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
@@ -3,10 +3,34 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
 
6
- const VALID_BACKENDS = new Set(['kernel', 'beads']);
6
+ const VALID_BACKENDS = new Set(['kernel']);
7
7
  const DEFAULT_BACKEND = 'kernel';
8
8
  const ENV_VAR = 'FORGE_ISSUE_BACKEND';
9
9
 
10
+ // Backends that Forge used to accept and has since retired. Kept as an explicit set
11
+ // (rather than folding them into the generic "unknown backend" path) so a user who
12
+ // still carries `issueBackend: beads` in config — or `FORGE_ISSUE_BACKEND=beads` in a
13
+ // shell profile — gets the ONE actionable instruction instead of a bare valid-values
14
+ // list: import the Beads store into the kernel.
15
+ const REMOVED_BACKENDS = new Set(['beads']);
16
+
17
+ // The single migrate pointer shared by every removed-backend surface (the resolver's
18
+ // warning and the CLI flag's hard error) so the two can never drift.
19
+ const BEADS_REMOVED_HINT =
20
+ 'the beads backend was removed; run `forge migrate --from beads` to import a Beads store into the kernel';
21
+
22
+ /**
23
+ * The migrate-pointer hint for a retired backend value, or null when the value is
24
+ * not a retired backend (callers then use the generic unknown-backend wording).
25
+ *
26
+ * @param {string} value
27
+ * @returns {string|null}
28
+ */
29
+ function removedBackendHint(value) {
30
+ const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
31
+ return REMOVED_BACKENDS.has(normalized) ? BEADS_REMOVED_HINT : null;
32
+ }
33
+
10
34
  /**
11
35
  * Read the `issueBackend` key from `<projectRoot>/.forge/config.yaml`, if the
12
36
  * file exists and is parseable. Returns `null` when the file is missing, the
@@ -72,14 +96,17 @@ function collectBackendSignal({ deps = {}, env = process.env, projectRoot } = {}
72
96
  * explicit deps.issueBackend > FORGE_ISSUE_BACKEND env > .forge/config.yaml > 'kernel'.
73
97
  *
74
98
  * An unknown value (from any source) falls back to the default backend and emits
75
- * a warning via the injected `warn` callback (defaults to console.warn).
99
+ * a warning via the injected `warn` callback (defaults to console.warn). A RETIRED
100
+ * value (`beads`) takes the same fallback path but warns with the migrate pointer,
101
+ * because "unknown backend, valid backends: kernel" would not tell a user carrying
102
+ * `issueBackend: beads` in config what to actually do about it.
76
103
  *
77
104
  * @param {object} [options]
78
105
  * @param {object} [options.deps]
79
106
  * @param {object} [options.env]
80
107
  * @param {string} [options.projectRoot]
81
108
  * @param {function(string): void} [options.warn]
82
- * @returns {'kernel'|'beads'}
109
+ * @returns {'kernel'}
83
110
  */
84
111
  function resolveIssueBackend({
85
112
  deps = {},
@@ -98,6 +125,15 @@ function resolveIssueBackend({
98
125
  return normalized;
99
126
  }
100
127
 
128
+ const removedHint = removedBackendHint(normalized);
129
+ if (removedHint) {
130
+ warn(
131
+ `Issue backend "${value}" from ${source} is no longer available: ${removedHint}. `
132
+ + `Falling back to "${DEFAULT_BACKEND}".`,
133
+ );
134
+ return DEFAULT_BACKEND;
135
+ }
136
+
101
137
  warn(
102
138
  `Unknown issue backend "${value}" from ${source}; `
103
139
  + `falling back to "${DEFAULT_BACKEND}". Valid backends: ${[...VALID_BACKENDS].join(', ')}.`,
@@ -139,7 +175,10 @@ module.exports = {
139
175
  hasExplicitBackendSignal,
140
176
  shouldUseKernelBroker,
141
177
  readConfigBackend,
178
+ removedBackendHint,
142
179
  VALID_BACKENDS,
180
+ REMOVED_BACKENDS,
181
+ BEADS_REMOVED_HINT,
143
182
  DEFAULT_BACKEND,
144
183
  ENV_VAR,
145
184
  };
@@ -171,7 +171,12 @@ function findExistingLink(driver, { worktreePath, branch }) {
171
171
  }
172
172
  if (typeof driver.listWorktrees === 'function') {
173
173
  const rows = driver.listWorktrees() || [];
174
- const match = rows.find(row => row && row.branch === branch && row.issue_id);
174
+ // Match ACTIVE (live) rows only: a superseded/stale registration for a
175
+ // reused branch name must not be treated as the existing link (be18881c —
176
+ // the third resolver, kept consistent with resolveActiveIssueId and
177
+ // currentBranchIssueFromDriver). Tolerate a null state for legacy rows.
178
+ const match = rows.find(row => row && row.branch === branch && row.issue_id
179
+ && (row.state === 'active' || row.state == null));
175
180
  if (match) return match;
176
181
  }
177
182
  } catch {
@@ -298,6 +303,7 @@ module.exports = {
298
303
  classifyBranch,
299
304
  deriveTitle,
300
305
  extractEncodedIssueId,
306
+ findExistingLink,
301
307
  matchesIgnoreGlob,
302
308
  DEFAULT_PROTECTED_BRANCHES,
303
309
  DEFAULT_IGNORE_GLOBS,
@@ -1177,6 +1177,34 @@ function createLocalBroker(options = {}) {
1177
1177
  return driver.importIssues(kernel, options, {}, config);
1178
1178
  },
1179
1179
 
1180
+ // PR reconcile-ledger read (autonomous-shepherd design §3.4). Read-only SELECT of
1181
+ // the open `pr` rows for a repo (keyed by git_common_dir); creates/migrates nothing.
1182
+ // Consumed later by prime and the reconciler to enumerate PRs under shepherd.
1183
+ async listOpenPrs(gitCommonDir, context = {}) {
1184
+ requireDriverMethod(driver, 'listOpenPrs');
1185
+ return driver.listOpenPrs(gitCommonDir, context, getConfig());
1186
+ },
1187
+
1188
+ // PR reconcile-ledger WRITE path (autonomous-shepherd design §5a). Mirror of the
1189
+ // listOpenPrs wrapper: guard the driver method, delegate with getConfig(). pr rows
1190
+ // are derived reconcile state (a direct idempotent upsert), not the guarded-event
1191
+ // issue path. upsertPr = register/refresh; updatePrVerdict = the one verdict authority
1192
+ // with freshest-head precedence enforced at the write; retirePr = merged/closed.
1193
+ async upsertPr(row, context = {}) {
1194
+ requireDriverMethod(driver, 'upsertPr');
1195
+ return driver.upsertPr(row, context, getConfig());
1196
+ },
1197
+
1198
+ async updatePrVerdict(key, patch = {}, context = {}) {
1199
+ requireDriverMethod(driver, 'updatePrVerdict');
1200
+ return driver.updatePrVerdict(key, patch, context, getConfig());
1201
+ },
1202
+
1203
+ async retirePr(key, patch = {}, context = {}) {
1204
+ requireDriverMethod(driver, 'retirePr');
1205
+ return driver.retirePr(key, patch, context, getConfig());
1206
+ },
1207
+
1180
1208
  // --- Projection-outbox read/update surface (D16) -----------------------
1181
1209
  // Additive read/update methods for projection consumers. These never touch
1182
1210
  // the append/CAS path above (runGuardedEvent / enqueueKernelProjection);
@@ -57,8 +57,8 @@ function renderDropTable(table) {
57
57
  // stays the full current schema; the named tables are filtered out of 001 so they are
58
58
  // created exactly once by their dedicated migration (both on a fresh DB and, via the
59
59
  // ledger, on an existing DB). KEEP IN SYNC with every new table-creating migration.
60
- // memories → 005
61
- const MIGRATION_ADDED_TABLES = ['memories'];
60
+ // memories → 005 ; pr → 009
61
+ const MIGRATION_ADDED_TABLES = ['memories', 'pr'];
62
62
 
63
63
  function getInitialKernelSchema() {
64
64
  const schema = getKernelSchema();
@@ -278,6 +278,32 @@ function buildMemoryFtsMigration() {
278
278
  };
279
279
  }
280
280
 
281
+ // 009: the PR reconcile ledger + verdict store (kernel_pr). Rendered from the schema.js
282
+ // table definition so the DDL never drifts from the registry — mirroring migration 005.
283
+ // A NEW authority table (NOT columns on kernel_worktrees) because a PR can outlive its
284
+ // worktree or have none at all (autonomous-shepherd design §3.1). Excluded from the 001
285
+ // initial schema (MIGRATION_ADDED_TABLES), so a fresh DB creates it exactly once here and
286
+ // an existing DB picks it up through the broker's per-migration ledger. CREATE … IF NOT
287
+ // EXISTS keeps a re-run idempotent; it is a create-table with no data backfill, so no
288
+ // BEGIN IMMEDIATE is needed (broker.js apply-loop caveat does not bite).
289
+ function buildPrLinkageMigration() {
290
+ const pr = getKernelSchema().tables.find(table => table.name === 'pr');
291
+ if (!pr) {
292
+ throw new Error('Kernel schema is missing the pr authority table');
293
+ }
294
+ return {
295
+ id: '009_kernel_pr_linkage',
296
+ apply: [
297
+ renderCreateTable(pr),
298
+ ...pr.indexes.map(prIndex => renderCreateIndex(pr, prIndex)),
299
+ ],
300
+ rollback: [
301
+ ...[...pr.indexes].reverse().map(prIndex => renderDropIndex(prIndex)),
302
+ renderDropTable(pr),
303
+ ],
304
+ };
305
+ }
306
+
281
307
  function validateKernelMigrations(migrations) {
282
308
  const ids = new Set();
283
309
  for (const migration of migrations) {
@@ -308,6 +334,7 @@ function buildKernelMigrationPlan(migrations = [
308
334
  buildIssueFidelityColumnsMigration(),
309
335
  buildWorktreeLinkageColumnsMigration(),
310
336
  buildMemoryFtsMigration(),
337
+ buildPrLinkageMigration(),
311
338
  ]) {
312
339
  validateKernelMigrations(migrations);
313
340
 
@@ -326,6 +353,7 @@ module.exports = {
326
353
  buildKernelMigrationPlan,
327
354
  buildMemoryFtsMigration,
328
355
  buildMemoryProjectionMigration,
356
+ buildPrLinkageMigration,
329
357
  buildSchemaMigration,
330
358
  buildWorktreeLinkageColumnsMigration,
331
359
  memoryFtsDdl,
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module kernel/owned-kernel
5
+ *
6
+ * Shared kernel-lifecycle helper for the pure-append event modules
7
+ * (grounding/context-events, gate-events). Both resolve a kernel driver the same
8
+ * way and carry the same close-what-you-built invariant, so it lives here once
9
+ * instead of being copied per module.
10
+ *
11
+ * The invariant: an INJECTED (shared) kernel is caller-owned and must NEVER be
12
+ * closed here — closing it would break the next operation that reuses it. A
13
+ * kernel this module BUILDS for a single short-lived read/append it MUST close —
14
+ * an unclosed SQLite handle leaks and, on Windows, locks the DB directory
15
+ * (`EBUSY` on `rmSync`, kernel issue e62e4bde).
16
+ */
17
+
18
+ const { buildMigratedKernelIssueDeps } = require('./cli-broker-factory');
19
+
20
+ /**
21
+ * Resolve the kernel driver + config. An injected (shared) kernel is returned
22
+ * untouched with `ownsKernel:false` — the caller owns its lifecycle. Otherwise a
23
+ * fresh one is built (via `deps.kernelBuilder`, a test seam over
24
+ * `buildMigratedKernelIssueDeps`, or the real builder) and tagged
25
+ * `ownsKernel:true` so {@link closeIfOwned} closes it.
26
+ */
27
+ async function resolveOwnedKernel(projectRoot, deps = {}) {
28
+ if (deps.kernelBroker && deps.kernelDriver) {
29
+ return { broker: deps.kernelBroker, driver: deps.kernelDriver, config: deps.kernelBroker.config, ownsKernel: false };
30
+ }
31
+ const build = deps.kernelBuilder || buildMigratedKernelIssueDeps;
32
+ const built = await build({ projectRoot });
33
+ return { broker: built.kernelBroker, driver: built.kernelDriver, config: built.kernelBroker.config, ownsKernel: true };
34
+ }
35
+
36
+ /** Close a kernel driver only when this module built it (never an injected one). */
37
+ function closeIfOwned(kernel) {
38
+ if (kernel && kernel.ownsKernel && kernel.driver && typeof kernel.driver.close === 'function') {
39
+ try { kernel.driver.close(); } catch { /* best-effort: closing is cleanup, never fatal */ }
40
+ }
41
+ }
42
+
43
+ module.exports = { resolveOwnedKernel, closeIfOwned };
@@ -278,6 +278,41 @@ const TABLE_LIST = deepFreeze([
278
278
  ], [
279
279
  index('idx_kernel_memories_source_agent', ['source_agent']),
280
280
  ]),
281
+ // The PR reconcile ledger + verdict store (autonomous-shepherd design §3): a
282
+ // first-class `pr` authority row links a pull request to its issue, worktree and
283
+ // journal, and records the winning verdict with its freshness discriminators
284
+ // (head_sha, verdict_source, verdict_at). A PR is the unit of ownership and can
285
+ // outlive its worktree (or have none — a hand-opened/other-harness PR), so it is a
286
+ // separate table, NOT columns on kernel_worktrees. git_common_dir keys every open PR
287
+ // to its repo so all worktrees share one reconcile view. issue_id/worktree_id are
288
+ // soft nullable links. Created by migration 009 (excluded from the 001 initial schema
289
+ // via MIGRATION_ADDED_TABLES), so a fresh DB creates it exactly once and an existing
290
+ // DB picks it up through the broker's per-migration ledger.
291
+ table('pr', 'authority', [
292
+ field('id', 'TEXT', { primaryKey: true }),
293
+ field('git_common_dir', 'TEXT', { notNull: true }),
294
+ field('repo', 'TEXT', { notNull: true }),
295
+ field('number', 'INTEGER', { notNull: true }),
296
+ field('issue_id', 'TEXT'),
297
+ field('worktree_id', 'TEXT'),
298
+ field('branch', 'TEXT'),
299
+ field('head_sha', 'TEXT'),
300
+ field('verdict', 'TEXT'),
301
+ field('verdict_source', 'TEXT'),
302
+ field('verdict_at', 'TEXT'),
303
+ field('journal_ptr', 'TEXT'),
304
+ field('state', 'TEXT', { notNull: true, default: "'open'" }),
305
+ field('registered_at', 'TEXT', { notNull: true }),
306
+ field('retired_at', 'TEXT'),
307
+ ], [
308
+ // Covering index for the reconciler's hot read `listOpenPrs` (WHERE
309
+ // git_common_dir=? AND state='open' ORDER BY repo, number): the trailing
310
+ // repo/number let SQLite satisfy both the state filter AND the ordering from
311
+ // this one index, instead of scanning every PR for the common-dir once the
312
+ // ledger retains merged/closed history. (Codex review, PR #424.)
313
+ index('idx_pr_common_dir_state_repo_number', ['git_common_dir', 'state', 'repo', 'number']),
314
+ index('idx_pr_common_dir_repo_number', ['git_common_dir', 'repo', 'number'], { unique: true }),
315
+ ]),
281
316
  ]);
282
317
 
283
318
  const KERNEL_TABLES = deepFreeze(Object.fromEntries(TABLE_LIST.map(candidate => [candidate.name, candidate])));
@@ -1113,8 +1113,41 @@ function loadWorktreeRowByPath(runtime, db, worktreePath) {
1113
1113
  return rows[0] || null;
1114
1114
  }
1115
1115
 
1116
+ // The idempotent upsert key. `forge plan` registers MULTIPLE branches from ONE
1117
+ // checkout (same absolute path), so keying by path ALONE made a second plan-first
1118
+ // feature UPDATE-in-place over the first branch's row and dead-end its ship (R1).
1119
+ // Key by (path, branch) so each branch keeps its own row; fall back to path-only
1120
+ // when no branch is supplied (worktree flows use distinct paths, so behavior there
1121
+ // is unchanged).
1122
+ function loadWorktreeRowByPathAndBranch(runtime, db, worktreePath, branch) {
1123
+ if (!worktreePath) return null;
1124
+ if (!branch) return loadWorktreeRowByPath(runtime, db, worktreePath);
1125
+ const rows = safeAll(
1126
+ runtime,
1127
+ db,
1128
+ 'SELECT * FROM kernel_worktrees WHERE path = ? AND branch = ? ORDER BY registered_at DESC LIMIT 1',
1129
+ [worktreePath, branch],
1130
+ );
1131
+ return rows[0] || null;
1132
+ }
1133
+
1134
+ // A git branch is checked out in exactly ONE worktree, so a NEW active registration
1135
+ // for a branch supersedes any prior ACTIVE row carrying that same branch under a
1136
+ // different id (be18881c): a reused/deleted-and-recreated branch must not keep a
1137
+ // stale binding to the OLD issue. Marking those rows state='superseded' lets the
1138
+ // active-only branch resolver skip them regardless of their timestamp.
1139
+ function supersedePriorBranchRegistrations(runtime, db, branch, keepId) {
1140
+ if (!branch) return;
1141
+ runParams(
1142
+ runtime,
1143
+ db,
1144
+ "UPDATE kernel_worktrees SET state = 'superseded' WHERE branch = ? AND state = 'active' AND id != ?",
1145
+ [branch, keepId || ''],
1146
+ );
1147
+ }
1148
+
1116
1149
  function upsertWorktreeRow(runtime, db, input) {
1117
- const existing = loadWorktreeRowByPath(runtime, db, input.path);
1150
+ const existing = loadWorktreeRowByPathAndBranch(runtime, db, input.path, input.branch);
1118
1151
  const row = {
1119
1152
  id: input.id || existing?.id || randomUUID(),
1120
1153
  git_common_dir: input.git_common_dir,
@@ -1142,6 +1175,9 @@ function upsertWorktreeRow(runtime, db, input) {
1142
1175
  KERNEL_WORKTREE_COLUMNS.map(column => row[column]),
1143
1176
  );
1144
1177
  }
1178
+ if (row.state === 'active') {
1179
+ supersedePriorBranchRegistrations(runtime, db, row.branch, row.id);
1180
+ }
1145
1181
  return row;
1146
1182
  }
1147
1183
 
@@ -2034,6 +2070,30 @@ function searchMemoryRowsRanked(runtime, db, query, limit) {
2034
2070
  ).map(memoryRowToEntry);
2035
2071
  }
2036
2072
 
2073
+ // Relevance-ONLY BM25 recall that exposes the raw bm25 score on each entry. The
2074
+ // per-turn auto-recall hook needs the score to apply a relevance FLOOR (inject nothing
2075
+ // when nothing clears the bar) — ordinal rank can't express "nothing was relevant".
2076
+ // Unlike searchMemoryRowsRanked, a no-match (or empty) query returns [] with NO recency
2077
+ // fallback: the whole point is to avoid surfacing recent-but-irrelevant notes. bm25()
2078
+ // returns more-negative for stronger matches, so rows come back best (lowest) first.
2079
+ function searchMemoryRowsRankedScored(runtime, db, query, limit) {
2080
+ const capped = Number.isInteger(limit) && limit > 0 ? limit : 20;
2081
+ const match = buildMemoryFtsMatch(query);
2082
+ if (!match) {
2083
+ return [];
2084
+ }
2085
+ return allParams(
2086
+ runtime,
2087
+ db,
2088
+ `SELECT m.*, bm25(kernel_memories_fts) AS __score FROM kernel_memories m
2089
+ JOIN kernel_memories_fts ON kernel_memories_fts.rowid = m.rowid
2090
+ WHERE kernel_memories_fts MATCH ?
2091
+ ORDER BY bm25(kernel_memories_fts)
2092
+ LIMIT ?`,
2093
+ [match, capped],
2094
+ ).map(row => ({ ...memoryRowToEntry(row), score: row.__score }));
2095
+ }
2096
+
2037
2097
  function closeDatabase(db) {
2038
2098
  if (db && typeof db.close === 'function') {
2039
2099
  db.close();
@@ -2145,6 +2205,124 @@ function createDriver(runtime, configuredDatabasePath) {
2145
2205
  [`${escaped}%`, limit],
2146
2206
  );
2147
2207
  },
2208
+ // Open PRs under shepherd for one repo (autonomous-shepherd design §3.4): the
2209
+ // reconciler's "open PRs in this repo" read, keyed by git_common_dir so every
2210
+ // worktree shares one view. Parameterized (git_common_dir is a filesystem path —
2211
+ // never interpolate it), covered by idx_pr_common_dir_state_repo_number. Ordered so
2212
+ // the result is deterministic. `context` is part of the broker contract but unused
2213
+ // by this direct SELECT (prefixed `_` for eslint no-unused-vars).
2214
+ async listOpenPrs(gitCommonDir, _context = {}, config = {}) {
2215
+ return allParams(
2216
+ runtime, getDatabase(config),
2217
+ "SELECT * FROM kernel_pr WHERE git_common_dir = ? AND state = 'open' ORDER BY repo ASC, number ASC",
2218
+ [gitCommonDir],
2219
+ );
2220
+ },
2221
+ // --- kernel_pr WRITE path (autonomous-shepherd design §5a). pr rows are DERIVED
2222
+ // reconcile state (reconstructable from GitHub), not audit-critical issue authority,
2223
+ // so they take a DIRECT idempotent upsert — NOT the event-sourced guarded path
2224
+ // (applyAcceptedIssueMutation). All target the physical `kernel_pr` table (matching
2225
+ // the listOpenPrs read) and are parameterized (git_common_dir/branch/head_sha are
2226
+ // externally-influenced values — never interpolate). `context` is part of the broker
2227
+ // contract but unused by these direct writes (prefixed `_` for eslint no-unused-vars).
2228
+ //
2229
+ // Register/refresh a PR row keyed by (git_common_dir, repo, number). Idempotent via
2230
+ // ON CONFLICT on the unique idx_pr_common_dir_repo_number: a re-upsert updates the
2231
+ // mutable columns and coalesces soft links (a later null never clobbers an existing
2232
+ // issue_id/worktree_id). registered_at is set on INSERT only; state defaults 'open'.
2233
+ async upsertPr(row, _context = {}, config = {}) {
2234
+ const id = row.id || randomUUID();
2235
+ const registeredAt = row.registered_at || new Date().toISOString();
2236
+ const state = row.state || 'open';
2237
+ runParams(
2238
+ runtime, getDatabase(config),
2239
+ `INSERT INTO kernel_pr
2240
+ (id, git_common_dir, repo, number, issue_id, worktree_id, branch, head_sha, journal_ptr, state, registered_at)
2241
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2242
+ ON CONFLICT(git_common_dir, repo, number) DO UPDATE SET
2243
+ head_sha = excluded.head_sha,
2244
+ branch = excluded.branch,
2245
+ issue_id = coalesce(excluded.issue_id, kernel_pr.issue_id),
2246
+ worktree_id = coalesce(excluded.worktree_id, kernel_pr.worktree_id),
2247
+ -- journal_ptr is a soft link: coalesce it (like issue_id/worktree_id) so a
2248
+ -- head-only refresh that omits journalPtr never severs the ledger↔journal
2249
+ -- link with NULL. (Codex review, PR #426.)
2250
+ journal_ptr = coalesce(excluded.journal_ptr, kernel_pr.journal_ptr),
2251
+ -- REOPEN semantics: upsertPr is only ever called for PRs GitHub reports as
2252
+ -- OPEN, so re-registering a previously retired row (a reopened PR) must flip
2253
+ -- it back to open and clear retired_at — else listOpenPrs (state='open') would
2254
+ -- keep the reopened PR invisible forever. (Codex review, PR #426.)
2255
+ state = 'open',
2256
+ retired_at = NULL,
2257
+ -- A new commit INVALIDATES the prior verdict: when the head advances to a
2258
+ -- different non-null sha, clear verdict/source/at so a verdict computed
2259
+ -- against the OLD head is never presented as fresh for the new head (and the
2260
+ -- freshest-head guard in updatePrVerdict keeps intact evidence). IS NOT is the
2261
+ -- null-safe distinctness test; a headless refresh (excluded.head_sha NULL)
2262
+ -- never clears. (Codex review, PR #426.)
2263
+ 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,
2264
+ 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,
2265
+ 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`,
2266
+ [
2267
+ id,
2268
+ row.git_common_dir,
2269
+ row.repo,
2270
+ row.number,
2271
+ row.issue_id ?? null,
2272
+ row.worktree_id ?? null,
2273
+ row.branch ?? null,
2274
+ row.head_sha ?? null,
2275
+ row.journal_ptr ?? null,
2276
+ state,
2277
+ registeredAt,
2278
+ ],
2279
+ );
2280
+ return { ok: true, id };
2281
+ },
2282
+ // The ONE verdict authority WRITE (design §1.2 rule 2) — FRESHEST-HEAD-SHA
2283
+ // PRECEDENCE enforced in the WHERE so a verdict computed against a SUPERSEDED head is
2284
+ // DISCARDED, not written (kills stale 9d35c14b at the write). A non-local (Actions
2285
+ // backstop) write lands only when its head_sha matches the row's current head (or the
2286
+ // row has none yet); a `local` verdict is computed live against the current head and
2287
+ // is always authoritative, so it bypasses the head match.
2288
+ async updatePrVerdict(key, patch = {}, _context = {}, config = {}) {
2289
+ const headSha = patch.head_sha ?? null;
2290
+ const source = patch.verdict_source ?? null;
2291
+ runParams(
2292
+ runtime, getDatabase(config),
2293
+ `UPDATE kernel_pr SET verdict = ?, verdict_source = ?, verdict_at = ?, head_sha = ?
2294
+ WHERE git_common_dir = ? AND repo = ? AND number = ?
2295
+ AND (head_sha IS NULL OR head_sha = ? OR ? = 'local')`,
2296
+ [
2297
+ patch.verdict ?? null,
2298
+ source,
2299
+ patch.verdict_at ?? null,
2300
+ headSha,
2301
+ key.git_common_dir,
2302
+ key.repo,
2303
+ key.number,
2304
+ headSha,
2305
+ source,
2306
+ ],
2307
+ );
2308
+ return { ok: true };
2309
+ },
2310
+ // Retire a PR row (merged/closed): flip state + stamp retired_at so it drops out of
2311
+ // the open-PR read while the reconcile history is retained.
2312
+ async retirePr(key, patch = {}, _context = {}, config = {}) {
2313
+ runParams(
2314
+ runtime, getDatabase(config),
2315
+ 'UPDATE kernel_pr SET state = ?, retired_at = ? WHERE git_common_dir = ? AND repo = ? AND number = ?',
2316
+ [
2317
+ patch.state ?? 'closed',
2318
+ patch.retired_at ?? new Date().toISOString(),
2319
+ key.git_common_dir,
2320
+ key.repo,
2321
+ key.number,
2322
+ ],
2323
+ );
2324
+ return { ok: true };
2325
+ },
2148
2326
  // --- Event-store primitives (Wave 2) — composed by broker.runGuardedEvent.
2149
2327
  // `context` is part of the broker contract but unused by these direct SQL
2150
2328
  // reads/writes (prefixed `_` for eslint no-unused-vars).
@@ -2277,6 +2455,14 @@ function createDriver(runtime, configuredDatabasePath) {
2277
2455
  ensureMemorySchema(database);
2278
2456
  return searchMemoryRowsRanked(runtime, database, query, limit);
2279
2457
  },
2458
+ // Relevance-only BM25 recall that also returns the raw bm25 `score` per entry, so a
2459
+ // caller can apply a relevance floor. A no-match/empty query returns [] (no recency
2460
+ // fallback). Used by the per-turn memory-recall hook.
2461
+ searchMemoriesRankedScored(query, limit, config = {}) {
2462
+ const database = getDatabase(config);
2463
+ ensureMemorySchema(database);
2464
+ return searchMemoryRowsRankedScored(runtime, database, query, limit);
2465
+ },
2280
2466
  // The newest `limit` entries (default recall with no query). `options.agents` scopes
2281
2467
  // the read to a source_agent allow-list (e.g. human `remember` notes only).
2282
2468
  recentMemories(limit, options = {}, config = {}) {
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module memory-recall
5
+ *
6
+ * Pure selection core for the per-turn memory-recall hook (the query-relevant tier-2
7
+ * that complements the recency digest pushed at SessionStart). Kept free of stdin/fs so
8
+ * it is fully testable; lib/commands/hooks.js does the I/O wiring around it.
9
+ *
10
+ * Design constraints (verified against the Claude Code hooks contract + external memory
11
+ * research, kernel issue 781f6f65):
12
+ * - UserPromptSubmit additionalContext APPENDS to history every prompt, so a per-turn
13
+ * injector must stay tiny: a hard token budget, a relevance floor, and cross-turn
14
+ * dedupe. Below the bar -> inject NOTHING (silence is safe; a wrong memory at
15
+ * authority every turn is not).
16
+ * - Anaphora guard: a trivial query ("continue", "fix it") carries no retrieval signal,
17
+ * so ranking on it is worse than silence. Require a minimum of distinct content tokens.
18
+ * - Scope is a FILTER; relevance is the RANKER (bm25). Never sort by recency here — that
19
+ * is the recency digest's job, not tier-2's.
20
+ */
21
+
22
+ // A query needs at least this many distinct content tokens to be worth ranking on.
23
+ // Below it we treat the prompt as anaphora and inject nothing.
24
+ const MIN_QUERY_TOKENS = 2;
25
+
26
+ // Default token budget for the whole tier-2 injection. Deliberately small: it rides on
27
+ // EVERY prompt, and it must never starve the always-on SessionStart digest.
28
+ const DEFAULT_TOKEN_BUDGET = 400;
29
+
30
+ // Default relevance floor for the live hook path so it never runs floor-less. bm25 is
31
+ // more-negative-is-better, so 0 keeps every token-AND FTS match: the ACTIVE relevance gate
32
+ // today is the token-AND match plus the anaphora guard, and the numeric floor is a knob to
33
+ // be tightened (made negative) once shadow-logging measurement (781f6f65 step 0) shows where
34
+ // the corpus's relevant/irrelevant boundary sits. Named + wired so the default is explicit,
35
+ // not an accidental `undefined`.
36
+ const DEFAULT_SCORE_FLOOR = 0;
37
+
38
+ // Short/function words that carry no retrieval signal. Not exhaustive — just enough to
39
+ // stop pure anaphora ("do that now", "same for it") from clearing the guard.
40
+ const STOPWORDS = new Set([
41
+ 'the', 'a', 'an', 'and', 'or', 'but', 'for', 'to', 'of', 'in', 'on', 'at', 'by', 'is',
42
+ 'it', 'this', 'that', 'these', 'those', 'do', 'did', 'now', 'then', 'same', 'again',
43
+ 'continue', 'go', 'ok', 'okay', 'yes', 'no', 'fix', 'please', 'thanks', 'with', 'as',
44
+ 'we', 'i', 'you', 'he', 'she', 'they', 'them', 'his', 'her', 'my', 'our', 'your',
45
+ ]);
46
+
47
+ // Rough token estimate: ~4 chars/token, matching lib/memory-digest.js's convention so
48
+ // the two tiers budget on the same scale.
49
+ function estimateTokens(text) {
50
+ return Math.ceil(String(text || '').length / 4);
51
+ }
52
+
53
+ /**
54
+ * Parse the JSON payload Claude Code delivers on a UserPromptSubmit hook's stdin. Never
55
+ * throws — any malformed input yields an empty prompt so the hook fails open.
56
+ *
57
+ * @param {string} raw
58
+ * @returns {{ prompt: string, sessionId: (string|null) }}
59
+ */
60
+ function parseHookInput(raw) {
61
+ try {
62
+ const parsed = JSON.parse(raw);
63
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
64
+ return { prompt: '', sessionId: null };
65
+ }
66
+ const prompt = typeof parsed.prompt === 'string' ? parsed.prompt : '';
67
+ const sessionId = typeof parsed.session_id === 'string' ? parsed.session_id : null;
68
+ return { prompt, sessionId };
69
+ } catch {
70
+ return { prompt: '', sessionId: null };
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Distinct content tokens in a query — lowercased, length >= 3, minus stopwords. The
76
+ * anaphora guard counts these; the FTS layer does its own tokenization for the actual match.
77
+ *
78
+ * @param {string} query
79
+ * @returns {string[]}
80
+ */
81
+ function meaningfulTokens(query) {
82
+ const seen = new Set();
83
+ // Unicode-aware split, matching the FTS tokenizer (/[\p{L}\p{N}]+/gu in the kernel driver)
84
+ // so non-Latin prompts (Cyrillic/CJK/accented) aren't silently stripped — otherwise the
85
+ // anaphora guard would disable recall for every non-Latin-script user.
86
+ for (const rawToken of String(query || '').toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
87
+ if (!rawToken) continue;
88
+ if (STOPWORDS.has(rawToken)) continue;
89
+ // The length>=3 filter suppresses ASCII noise ("it", "do"), but CJK words are 1-2 chars
90
+ // and any non-ASCII token is inherently content — keep those regardless of length.
91
+ if (rawToken.length < 3 && /^[a-z0-9]+$/.test(rawToken)) continue;
92
+ seen.add(rawToken);
93
+ }
94
+ return [...seen];
95
+ }
96
+
97
+ /**
98
+ * Choose which memories to inject this turn. PURE.
99
+ *
100
+ * @param {object} args
101
+ * @param {string} args.query — the submitted prompt
102
+ * @param {Array<{key:string, value:string, score:number}>} args.hits — bm25-ordered
103
+ * (best/lowest score first), already relevance-only (token-AND matched)
104
+ * @param {number} [args.scoreFloor] — keep only hits with score <= floor (more negative =
105
+ * stronger). Omit/null to rely on the FTS match alone. The VALUE is corpus-dependent and
106
+ * should be tuned from shadow-logging measurement, not guessed — this is the knob.
107
+ * @param {number} [args.tokenBudget]
108
+ * @param {string[]} [args.excludeKeys] — keys injected on recent turns (cross-turn dedupe)
109
+ * @returns {{ lines: string[], injectedKeys: string[] }}
110
+ */
111
+ function selectInjection({ query, hits, scoreFloor = null, tokenBudget = DEFAULT_TOKEN_BUDGET, excludeKeys = [] }) {
112
+ // Anaphora guard: a query with too little signal ranks garbage — stay silent.
113
+ if (meaningfulTokens(query).length < MIN_QUERY_TOKENS) {
114
+ return { lines: [], injectedKeys: [] };
115
+ }
116
+
117
+ const exclude = new Set(excludeKeys || []);
118
+ const lines = [];
119
+ const injectedKeys = [];
120
+ let spent = 0;
121
+
122
+ for (const hit of hits || []) {
123
+ if (!hit || typeof hit.key !== 'string') continue;
124
+ if (exclude.has(hit.key)) continue;
125
+ // Relevance floor: below the bar contributes nothing. bm25 is more-negative-is-better.
126
+ if (typeof scoreFloor === 'number' && !(typeof hit.score === 'number' && hit.score <= scoreFloor)) {
127
+ continue;
128
+ }
129
+ const body = String(hit.value == null ? '' : hit.value);
130
+ const cost = estimateTokens(body);
131
+ if (spent + cost > tokenBudget) {
132
+ // Budget exhausted; stop rather than skip-and-continue so the strongest fit.
133
+ break;
134
+ }
135
+ lines.push(body);
136
+ injectedKeys.push(hit.key);
137
+ spent += cost;
138
+ }
139
+
140
+ return { lines, injectedKeys };
141
+ }
142
+
143
+ module.exports = {
144
+ MIN_QUERY_TOKENS,
145
+ DEFAULT_TOKEN_BUDGET,
146
+ DEFAULT_SCORE_FLOOR,
147
+ estimateTokens,
148
+ parseHookInput,
149
+ meaningfulTokens,
150
+ selectInjection,
151
+ };