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.
- package/AGENTS.md +14 -7
- package/CHANGELOG.md +43 -1
- package/README.md +6 -2
- package/bin/forge-cmd.js +21 -1
- package/bin/forge.js +16 -369
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +9 -4
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +117 -17
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +397 -100
- package/lib/agents-config.js +5 -0
- package/lib/audit-evidence.js +71 -110
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/commands/_issue.js +31 -46
- package/lib/commands/_manifest.js +1 -1
- package/lib/commands/_registry.js +2 -2
- package/lib/commands/_resolve-command-opts.js +36 -29
- package/lib/commands/claim.js +2 -4
- package/lib/commands/clean.js +196 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/hooks.js +358 -13
- package/lib/commands/insights.js +8 -3
- package/lib/commands/merge.js +600 -40
- package/lib/commands/plan.js +23 -115
- package/lib/commands/pr.js +1 -1
- package/lib/commands/preflight.js +11 -2
- package/lib/commands/prime.js +23 -3
- package/lib/commands/push.js +41 -51
- package/lib/commands/recall.js +60 -16
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +18 -4
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +191 -95
- package/lib/commands/shepherd.js +49 -4
- package/lib/commands/ship.js +22 -23
- package/lib/commands/skill.js +383 -0
- package/lib/commands/status.js +54 -33
- package/lib/commands/test.js +56 -34
- package/lib/commands/worktree.js +247 -43
- package/lib/core/runtime-graph.js +89 -15
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +64 -491
- package/lib/git-defaults.js +56 -0
- package/lib/harness-capability-matrix.js +5 -5
- package/lib/hook-renderer.js +147 -16
- package/lib/insights.js +96 -80
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +44 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +292 -18
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/router.js +16 -1
- package/lib/memory-digest.js +47 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +212 -0
- package/lib/merge-rules.js +8 -4
- package/lib/npm-publish-workflow.js +272 -0
- package/lib/orientation.js +371 -49
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +9 -6
- package/lib/pr-monitor/journal.js +18 -2
- package/lib/pr-monitor/reconcile-executor.js +842 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +196 -0
- package/lib/pr-monitor/shepherd-lease.js +252 -0
- package/lib/pr-monitor/watch-lifecycle.js +14 -2
- package/lib/pr-pull.js +98 -24
- package/lib/pr-shepherd.js +34 -8
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +40 -0
- package/lib/protected-state-authority.js +305 -0
- package/lib/protected-state-surfaces.js +64 -44
- package/lib/release-readiness.js +51 -4
- package/lib/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/shell-utils.js +1 -1
- package/lib/skill-eval.js +750 -0
- package/lib/skills-sync.js +6 -3
- package/lib/smart-merge.js +28 -4
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +47 -9
- package/lib/using-forge.js +328 -0
- package/lib/workflow/enforce-stage.js +5 -5
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +6 -7
- package/rules/using-forge.md +24 -0
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/forge-team/index.sh +0 -5
- package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
- package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +456 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +761 -0
- package/scripts/protected-state-check.js +47 -22
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +249 -37
- package/scripts/test.js +184 -44
- package/skills/claim-safety/SKILL.md +4 -0
- package/skills/claim-safety/evals/scorecard.json +41 -0
- package/skills/coverage.json +83 -0
- package/skills/dev/SKILL.md +4 -0
- package/skills/dev/evals/scorecard.json +41 -0
- package/skills/gates/SKILL.md +80 -0
- package/skills/gates/evals/evals.json +38 -0
- package/skills/gates/evals/scorecard.json +41 -0
- package/skills/hermes-forge/SKILL.md +1 -0
- package/skills/hermes-forge/evals/scorecard.json +41 -0
- package/skills/issue-basics/SKILL.md +1 -0
- package/skills/issue-basics/evals/scorecard.json +41 -0
- package/skills/kernel/SKILL.md +38 -0
- package/skills/kernel/evals/scorecard.json +41 -0
- package/skills/memory/SKILL.md +16 -1
- package/skills/memory/evals/scorecard.json +41 -0
- package/skills/parallel-deep-research/SKILL.md +1 -0
- package/skills/parallel-deep-research/evals/scorecard.json +41 -0
- package/skills/plan/SKILL.md +6 -0
- package/skills/plan/evals/scorecard.json +41 -0
- package/skills/portability/SKILL.md +47 -0
- package/skills/portability/evals/evals.json +34 -0
- package/skills/portability/evals/scorecard.json +41 -0
- package/skills/research/SKILL.md +1 -0
- package/skills/research/evals/scorecard.json +41 -0
- package/skills/review/SKILL.md +10 -11
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +5 -11
- package/skills/rollback/evals/scorecard.json +41 -0
- package/skills/setup/SKILL.md +91 -0
- package/skills/setup/evals/evals.json +42 -0
- package/skills/setup/evals/scorecard.json +41 -0
- package/skills/shepherd/SKILL.md +84 -38
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +10 -12
- package/skills/ship/evals/scorecard.json +41 -0
- package/skills/smith/SKILL.md +8 -0
- package/skills/smith/evals/scorecard.json +41 -0
- package/skills/sonarcloud/SKILL.md +1 -0
- package/skills/sonarcloud/evals/scorecard.json +41 -0
- package/skills/sonarcloud-analysis/SKILL.md +1 -0
- package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
- package/skills/status/SKILL.md +3 -0
- package/skills/status/evals/scorecard.json +41 -0
- package/skills/triage-ready/SKILL.md +2 -0
- package/skills/triage-ready/evals/scorecard.json +41 -0
- package/skills/using-forge/SKILL.md +104 -0
- package/skills/using-forge/evals/scorecard.json +41 -0
- package/skills/validate/SKILL.md +4 -0
- package/skills/validate/evals/scorecard.json +41 -0
- package/skills/verify/SKILL.md +4 -0
- package/skills/verify/evals/scorecard.json +41 -0
- package/skills/worktree/SKILL.md +92 -0
- package/skills/worktree/evals/evals.json +38 -0
- package/skills/worktree/evals/scorecard.json +41 -0
- package/lib/adapters/beads-issue-adapter.js +0 -127
- package/lib/beads-nudge.js +0 -91
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/commands/board.js +0 -64
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -192
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/lib/status/beads-snapshot.js +0 -145
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/forge-team/lib/dashboard.sh +0 -316
- package/scripts/forge-team/tests/dashboard.test.sh +0 -155
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
package/lib/issue-backend.js
CHANGED
|
@@ -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'
|
|
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'
|
|
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
|
-
//
|
|
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
|
|
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
|
}
|
package/lib/kernel/broker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|
package/lib/kernel/fs-class.js
CHANGED
|
@@ -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`
|
|
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
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
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
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/lib/kernel/migrations.js
CHANGED
|
@@ -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,
|
package/lib/kernel/schema.js
CHANGED
|
@@ -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])));
|