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
@@ -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
  };
@@ -39,7 +39,14 @@ const DEFAULT_IGNORE_GLOBS = Object.freeze(['tmp/*', 'spike/*', 'wip/*', 'throwa
39
39
  const AUTO_STUB_LABEL = 'auto-stub';
40
40
  // Branch prefixes stripped before deriving a title / probing for an encoded issue id.
41
41
  const BRANCH_PREFIX = /^(feat|feature|fix|bugfix|hotfix|chore|refactor|docs|test|spike|wip)\//i;
42
- // An issue key encoded in a branch slug, e.g. `feat/kap-7-foo` -> `kap-7`.
42
+ // A full Kernel issue id (UUID) named anywhere in the branch, e.g.
43
+ // `feat/18f1988e-...-close-on-merge`. Kernel ids ARE UUIDs, so this is the shape a
44
+ // real branch->issue reference actually takes; kept identical to the UUID_RE that
45
+ // resolveActiveIssueId (lib/workflow/enforce-stage.js) matches, so every resolver
46
+ // agrees on which issue a branch names.
47
+ const ENCODED_UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
48
+ // Legacy Beads-era issue key encoded in a branch slug, e.g. `feat/kap-7-foo` -> `kap-7`.
49
+ // Still honored for stores imported via `forge migrate --from beads`.
43
50
  const ENCODED_ISSUE_ID = /^([a-z][a-z0-9]*-\d+)\b/i;
44
51
  // Regex metacharacters escaped when compiling an ignore glob.
45
52
  const GLOB_METACHARS = '.+^${}()|[]\\';
@@ -145,12 +152,17 @@ function deriveTitle(branch) {
145
152
  }
146
153
 
147
154
  /**
148
- * Extract an issue id encoded in the branch name (e.g. `feat/kap-7-foo` -> `kap-7`).
155
+ * Extract an issue id named in the branch: a Kernel UUID anywhere in the name
156
+ * (`feat/18f1988e-...-foo`), else a legacy Beads key at the head of the slug
157
+ * (`feat/kap-7-foo` -> `kap-7`). The caller VERIFIES the id exists before linking,
158
+ * so a branch that merely looks like an id never binds to a phantom issue.
149
159
  *
150
160
  * @param {string} branch
151
161
  * @returns {string|null}
152
162
  */
153
163
  function extractEncodedIssueId(branch) {
164
+ const uuid = ENCODED_UUID.exec(String(branch));
165
+ if (uuid) return uuid[0];
154
166
  const match = ENCODED_ISSUE_ID.exec(stripPrefix(branch));
155
167
  return match ? match[1] : null;
156
168
  }
@@ -782,6 +782,12 @@ async function buildIssueMutationEvent(driver, operation, args, context, config)
782
782
  const payload = operation === 'comment'
783
783
  ? buildCommentPayload(issueId, args)
784
784
  : buildUpdatePayload(flags);
785
+ // `close` already means the terminal `done` transition. Treat an explicit
786
+ // `--status done` as that same default close intent so it does not get checked
787
+ // as the generic (and illegal) open -> done update transition below.
788
+ if (operation === 'close' && payload.status === 'done') {
789
+ delete payload.status;
790
+ }
785
791
  // update/close validate only the supplied taxonomy fields (no mandatory title);
786
792
  // comment carries no taxonomy fields, so it is exempt.
787
793
  if (operation !== 'comment') {
@@ -1177,6 +1183,44 @@ function createLocalBroker(options = {}) {
1177
1183
  return driver.importIssues(kernel, options, {}, config);
1178
1184
  },
1179
1185
 
1186
+ // Bulk activity read for `forge insights` (Slice C2). Read-only SELECT over
1187
+ // kernel_events, newest first, bounded by an optional `since` ISO cutoff + `limit`.
1188
+ // Additive: deliberately NOT registered in GUARDED_DRIVER_METHODS (the write-path
1189
+ // contract), so existing driver stubs that never call it stay valid. Imported beads
1190
+ // interactions live here as `beads.interaction.<kind>` events. Creates/migrates nothing.
1191
+ async listRecentEvents(options = {}, context = {}) {
1192
+ requireDriverMethod(driver, 'listRecentKernelEvents');
1193
+ return driver.listRecentKernelEvents(options, context, getConfig());
1194
+ },
1195
+
1196
+ // PR reconcile-ledger read (autonomous-shepherd design §3.4). Read-only SELECT of
1197
+ // the open `pr` rows for a repo (keyed by git_common_dir); creates/migrates nothing.
1198
+ // Consumed later by prime and the reconciler to enumerate PRs under shepherd.
1199
+ async listOpenPrs(gitCommonDir, context = {}) {
1200
+ requireDriverMethod(driver, 'listOpenPrs');
1201
+ return driver.listOpenPrs(gitCommonDir, context, getConfig());
1202
+ },
1203
+
1204
+ // PR reconcile-ledger WRITE path (autonomous-shepherd design §5a). Mirror of the
1205
+ // listOpenPrs wrapper: guard the driver method, delegate with getConfig(). pr rows
1206
+ // are derived reconcile state (a direct idempotent upsert), not the guarded-event
1207
+ // issue path. upsertPr = register/refresh; updatePrVerdict = the one verdict authority
1208
+ // with freshest-head precedence enforced at the write; retirePr = merged/closed.
1209
+ async upsertPr(row, context = {}) {
1210
+ requireDriverMethod(driver, 'upsertPr');
1211
+ return driver.upsertPr(row, context, getConfig());
1212
+ },
1213
+
1214
+ async updatePrVerdict(key, patch = {}, context = {}) {
1215
+ requireDriverMethod(driver, 'updatePrVerdict');
1216
+ return driver.updatePrVerdict(key, patch, context, getConfig());
1217
+ },
1218
+
1219
+ async retirePr(key, patch = {}, context = {}) {
1220
+ requireDriverMethod(driver, 'retirePr');
1221
+ return driver.retirePr(key, patch, context, getConfig());
1222
+ },
1223
+
1180
1224
  // --- Projection-outbox read/update surface (D16) -----------------------
1181
1225
  // Additive read/update methods for projection consumers. These never touch
1182
1226
  // the append/CAS path above (runGuardedEvent / enqueueKernelProjection);
@@ -113,7 +113,18 @@ async function buildMigratedKernelIssueDeps(options = {}) {
113
113
  databasePath: deps.kernelDatabasePath,
114
114
  driver: deps.kernelDriver,
115
115
  });
116
- await ensureKernelMigrated(broker);
116
+ const close = async () => {
117
+ if (typeof deps.kernelDriver.close === 'function') {
118
+ await deps.kernelDriver.close();
119
+ }
120
+ };
121
+ broker.close = close;
122
+ try {
123
+ await ensureKernelMigrated(broker);
124
+ } catch (error) {
125
+ try { await close(); } catch { /* preserve the migration failure */ }
126
+ throw error;
127
+ }
117
128
  return {
118
129
  useKernelBroker: true,
119
130
  kernelBroker: broker,
@@ -0,0 +1,154 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Close-on-merge linkage (kernel issue 18f1988e).
5
+ *
6
+ * The branch->issue linkage backbone shipped (kernel_worktrees rows written by
7
+ * `forge worktree create` and the auto-file rail), but nothing ever consumed it:
8
+ * a merged PR closed no kernel issue, so finished work stayed open forever. A
9
+ * hygiene sweep on 2026-07-25 closed 104 such issues, 61 of them auto-stubs
10
+ * minted on branch push whose merged PR closed nothing.
11
+ *
12
+ * This module is the consumer. Given a branch whose PR is known to be MERGED, it
13
+ * comments the merge evidence onto the linked kernel issue and closes it.
14
+ *
15
+ * Safety properties (each covered by test/kernel/close-on-merge.test.js):
16
+ * - LINKED ONLY. The issue comes from the kernel_worktrees linkage registry via
17
+ * resolveActiveIssueId — the same authoritative resolver `forge ship` stage
18
+ * state uses. An issue is never guessed from the branch/PR title, so a merge
19
+ * can only ever close the issue the branch was actually bound to.
20
+ * - IDEMPOTENT. The issue's status is read back first; a terminal issue
21
+ * (`done` / `cancelled` — the kernel's real terminal vocabulary) is a pure
22
+ * no-op that writes neither a comment nor a close. Running clean twice
23
+ * closes once.
24
+ * - NEVER CLOSES BLIND. An unreadable status is a skip, not an assumption.
25
+ * - BEST EFFORT. Every failure path — no kernel, a throwing driver, a rejected
26
+ * mutation — resolves to `{ closed: false, reason }`. Nothing throws, so
27
+ * `forge clean` still removes worktrees when tracking is broken.
28
+ *
29
+ * Writes go through the supported `runIssueOperation` seam (lib/forge-issues.js),
30
+ * the same path `forge inbox ack` uses, so kernel events/provenance are recorded
31
+ * exactly as a hand-run `forge comment` / `forge close` would be.
32
+ *
33
+ * @module kernel/close-on-merge
34
+ */
35
+
36
+ const { resolveActiveIssueId } = require('../workflow/enforce-stage');
37
+ const { isWorkableStatus } = require('./readiness-model');
38
+
39
+ /**
40
+ * Render the merge-evidence comment posted onto the issue before it is closed.
41
+ * Stays honest when the PR could not be resolved: `forge clean` also detects a
42
+ * merge by git patch-equivalence, with no GitHub data to cite.
43
+ *
44
+ * @param {object} params
45
+ * @param {string} params.branch - Merged branch name
46
+ * @param {{number: number, title?: string, mergeCommitOid?: string}|null} [params.pr]
47
+ * @returns {string} Comment body
48
+ */
49
+ function buildMergeEvidence({ branch, pr }) {
50
+ const lines = ['Closed automatically: the branch backing this issue was merged.', '', `branch: ${branch}`];
51
+ if (pr && pr.number) {
52
+ lines.push(`pull request: #${pr.number}${pr.title ? ` — ${pr.title}` : ''}`);
53
+ if (pr.mergeCommitOid) lines.push(`merge commit: ${pr.mergeCommitOid}`);
54
+ } else {
55
+ lines.push('pull request: no pull request resolved (merge detected from git history)');
56
+ }
57
+ return lines.join('\n');
58
+ }
59
+
60
+ /**
61
+ * Render the `--reason` recorded on the close event.
62
+ *
63
+ * @param {object} params
64
+ * @param {string} params.branch
65
+ * @param {{number: number}|null} [params.pr]
66
+ * @returns {string}
67
+ */
68
+ function buildCloseReason({ branch, pr }) {
69
+ return pr && pr.number ? `merged in PR #${pr.number}` : `branch ${branch} merged`;
70
+ }
71
+
72
+ /**
73
+ * Read an issue's current status through the supported read path.
74
+ *
75
+ * @param {Function} runIssueOperation
76
+ * @param {string} issueId
77
+ * @param {string} projectRoot
78
+ * @returns {Promise<string|null>} the status, or null when it could not be read.
79
+ */
80
+ async function readIssueStatus(runIssueOperation, issueId, projectRoot) {
81
+ const result = await runIssueOperation('show', [issueId, '--json'], projectRoot);
82
+ if (!result || result.ok !== true || !result.data) return null;
83
+ const status = result.data.status;
84
+ return typeof status === 'string' && status ? status : null;
85
+ }
86
+
87
+ /** @returns {boolean} whether a mutation envelope reports success. */
88
+ function mutationSucceeded(result) {
89
+ return Boolean(result && (result.ok === true || result.success === true));
90
+ }
91
+
92
+ /**
93
+ * Comment the merge evidence onto the linked kernel issue and close it.
94
+ * See the module doc for the full contract. NEVER throws.
95
+ *
96
+ * @param {object} options
97
+ * @param {string} options.branch - The merged branch.
98
+ * @param {string} [options.projectRoot] - Repo root threaded to the issue runner.
99
+ * @param {{number: number, title?: string, mergeCommitOid?: string}|null} [options.pr]
100
+ * Merge evidence from GitHub, when available.
101
+ * @param {object} options.driver - Kernel driver exposing the worktree linkage registry.
102
+ * @param {Function} options.runIssueOperation - (operation, args, projectRoot) => envelope.
103
+ * @param {Function} [options.resolveIssueId] - Override the branch->issue resolver (tests).
104
+ * @returns {Promise<{closed: boolean, issueId?: string|null, reason?: string,
105
+ * commented?: boolean, status?: string, error?: string}>}
106
+ */
107
+ async function closeLinkedIssueOnMerge(options = {}) {
108
+ const { branch, projectRoot, pr = null, driver, runIssueOperation } = options;
109
+ try {
110
+ if (!branch || typeof branch !== 'string') return { closed: false, reason: 'no-branch' };
111
+ if (!driver || typeof runIssueOperation !== 'function') {
112
+ return { closed: false, reason: 'unavailable' };
113
+ }
114
+
115
+ // Linked only — never a title/heuristic match.
116
+ const resolveIssueId = options.resolveIssueId || resolveActiveIssueId;
117
+ const issueId = await resolveIssueId(driver, branch);
118
+ if (!issueId) return { closed: false, reason: 'not-linked' };
119
+
120
+ // Idempotency + never-close-blind: read the current status first.
121
+ const status = await readIssueStatus(runIssueOperation, issueId, projectRoot);
122
+ if (!status) return { closed: false, reason: 'read-failed', issueId };
123
+ if (!isWorkableStatus(status)) {
124
+ return { closed: false, reason: 'already-closed', issueId, status };
125
+ }
126
+
127
+ // Evidence first, so it survives even if the close is rejected.
128
+ const commentResult = await runIssueOperation(
129
+ 'comment',
130
+ [issueId, buildMergeEvidence({ branch, pr })],
131
+ projectRoot,
132
+ );
133
+ const commented = mutationSucceeded(commentResult);
134
+
135
+ const closeResult = await runIssueOperation(
136
+ 'close',
137
+ [issueId, '--reason', buildCloseReason({ branch, pr })],
138
+ projectRoot,
139
+ );
140
+ if (!mutationSucceeded(closeResult)) {
141
+ return { closed: false, reason: 'close-failed', issueId, commented };
142
+ }
143
+ return { closed: true, issueId, commented };
144
+ } catch (error) {
145
+ // Best-effort: cleanup must never break on tracking.
146
+ return { closed: false, reason: 'error', error: error && error.message };
147
+ }
148
+ }
149
+
150
+ module.exports = {
151
+ closeLinkedIssueOnMerge,
152
+ buildMergeEvidence,
153
+ buildCloseReason,
154
+ };
@@ -284,7 +284,7 @@ function driveLetterOf(absPath) {
284
284
 
285
285
  // Bounded exec options for the Windows drive probes. The timeout is the BLOCKER-1
286
286
  // fix: execFileSync's try/catch only catches throws, never hangs, so an
287
- // unresponsive `net use` / PowerShell (e.g. a wedged network redirector) would
287
+ // an unresponsive `net use` probe (e.g. a wedged network redirector) would
288
288
  // hang the whole process indefinitely. With a timeout, a hang becomes a throw
289
289
  // that the gatherSignals catch turns into driveType='unknown' → warn (never
290
290
  // refuse), symmetric with the G1 fail-safe. killSignal forces the child dead.
@@ -310,18 +310,21 @@ function parseNetUseDriveType(stdout, driveLetter) {
310
310
  return null;
311
311
  }
312
312
 
313
- // PURE (M5): a non-empty PowerShell DisplayRoot means the PSDrive is backed by a
314
- // network/remote root. Returns 'network' when non-blank, else null.
315
- function parseDisplayRoot(stdout) {
316
- return String(stdout || '').trim() ? 'network' : null;
317
- }
318
-
319
313
  // Windows: detect whether a drive letter is a mapped network drive.
320
- // FAIL-SAFE FALLBACK (locked decision G1, option a): try `net use`, then a
321
- // PowerShell DisplayRoot fallback; on ANY probe failure (including a timeout-
322
- // induced throw, per BLOCKER 1) the caller treats the result as 'unknown'.
323
- // Probes use arg ARRAYS (no shell) so they remain injection-free.
314
+ // The conventional local C: drive is fixed without a subprocess. `net use` is
315
+ // authoritative for other mapped drive letters, while a no-match remains
316
+ // unknown because it does not prove that an SMB/cloud redirector is absent. The unknown
317
+ // result is fail-open (warn), while UNC and cloud path signals still refuse.
318
+ // Do not launch a second shell process for every kernel child. Under a heavily
319
+ // sharded suite those redundant fallback probes contend for host resources and
320
+ // can leave a shard blocked indefinitely.
321
+ // A probe failure still propagates to the gatherer, which maps it to the
322
+ // fail-safe `unknown`/warn classification.
323
+ // The probe uses an arg ARRAY (no shell) so it remains injection-free.
324
324
  function defaultProbeDriveType(driveLetter, deps = {}) {
325
+ const normalizedDriveLetter = String(driveLetter || '').trim().toUpperCase();
326
+ if (normalizedDriveLetter === 'C:') return 'fixed';
327
+
325
328
  const exec = deps.execFileSync || execFileSync;
326
329
 
327
330
  // Primary: `net use` lists mapped drive letters (and their remote paths).
@@ -330,17 +333,9 @@ function defaultProbeDriveType(driveLetter, deps = {}) {
330
333
  return 'network';
331
334
  }
332
335
 
333
- // Secondary: PowerShell DisplayRoot — non-empty for network-backed PSDrives.
334
- const psLetter = String(driveLetter).replace(':', '');
335
- const psCmd =
336
- `(Get-PSDrive -Name '${psLetter}' -ErrorAction SilentlyContinue).DisplayRoot`;
337
- const psOut = exec('powershell', ['-NoProfile', '-Command', psCmd], WIN_PROBE_EXEC_OPTIONS);
338
- if (parseDisplayRoot(psOut) === 'network') {
339
- return 'network';
340
- }
341
-
342
- // Letter exists, no network backing detected → fixed.
343
- return 'fixed';
336
+ // `net use` completed successfully without listing this non-system letter.
337
+ // A no-match is not evidence that an SMB/cloud redirector is absent.
338
+ return 'unknown';
344
339
  }
345
340
 
346
341
  // Linux: read /proc/mounts, return fs type of the longest mountpoint prefixing absPath.
@@ -443,27 +438,49 @@ function isUnsafeFsOverrideActive(env = {}) {
443
438
  return value === '1' || value === 'true';
444
439
  }
445
440
 
441
+ // The gate runs on every broker instance, and one command builds several
442
+ // (`forge status` and `forge prime` each build four), so an un-memoized advisory
443
+ // printed four times per command. The advisory describes the process's database
444
+ // path, not the broker, so it is emitted once per (tier, class, path) per
445
+ // process. Keyed rather than a single flag so a second path — or a path that
446
+ // reclassifies — is still reported. Warnings only: `refuse` still throws on
447
+ // every call, so deduping can never turn a refusal into a pass.
448
+ const warnedFilesystemKeys = new Set();
449
+
450
+ function resetFilesystemWarningMemo() {
451
+ warnedFilesystemKeys.clear();
452
+ }
453
+
446
454
  function assertFilesystemSafeForKernel(dbPath, deps = {}) {
447
455
  const env = deps.env || process.env;
456
+ // console.warn writes to stderr, which is the contract here: `--json`
457
+ // consumers parse stdout, so an advisory on stdout would corrupt the envelope.
448
458
  const warn = deps.warn || (msg => console.warn(msg));
449
459
  const classify = deps.classifyFilesystem || classifyFilesystem;
450
460
 
451
461
  const classification = classify(dbPath, deps);
452
462
  const remediation = REMEDIATION[classification.remediationKey] || REMEDIATION.unknown;
453
463
 
464
+ const warnOnce = message => {
465
+ const key = `${classification.riskTier}|${classification.class}|${dbPath}`;
466
+ if (warnedFilesystemKeys.has(key)) return;
467
+ warnedFilesystemKeys.add(key);
468
+ warn(message);
469
+ };
470
+
454
471
  if (classification.riskTier === 'safe') {
455
472
  return classification;
456
473
  }
457
474
 
458
475
  if (classification.riskTier === 'warn') {
459
- warn(`forge kernel filesystem warning (${classification.class}):\n${remediation}`);
476
+ warnOnce(`forge kernel filesystem warning (${classification.class}):\n${remediation}`);
460
477
  return classification;
461
478
  }
462
479
 
463
480
  // refuse
464
481
  const overrideActive = isUnsafeFsOverrideActive(env);
465
482
  if (overrideActive) {
466
- warn(
483
+ warnOnce(
467
484
  `forge kernel filesystem REFUSE downgraded by FORGE_KERNEL_ALLOW_UNSAFE_FS ` +
468
485
  `(${classification.class}) — override active:\n${remediation}`,
469
486
  );
@@ -487,9 +504,9 @@ module.exports = {
487
504
  gatherSignals,
488
505
  classifyFilesystem,
489
506
  assertFilesystemSafeForKernel,
507
+ resetFilesystemWarningMemo,
490
508
  isUnsafeFsOverrideActive,
491
509
  defaultProbeDriveType,
492
510
  parseNetUseDriveType,
493
- parseDisplayRoot,
494
511
  REMEDIATION,
495
512
  };
@@ -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])));