forge-workflow 0.1.0-beta.3 → 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 (122) hide show
  1. package/AGENTS.md +1 -1
  2. package/bin/forge-cmd.js +1 -1
  3. package/bin/forge.js +5 -0
  4. package/docs/reference/COMMANDS.md +3 -1
  5. package/docs/reference/shepherd.md +72 -1
  6. package/lib/adapters/greptile-review-adapter.js +1 -1
  7. package/lib/adapters/pr-state-adapter.js +103 -8
  8. package/lib/agents-config.js +5 -0
  9. package/lib/commands/_issue.js +31 -46
  10. package/lib/commands/_manifest.js +1 -1
  11. package/lib/commands/_resolve-command-opts.js +36 -29
  12. package/lib/commands/claim.js +2 -4
  13. package/lib/commands/hooks.js +155 -8
  14. package/lib/commands/plan.js +23 -115
  15. package/lib/commands/prime.js +8 -1
  16. package/lib/commands/release.js +1 -2
  17. package/lib/commands/serve.js +5 -2
  18. package/lib/commands/setup.js +0 -1
  19. package/lib/commands/shepherd.js +36 -3
  20. package/lib/commands/skill.js +275 -0
  21. package/lib/commands/status.js +37 -32
  22. package/lib/commands/test.js +32 -0
  23. package/lib/commands/worktree.js +27 -1
  24. package/lib/core/runtime-graph.js +88 -14
  25. package/lib/forge-issues.js +13 -464
  26. package/lib/harness-capability-matrix.js +2 -2
  27. package/lib/hook-renderer.js +54 -12
  28. package/lib/issue-backend.js +42 -3
  29. package/lib/kernel/broker.js +28 -0
  30. package/lib/kernel/migrations.js +30 -2
  31. package/lib/kernel/schema.js +35 -0
  32. package/lib/kernel/sqlite-driver.js +150 -0
  33. package/lib/memory-recall.js +151 -0
  34. package/lib/orientation.js +303 -6
  35. package/lib/pr-bundle.js +6 -2
  36. package/lib/pr-monitor/reconcile-executor.js +659 -0
  37. package/lib/pr-monitor/reconcile-tick.js +138 -0
  38. package/lib/pr-monitor/reconcile.js +0 -0
  39. package/lib/pr-monitor/render-sticky.js +14 -0
  40. package/lib/pr-monitor/shepherd-lease.js +243 -0
  41. package/lib/pr-monitor/upsert-sticky.js +1 -1
  42. package/lib/pr-monitor/watch-lifecycle.js +1 -1
  43. package/lib/pr-pull.js +70 -15
  44. package/lib/project-memory.js +8 -0
  45. package/lib/rules-sync.js +4 -0
  46. package/lib/runtime-health.js +15 -46
  47. package/lib/skill-eval.js +750 -0
  48. package/lib/status/identity.js +46 -0
  49. package/lib/status/presenter.js +0 -35
  50. package/lib/status/snapshot.js +11 -16
  51. package/lib/upgrade-safety.js +8 -9
  52. package/lib/using-forge.js +315 -0
  53. package/lib/workflow/enforce-stage.js +5 -5
  54. package/lib/workflow/state-manager.js +23 -23
  55. package/package.json +1 -1
  56. package/rules/using-forge.md +24 -0
  57. package/scripts/forge-team/index.sh +0 -5
  58. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  59. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  60. package/scripts/test.js +8 -1
  61. package/skills/claim-safety/SKILL.md +4 -0
  62. package/skills/claim-safety/evals/scorecard.json +41 -0
  63. package/skills/coverage.json +83 -0
  64. package/skills/dev/SKILL.md +4 -0
  65. package/skills/dev/evals/scorecard.json +41 -0
  66. package/skills/gates/SKILL.md +80 -0
  67. package/skills/gates/evals/evals.json +38 -0
  68. package/skills/gates/evals/scorecard.json +41 -0
  69. package/skills/hermes-forge/SKILL.md +1 -0
  70. package/skills/hermes-forge/evals/scorecard.json +41 -0
  71. package/skills/issue-basics/SKILL.md +1 -0
  72. package/skills/issue-basics/evals/scorecard.json +41 -0
  73. package/skills/kernel/SKILL.md +38 -0
  74. package/skills/kernel/evals/scorecard.json +41 -0
  75. package/skills/memory/SKILL.md +16 -1
  76. package/skills/memory/evals/scorecard.json +41 -0
  77. package/skills/parallel-deep-research/SKILL.md +1 -0
  78. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  79. package/skills/plan/SKILL.md +6 -0
  80. package/skills/plan/evals/scorecard.json +41 -0
  81. package/skills/portability/SKILL.md +47 -0
  82. package/skills/portability/evals/evals.json +34 -0
  83. package/skills/portability/evals/scorecard.json +41 -0
  84. package/skills/research/SKILL.md +1 -0
  85. package/skills/research/evals/scorecard.json +41 -0
  86. package/skills/review/SKILL.md +6 -0
  87. package/skills/review/evals/scorecard.json +41 -0
  88. package/skills/rollback/SKILL.md +1 -0
  89. package/skills/rollback/evals/scorecard.json +41 -0
  90. package/skills/setup/SKILL.md +91 -0
  91. package/skills/setup/evals/evals.json +42 -0
  92. package/skills/setup/evals/scorecard.json +41 -0
  93. package/skills/shepherd/SKILL.md +76 -36
  94. package/skills/shepherd/evals/evals.json +21 -9
  95. package/skills/shepherd/evals/scorecard.json +41 -0
  96. package/skills/ship/SKILL.md +6 -0
  97. package/skills/ship/evals/scorecard.json +41 -0
  98. package/skills/smith/SKILL.md +8 -0
  99. package/skills/smith/evals/scorecard.json +41 -0
  100. package/skills/sonarcloud/SKILL.md +1 -0
  101. package/skills/sonarcloud/evals/scorecard.json +41 -0
  102. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  103. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  104. package/skills/status/SKILL.md +3 -0
  105. package/skills/status/evals/scorecard.json +41 -0
  106. package/skills/triage-ready/SKILL.md +2 -0
  107. package/skills/triage-ready/evals/scorecard.json +41 -0
  108. package/skills/using-forge/SKILL.md +104 -0
  109. package/skills/using-forge/evals/scorecard.json +41 -0
  110. package/skills/validate/SKILL.md +4 -0
  111. package/skills/validate/evals/scorecard.json +41 -0
  112. package/skills/verify/SKILL.md +4 -0
  113. package/skills/verify/evals/scorecard.json +41 -0
  114. package/skills/worktree/SKILL.md +87 -0
  115. package/skills/worktree/evals/evals.json +38 -0
  116. package/skills/worktree/evals/scorecard.json +41 -0
  117. package/lib/adapters/beads-issue-adapter.js +0 -127
  118. package/lib/beads-nudge.js +0 -91
  119. package/lib/commands/board.js +0 -64
  120. package/lib/status/beads-snapshot.js +0 -145
  121. package/scripts/forge-team/lib/dashboard.sh +0 -316
  122. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
@@ -33,7 +33,7 @@ const SOURCE_ROWS = [
33
33
  ['S4', 'Cursor MCP', 'https://docs.cursor.com/context/model-context-protocol', 'Cursor connects external tools and data sources through MCP using stdio, SSE, and streamable HTTP transports.'],
34
34
  ['S5', 'Codex skills', 'https://developers.openai.com/codex/skills', 'Codex skills package instructions, resources, and scripts; Codex scans .agents/skills from cwd up to the repo root for repo-scope discovery. Forge installs the global $CODEX_HOME/skills from the canonical skills/ source AND commits the repo-local .agents/skills mirror (kept in sync with skills/ by a pre-commit hook + drift gate) for teammate-clone discovery.'],
35
35
  ['S6', 'Codex MCP', 'https://developers.openai.com/codex/mcp', 'Codex configures MCP servers in config.toml and supports plugin-provided MCP servers.'],
36
- ['S7', 'Codex hooks', 'https://developers.openai.com/codex/hooks', 'Codex hooks run deterministic scripts during lifecycle events.'],
36
+ ['S7', 'Codex hooks', 'https://developers.openai.com/codex/hooks', 'Codex hooks run deterministic scripts during lifecycle events; they live in GLOBAL $CODEX_HOME/config.toml so project setup does not render them. Codex instead reads the committed project-local AGENTS.md every session, which is the always-on carrier for Forge instruction/policy including the using-forge skill-dispatch pointer (see S9).'],
37
37
  ['S8', 'Codex plugins and marketplaces', 'https://developers.openai.com/codex/plugins/build', 'Codex plugins can package skills, apps, MCP servers, hooks, and marketplace metadata.'],
38
38
  ['S9', 'AGENTS.md standard', 'https://agents.md/', 'AGENTS.md provides repository instructions for coding agents.'],
39
39
  ['S10', 'Cursor Agent Skills', 'https://docs.cursor.com/en/agent/skills', 'Cursor reads on-demand Agent Skills from .cursor/skills/<name>/SKILL.md; Forge populates them at setup from the canonical skills/ source.'],
@@ -41,7 +41,7 @@ const SOURCE_ROWS = [
41
41
  ['S12', 'Cursor ignore files', 'https://cursor.com/docs/reference/ignore-file', 'Cursor reads project-local .cursorignore (blocks AI access + indexing) and .cursorindexingignore (index-only), gitignore-style; .gitignore and common secret/lock files are ignored by default.'],
42
42
  ['S13', 'Codex sandbox and approvals config', 'https://developers.openai.com/codex/config-reference', 'Codex config.toml sets sandbox_mode (read-only/workspace-write/danger-full-access) and approval_policy (untrusted/on-request/never); a project-local .codex/config.toml is honored ONLY when the project is trusted in the GLOBAL $CODEX_HOME/config.toml (projects.<path>.trust_level).'],
43
43
  ['S14', 'Claude Code hooks', 'https://code.claude.com/docs/en/hooks', 'Claude Code hooks live in .claude/settings.json under a `hooks` block; a PreToolUse matcher group (e.g. "Write|Edit"/"Bash") runs a command that can deny the tool call via hookSpecificOutput.permissionDecision.'],
44
- ['S15', 'Cursor hooks', 'https://cursor.com/docs/hooks', 'Cursor 1.7+ reads project-local .cursor/hooks.json ({ version: 1, hooks }); only before* events (beforeShellExecution/beforeReadFile/beforeMCPExecution) can deny — there is no pre-edit deny event, so afterFileEdit is observational.'],
44
+ ['S15', 'Cursor hooks', 'https://cursor.com/docs/hooks', 'Cursor 1.7+ reads project-local .cursor/hooks.json ({ version: 1, hooks }); only before* events (beforeShellExecution/beforeReadFile/beforeMCPExecution) can deny — there is no pre-edit deny event, so afterFileEdit is observational. Cursor hooks are therefore NOT the session-context path: Cursor auto-injects always-apply .cursor/rules/*.mdc (alwaysApply:true) every session (see S3), which is the always-on carrier for the using-forge skill-dispatch pointer.'],
45
45
  ['S16', 'Hermes Agent event hooks', 'https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks/', 'Hermes (NousResearch) shell hooks are declared in a `hooks:` block in the GLOBAL ~/.hermes/config.yaml; each matcher/command entry runs as a subprocess over a JSON-stdin/stdout wire protocol, and a pre_tool_call hook CAN deny a tool call ({action:block, message} — it also accepts the Claude-Code {decision:block, reason} shape).'],
46
46
  ];
47
47
 
@@ -80,6 +80,9 @@ const FORGE_INBOX_CONTEXT_MARKER = 'hooks inbox-pickup';
80
80
  // Forge UserPromptSubmit group is already recognized via the inbox marker, but this keeps
81
81
  // the shepherd-events command independently identifiable (symmetry with the other tiers).
82
82
  const FORGE_SHEPHERD_EVENTS_MARKER = 'hooks shepherd-events';
83
+ // The memory-recall context hook (UserPromptSubmit tier). Its own idempotency marker so a
84
+ // re-render recognizes and REPLACES the Forge-owned entry in place rather than duplicating it.
85
+ const FORGE_MEMORY_RECALL_MARKER = 'hooks memory-recall';
83
86
  // The capture-on-exit context hook (PreCompact + Stop tier). A THIRD context marker so a
84
87
  // re-merge recognizes + replaces the Forge-owned PreCompact/Stop entries in place. Both
85
88
  // events share this one marker (they differ only by a --trigger suffix on the command).
@@ -91,16 +94,33 @@ const FORGE_SHEPHERD_EVENTS_MARKER = 'hooks shepherd-events';
91
94
  // the merge replaces exactly Forge's group and preserves the user's.
92
95
  const FORGE_CAPTURE_CONTEXT_MARKER = `${FORGE_CLI} hooks capture`;
93
96
 
94
- // Per-harness SessionStart context-injection capability. Honest capability matrix
95
- // only Claude exposes a native session-start surface that can inject additionalContext.
96
- // Cursor's 1.7 hooks are deny-oriented (no session-start context surface); Codex and
97
- // Hermes hooks live in GLOBAL home config that project setup never writes. We NEVER
98
- // fake parity: each non-Claude harness carries an explicit, tested skip reason.
97
+ // Per-harness SessionStart HOOK-render capability. This constant governs ONE thing: whether
98
+ // Forge renders a harness-native SessionStart *hook* that injects additionalContext. Only Claude
99
+ // exposes that surface, so only Claude is rendered:true. Every other harness is rendered:false
100
+ // with an HONEST reason naming WHERE the same dispatch bootstrap is delivered instead — because
101
+ // the bootstrap is NOT Claude-only, it just travels a different always-on surface per harness
102
+ // (see BOOTSTRAP_DELIVERY below): Cursor auto-applies an always-on rule (.cursor/rules/*.mdc,
103
+ // alwaysApply:true); Codex reads the committed project-local AGENTS.md every session; Hermes
104
+ // consumes it through the forge CLI (forge prime/orient + forge skill for). We NEVER fake parity
105
+ // and NEVER branch on harness identity at runtime — delivery is keyed by SURFACE TYPE.
99
106
  const SESSION_START_SUPPORT = Object.freeze({
100
107
  claude: Object.freeze({ rendered: true }),
101
- cursor: Object.freeze({ rendered: false, reason: 'no-session-start-surface' }),
102
- codex: Object.freeze({ rendered: false, reason: 'global-config' }),
103
- hermes: Object.freeze({ rendered: false, reason: 'global-config' }),
108
+ cursor: Object.freeze({ rendered: false, reason: 'delivered-via-rule-surface' }),
109
+ codex: Object.freeze({ rendered: false, reason: 'delivered-via-agents-md' }),
110
+ hermes: Object.freeze({ rendered: false, reason: 'delivered-via-cli-fallback' }),
111
+ });
112
+
113
+ // Where the using-forge dispatch BOOTSTRAP reaches each harness EVERY session. Data, not
114
+ // identity-branching: the parity gate iterates this so no harness is silently left with no
115
+ // auto-surface. Only Claude's is a native SessionStart hook; the rest are equally always-on
116
+ // surfaces (a Cursor always-apply rule, the committed AGENTS.md for Codex, the forge CLI as an
117
+ // honest fallback for Hermes). All four ALSO carry the full using-forge SKILL.md via the synced
118
+ // skill mirrors. 'cli-fallback' is the explicit honest-fallback marker (no faked native surface).
119
+ const BOOTSTRAP_DELIVERY = Object.freeze({
120
+ claude: 'session-start-hook',
121
+ cursor: 'always-apply-rule',
122
+ codex: 'agents-md',
123
+ hermes: 'cli-fallback',
104
124
  });
105
125
 
106
126
  // Per-harness UserPromptSubmit context-injection capability (the near-real-time inbox
@@ -179,6 +199,22 @@ const FORGE_HOOK_CONTRACT = Object.freeze({
179
199
  lifecycle: 'user-prompt-submit',
180
200
  command: `${FORGE_CLI} hooks inbox-pickup`,
181
201
  }),
202
+ Object.freeze({
203
+ id: 'memory-recall',
204
+ kind: 'context',
205
+ cliAction: 'memory-recall',
206
+ // QUERY-RELEVANT MEMORY (tier-2): on each prompt, ranks stored memories by BM25
207
+ // relevance to the submitted prompt (read from the hook's own stdin) and injects the
208
+ // survivors, gated by a relevance floor + anaphora guard + cross-turn dedupe + a hard
209
+ // token budget. Complements the recency digest at SessionStart, which answers a
210
+ // different question (what's newest, not what's relevant to THIS turn). Reads the
211
+ // user's OWN kernel memory via a supported hook and its own stdin input — it NEVER
212
+ // injects into a running session's stdin and never drives the agent (Anthropic Usage
213
+ // Policy). Kill-switch: `forge gate disable rail.memory_recall`.
214
+ enforces: 'Memory recall: on each UserPromptSubmit, inject query-relevant stored memories ranked by BM25 against the submitted prompt, gated by a relevance floor, an anaphora guard, cross-turn dedupe, and a hard token budget (compact to avoid additionalContext accumulation). Additive and FAIL-OPEN — nothing relevant, a disabled rail, or any error injects nothing and never blocks a prompt.',
215
+ lifecycle: 'user-prompt-submit',
216
+ command: `${FORGE_CLI} hooks memory-recall`,
217
+ }),
182
218
  Object.freeze({
183
219
  id: 'shepherd-events',
184
220
  kind: 'context',
@@ -295,15 +331,18 @@ function renderClaudeHooks(contract) {
295
331
  // Surfaces pending targeted dashboard instruction comments (fenced kernel DATA) on each
296
332
  // prompt; the command emits { hookSpecificOutput.additionalContext }. Reads the user's
297
333
  // own kernel data via a supported hook — NEVER stdin injection (Anthropic Usage Policy).
298
- // Both UserPromptSubmit context hooks share ONE Forge-owned group (inbox-pickup +
299
- // PR-shepherd deltas). Claude runs every hook in the group and appends each hook's
300
- // additionalContext; keeping them in one group means a re-merge replaces the pair
301
- // atomically (the group is Forge-owned via either marker). Both are compact + fail-open.
334
+ // The UserPromptSubmit context hooks share ONE Forge-owned group (inbox-pickup nudge,
335
+ // PR-shepherd deltas, and query-relevant memory recall). Claude runs every hook in the
336
+ // group and appends each hook's additionalContext; keeping them in one group means a
337
+ // re-merge replaces the set atomically (the group is Forge-owned via any of their
338
+ // markers). All three are compact + fail-open. (memory-recall reads its own hook stdin
339
+ // for the prompt — that is its supported input, not session-stdin injection.)
302
340
  UserPromptSubmit: [
303
341
  {
304
342
  hooks: [
305
343
  { type: 'command', command: harnessCommand(contract, 'inbox-pickup', 'claude') },
306
344
  { type: 'command', command: harnessCommand(contract, 'shepherd-events', 'claude') },
345
+ { type: 'command', command: harnessCommand(contract, 'memory-recall', 'claude') },
307
346
  ],
308
347
  },
309
348
  ],
@@ -398,6 +437,7 @@ function isForgeCommand(command) {
398
437
  || command.includes(FORGE_CONTEXT_MARKER)
399
438
  || command.includes(FORGE_INBOX_CONTEXT_MARKER)
400
439
  || command.includes(FORGE_SHEPHERD_EVENTS_MARKER)
440
+ || command.includes(FORGE_MEMORY_RECALL_MARKER)
401
441
  || command.includes(FORGE_CAPTURE_CONTEXT_MARKER));
402
442
  }
403
443
 
@@ -523,8 +563,10 @@ module.exports = {
523
563
  FORGE_HOOK_MARKER,
524
564
  FORGE_CONTEXT_MARKER,
525
565
  FORGE_INBOX_CONTEXT_MARKER,
566
+ FORGE_MEMORY_RECALL_MARKER,
526
567
  FORGE_CAPTURE_CONTEXT_MARKER,
527
568
  SESSION_START_SUPPORT,
569
+ BOOTSTRAP_DELIVERY,
528
570
  USER_PROMPT_SUBMIT_SUPPORT,
529
571
  SESSION_END_SUPPORT,
530
572
  sessionStartCapability,
@@ -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
  };
@@ -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,
@@ -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])));
@@ -2070,6 +2070,30 @@ function searchMemoryRowsRanked(runtime, db, query, limit) {
2070
2070
  ).map(memoryRowToEntry);
2071
2071
  }
2072
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
+
2073
2097
  function closeDatabase(db) {
2074
2098
  if (db && typeof db.close === 'function') {
2075
2099
  db.close();
@@ -2181,6 +2205,124 @@ function createDriver(runtime, configuredDatabasePath) {
2181
2205
  [`${escaped}%`, limit],
2182
2206
  );
2183
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
+ },
2184
2326
  // --- Event-store primitives (Wave 2) — composed by broker.runGuardedEvent.
2185
2327
  // `context` is part of the broker contract but unused by these direct SQL
2186
2328
  // reads/writes (prefixed `_` for eslint no-unused-vars).
@@ -2313,6 +2455,14 @@ function createDriver(runtime, configuredDatabasePath) {
2313
2455
  ensureMemorySchema(database);
2314
2456
  return searchMemoryRowsRanked(runtime, database, query, limit);
2315
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
+ },
2316
2466
  // The newest `limit` entries (default recall with no query). `options.agents` scopes
2317
2467
  // the read to a source_agent allow-list (e.g. human `remember` notes only).
2318
2468
  recentMemories(limit, options = {}, config = {}) {