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
|
@@ -26,7 +26,6 @@ const commands = [
|
|
|
26
26
|
{ file: "add.js", module: require("./add") },
|
|
27
27
|
{ file: "audit.js", module: require("./audit") },
|
|
28
28
|
{ file: "blocked.js", module: require("./blocked") },
|
|
29
|
-
{ file: "board.js", module: require("./board") },
|
|
30
29
|
{ file: "claim.js", module: require("./claim") },
|
|
31
30
|
{ file: "claims.js", module: require("./claims") },
|
|
32
31
|
{ file: "clean.js", module: require("./clean") },
|
|
@@ -73,6 +72,7 @@ const commands = [
|
|
|
73
72
|
{ file: "shepherd.js", module: require("./shepherd") },
|
|
74
73
|
{ file: "ship.js", module: require("./ship") },
|
|
75
74
|
{ file: "show.js", module: require("./show") },
|
|
75
|
+
{ file: "skill.js", module: require("./skill") },
|
|
76
76
|
{ file: "stage.js", module: require("./stage") },
|
|
77
77
|
{ file: "stale.js", module: require("./stale") },
|
|
78
78
|
{ file: "status.js", module: require("./status") },
|
|
@@ -201,8 +201,8 @@ async function executeCommand(commands, commandName, args, flags, projectRoot, o
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
// Lazy `.forge/` home (activation foundation): the FIRST mutating verb in a
|
|
204
|
-
// bare repo materializes the
|
|
205
|
-
// verbs never enter this branch, so they write nothing; an already-inited
|
|
204
|
+
// bare repo materializes the default-enabled config skeleton on demand.
|
|
205
|
+
// Read-only verbs never enter this branch, so they write nothing; an already-inited
|
|
206
206
|
// repo is a no-op (never clobbered). Failure to create the home must not
|
|
207
207
|
// crash the command — degrade to a warning. Opt out via `skipEnsureHome`.
|
|
208
208
|
if (projectRoot && options.skipEnsureHome !== true && isMutatingVerb(commandName, command)) {
|
|
@@ -5,15 +5,17 @@
|
|
|
5
5
|
* handler (the 4th arg). For issue/alias commands this:
|
|
6
6
|
*
|
|
7
7
|
* 1. Strips the selector tokens (`--kernel`, `--issue-backend <val>`) from the
|
|
8
|
-
* args BEFORE they reach the handler
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* be mistaken for an issue id.
|
|
8
|
+
* args BEFORE they reach the handler. This is load-bearing: bin parseFlags()
|
|
9
|
+
* early-returns for issue passthrough commands, so these tokens otherwise flow
|
|
10
|
+
* untouched into the handler and the positional `kernel` value would be
|
|
11
|
+
* mistaken for an issue id.
|
|
13
12
|
* 2. Resolves the backend (flag > env > config > default) via the backend
|
|
14
13
|
* authority module (lib/issue-backend.js). The CLI flag tokens are mapped to
|
|
15
14
|
* that module's `deps.issueBackend` signal; env/config/default precedence is
|
|
16
|
-
* applied by the resolver itself.
|
|
15
|
+
* applied by the resolver itself. `kernel` is the only accepted value — a
|
|
16
|
+
* retired backend named on the FLAG is a hard error (the user typed it in this
|
|
17
|
+
* invocation, so silently ignoring it would run the wrong store), while the
|
|
18
|
+
* same value in env/config only warns and falls back.
|
|
17
19
|
* 3. When kernel, assembles the kernel deps (driver + flag) via the CLI broker
|
|
18
20
|
* factory so createKernelIssueBackend has a real driver (B1).
|
|
19
21
|
*
|
|
@@ -24,7 +26,7 @@
|
|
|
24
26
|
* @module commands/resolve-command-opts
|
|
25
27
|
*/
|
|
26
28
|
|
|
27
|
-
const { resolveIssueBackend } = require('../issue-backend');
|
|
29
|
+
const { resolveIssueBackend, removedBackendHint } = require('../issue-backend');
|
|
28
30
|
const {
|
|
29
31
|
buildMigratedKernelIssueDeps: defaultBuildKernelIssueDeps,
|
|
30
32
|
} = require('../kernel/cli-broker-factory');
|
|
@@ -45,14 +47,11 @@ const ISSUE_COMMANDS = new Set([
|
|
|
45
47
|
'stale',
|
|
46
48
|
'orphans',
|
|
47
49
|
'lint',
|
|
48
|
-
// Epic grouping read
|
|
49
|
-
// verified Beads equivalent), so it belongs in ISSUE_COMMANDS like the rest.
|
|
50
|
+
// Epic grouping read (issue.children rollup).
|
|
50
51
|
'children',
|
|
51
|
-
// Lease-ownership verification
|
|
52
|
-
// so it is kernel-routed like the rest of the issue surface.
|
|
52
|
+
// Lease-ownership verification (leases live in the Kernel).
|
|
53
53
|
'owns',
|
|
54
|
-
// Active-lease listing
|
|
55
|
-
// is kernel-routed like the rest of the issue surface.
|
|
54
|
+
// Active-lease listing (leases live in the Kernel).
|
|
56
55
|
'claims',
|
|
57
56
|
'search',
|
|
58
57
|
'stats',
|
|
@@ -101,7 +100,7 @@ function stripSelectorTokens(rawArgs = []) {
|
|
|
101
100
|
flags.issueBackend = next;
|
|
102
101
|
i += 1;
|
|
103
102
|
} else {
|
|
104
|
-
throw new Error(`${ISSUE_BACKEND_FLAG} requires a value: kernel
|
|
103
|
+
throw new Error(`${ISSUE_BACKEND_FLAG} requires a value: kernel.`);
|
|
105
104
|
}
|
|
106
105
|
continue;
|
|
107
106
|
}
|
|
@@ -109,7 +108,7 @@ function stripSelectorTokens(rawArgs = []) {
|
|
|
109
108
|
// Equals form: reject an empty value (`--issue-backend=`) for the same reason.
|
|
110
109
|
const value = token.slice(ISSUE_BACKEND_FLAG.length + 1).trim();
|
|
111
110
|
if (!value) {
|
|
112
|
-
throw new Error(`${ISSUE_BACKEND_FLAG} requires a value: kernel
|
|
111
|
+
throw new Error(`${ISSUE_BACKEND_FLAG} requires a value: kernel.`);
|
|
113
112
|
}
|
|
114
113
|
flags.issueBackend = value;
|
|
115
114
|
continue;
|
|
@@ -122,12 +121,14 @@ function stripSelectorTokens(rawArgs = []) {
|
|
|
122
121
|
/**
|
|
123
122
|
* Reduce the stripped selector flags to a single explicit backend value (or null
|
|
124
123
|
* when no flag selects one), honoring `--kernel` / `--issue-backend` equivalence
|
|
125
|
-
* and rejecting a mutually-exclusive conflict.
|
|
126
|
-
* here
|
|
124
|
+
* and rejecting a mutually-exclusive conflict. A RETIRED backend value is rejected
|
|
125
|
+
* here with the migrate pointer; any other value is NOT validated here — the backend
|
|
126
|
+
* authority module normalizes/warns on unknown values.
|
|
127
127
|
*
|
|
128
128
|
* @param {{ kernel?: boolean, issueBackend?: string }} flags
|
|
129
129
|
* @returns {string|null}
|
|
130
|
-
* @throws {Error} when --kernel and --issue-backend select different backends
|
|
130
|
+
* @throws {Error} when --kernel and --issue-backend select different backends, or
|
|
131
|
+
* when --issue-backend names a removed backend.
|
|
131
132
|
*/
|
|
132
133
|
function resolveFlagBackend(flags = {}) {
|
|
133
134
|
const fromBackend = typeof flags.issueBackend === 'string' && flags.issueBackend.trim()
|
|
@@ -142,6 +143,19 @@ function resolveFlagBackend(flags = {}) {
|
|
|
142
143
|
);
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
// Hard error (not the resolver's warn+fallback) because the value was typed into
|
|
147
|
+
// THIS invocation: silently running the kernel after the user explicitly asked for
|
|
148
|
+
// a different store is the kind of surprise that loses writes.
|
|
149
|
+
if (fromBackend) {
|
|
150
|
+
const removedHint = removedBackendHint(fromBackend);
|
|
151
|
+
if (removedHint) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`${ISSUE_BACKEND_FLAG} ${fromBackend} is no longer supported: ${removedHint}. `
|
|
154
|
+
+ `Only '${KERNEL}' is accepted.`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
145
159
|
return fromKernel || fromBackend || null;
|
|
146
160
|
}
|
|
147
161
|
|
|
@@ -188,22 +202,15 @@ async function resolveCommandOpts(command, rawArgs = [], deps = {}) {
|
|
|
188
202
|
|
|
189
203
|
const { args, flags } = stripSelectorTokens(rawArgs);
|
|
190
204
|
const flagBackend = resolveFlagBackend(flags);
|
|
191
|
-
|
|
205
|
+
// Still routed through the authority module (rather than hardcoding 'kernel') so a
|
|
206
|
+
// retired/unknown env or config value produces its warning exactly once, here, on
|
|
207
|
+
// the way to the kernel fallback.
|
|
208
|
+
resolveIssueBackend({
|
|
192
209
|
deps: flagBackend ? { issueBackend: flagBackend } : {},
|
|
193
210
|
env,
|
|
194
211
|
projectRoot: deps.projectRoot,
|
|
195
212
|
});
|
|
196
213
|
|
|
197
|
-
if (issueBackend !== KERNEL) {
|
|
198
|
-
return {
|
|
199
|
-
commandOpts: {
|
|
200
|
-
issueBackend,
|
|
201
|
-
useKernelBroker: false,
|
|
202
|
-
},
|
|
203
|
-
args,
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
|
|
207
214
|
// Kernel backend: assemble the driver (B1) + migrated broker (B2). The factory
|
|
208
215
|
// builder constructs the driver, builds the broker, and runs initialize().
|
|
209
216
|
const kernelDeps = await buildKernelIssueDeps({
|
package/lib/commands/claim.js
CHANGED
|
@@ -3,10 +3,8 @@
|
|
|
3
3
|
const { runIssueSubcommand } = require('./_issue');
|
|
4
4
|
|
|
5
5
|
// `forge claim <id>` claims an issue through the SHARED issue dispatch
|
|
6
|
-
// (runIssueSubcommand): backend resolution (Kernel
|
|
7
|
-
//
|
|
8
|
-
// Beads backend translates claim to `update <id> --claim`), contract
|
|
9
|
-
// normalization, AND the check-after-write verification loop
|
|
6
|
+
// (runIssueSubcommand): backend resolution (the Kernel is the only backend),
|
|
7
|
+
// contract normalization, AND the check-after-write verification loop
|
|
10
8
|
// (gate.issue_verify). This command previously inlined its own copy of the
|
|
11
9
|
// dispatch, which silently BYPASSED the boundary verify — the exact surface
|
|
12
10
|
// where the d71a824b phantom-claim replay lied to a losing agent — so it now
|
package/lib/commands/clean.js
CHANGED
|
@@ -118,13 +118,15 @@ function parseMainWorktree(output) {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
/**
|
|
121
|
-
* GitHub tier: collect
|
|
122
|
-
* Returns an empty
|
|
121
|
+
* GitHub tier: collect merged PRs keyed by head branch (one memoized call).
|
|
122
|
+
* Returns an empty Map when gh is unavailable or errors — a safe no-signal.
|
|
123
|
+
* The number/title/merge-commit ride along so a merge can be CITED as evidence on
|
|
124
|
+
* the kernel issue the branch was linked to (18f1988e), not just detected.
|
|
123
125
|
* @param {Function} runFile - execFileSync-compatible function
|
|
124
|
-
* @returns {Map<string, string
|
|
126
|
+
* @returns {Map<string, {headRefOid: string, number: number, title: string, mergeCommitOid: string|null}>}
|
|
125
127
|
*/
|
|
126
|
-
function
|
|
127
|
-
const out = tryRun(runFile, 'gh', ['pr', 'list', '--state', 'merged', '--json', 'headRefName,headRefOid', '--limit', '200']);
|
|
128
|
+
function getGhMergedPrs(runFile) {
|
|
129
|
+
const out = tryRun(runFile, 'gh', ['pr', 'list', '--state', 'merged', '--json', 'number,title,headRefName,headRefOid,mergeCommit', '--limit', '200']);
|
|
128
130
|
if (!out) return new Map();
|
|
129
131
|
try {
|
|
130
132
|
const arr = JSON.parse(out);
|
|
@@ -133,7 +135,14 @@ function getGhMergedRefs(runFile) {
|
|
|
133
135
|
// Record the head OID too: a reused/advanced branch name must NOT be treated as
|
|
134
136
|
// merged unless its current tip still matches the OID GitHub merged.
|
|
135
137
|
for (const p of arr) {
|
|
136
|
-
if (p && p.headRefName && p.headRefOid)
|
|
138
|
+
if (p && p.headRefName && p.headRefOid) {
|
|
139
|
+
map.set(p.headRefName, {
|
|
140
|
+
headRefOid: p.headRefOid,
|
|
141
|
+
number: p.number,
|
|
142
|
+
title: p.title,
|
|
143
|
+
mergeCommitOid: (p.mergeCommit && p.mergeCommit.oid) || null,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
137
146
|
}
|
|
138
147
|
return map;
|
|
139
148
|
} catch (_e) { /* intentional: malformed gh output → no signal */ // NOSONAR S2486
|
|
@@ -166,30 +175,63 @@ function isSquashMerged(branch, defaultBranch, runFile) {
|
|
|
166
175
|
return cherry.startsWith('-');
|
|
167
176
|
}
|
|
168
177
|
|
|
178
|
+
/**
|
|
179
|
+
* GitHub tier: a merged PR whose head OID still matches the branch tip. This is
|
|
180
|
+
* direct evidence THIS tip was merged, not a git-topology inference — a
|
|
181
|
+
* reused/advanced branch name falls through to the git-only tiers instead.
|
|
182
|
+
* @param {string} branch - Branch name
|
|
183
|
+
* @param {object} ctx - Detection context (see detectMerged)
|
|
184
|
+
* @returns {boolean} True iff a merged PR is pinned to the current branch tip
|
|
185
|
+
*/
|
|
186
|
+
function hasMergedPrEvidence(branch, ctx) {
|
|
187
|
+
const pr = ctx.ghMergedPrs && ctx.ghMergedPrs.get(branch);
|
|
188
|
+
if (!pr || !pr.headRefOid) return false;
|
|
189
|
+
const tip = tryRun(ctx.runFile, 'git', ['rev-parse', branch]);
|
|
190
|
+
return Boolean(tip) && tip === pr.headRefOid;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Whether a branch has contributed nothing yet — zero commits ahead of the
|
|
195
|
+
* default branch (kernel c5ab529e).
|
|
196
|
+
*
|
|
197
|
+
* A branch created but not yet committed to points AT a default-branch commit,
|
|
198
|
+
* so `git branch --merged` lists it and squash patch-equivalence finds an empty
|
|
199
|
+
* diff already present: every git-only tier reads it as merged. It is unstarted
|
|
200
|
+
* work, and `forge clean` deleted a live agent's fresh worktree and closed its
|
|
201
|
+
* issue on that reading. Only a VERIFIED count of 0 counts as unstarted; an
|
|
202
|
+
* unreadable count leaves detection exactly as it was.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} branch - Branch name
|
|
205
|
+
* @param {object} ctx - Detection context (see detectMerged)
|
|
206
|
+
* @returns {boolean} True iff the branch is verified to have no commits of its own
|
|
207
|
+
*/
|
|
208
|
+
function isUnstartedBranch(branch, ctx) {
|
|
209
|
+
const out = tryRun(ctx.runFile, 'git', ['rev-list', '--count', `${ctx.defaultBranch}..${branch}`]);
|
|
210
|
+
return out !== '' && parseInt(out, 10) === 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
169
213
|
/**
|
|
170
214
|
* Squash-aware merged detection over three short-circuiting tiers:
|
|
171
|
-
* (a) ancestry list (`git branch --merged`),
|
|
215
|
+
* (a) merged-PR head refs (gh), (b) ancestry list (`git branch --merged`),
|
|
172
216
|
* (c) git-only squash patch-equivalence. A branch confirmed by none stays.
|
|
217
|
+
*
|
|
218
|
+
* The gh tier runs first because it is the only tier that cites real merge
|
|
219
|
+
* evidence, so it still recognizes a fast-forward/merge-commit PR whose branch
|
|
220
|
+
* ends up 0 commits ahead. The git-only tiers below cannot tell that case apart
|
|
221
|
+
* from an unstarted branch, so they are gated on the branch having commits.
|
|
222
|
+
*
|
|
173
223
|
* @param {string} branch - Branch name
|
|
174
224
|
* @param {object} ctx - Detection context
|
|
175
225
|
* @param {string} ctx.defaultBranch - Default branch name
|
|
176
226
|
* @param {string[]} ctx.mergedBranches - Ancestry-merged branch names
|
|
177
|
-
* @param {Map<string,
|
|
227
|
+
* @param {Map<string, object>} ctx.ghMergedPrs - Merged PR head branch -> PR evidence
|
|
178
228
|
* @param {Function} ctx.runFile - execFileSync-compatible function
|
|
179
229
|
* @returns {boolean} True iff the branch is merged (any tier)
|
|
180
230
|
*/
|
|
181
231
|
function detectMerged(branch, ctx) {
|
|
232
|
+
if (hasMergedPrEvidence(branch, ctx)) return true;
|
|
233
|
+
if (isUnstartedBranch(branch, ctx)) return false;
|
|
182
234
|
if (ctx.mergedBranches.includes(branch)) return true;
|
|
183
|
-
if (ctx.ghMergedRefs) {
|
|
184
|
-
const mergedOid = ctx.ghMergedRefs.get(branch);
|
|
185
|
-
// Only trust the gh "merged" signal when the merged PR's head OID still matches
|
|
186
|
-
// the branch tip; a reused/advanced branch name with new commits falls through
|
|
187
|
-
// to the squash patch-equivalence check (never remove on doubt).
|
|
188
|
-
if (mergedOid) {
|
|
189
|
-
const tip = tryRun(ctx.runFile, 'git', ['rev-parse', branch]);
|
|
190
|
-
if (tip && tip === mergedOid) return true;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
235
|
return isSquashMerged(branch, ctx.defaultBranch, ctx.runFile);
|
|
194
236
|
}
|
|
195
237
|
|
|
@@ -270,32 +312,40 @@ async function removeWorktreeRobust(wtPath, runFile, fsApi, opts = {}) {
|
|
|
270
312
|
* @param {Function} runFile - execFileSync-compatible function
|
|
271
313
|
* @param {object} fsApi - fs-compatible module
|
|
272
314
|
* @param {object} opts - Removal injection options
|
|
273
|
-
* @returns {Promise<{ status: string, path: string, branch: string|null,
|
|
315
|
+
* @returns {Promise<{ status: string, path: string, branch: string|null,
|
|
316
|
+
* closeIssue: boolean, error?: string }>}
|
|
274
317
|
*/
|
|
275
318
|
async function cleanWorktree(dir, worktreeMap, isMergedFn, worktreesDir, dryRun, runFile, fsApi, opts) {
|
|
276
319
|
const wtPath = path.resolve(worktreesDir, dir);
|
|
277
320
|
// Lookup by canonical key (86b04c20): porcelain emits C:/ paths, resolve emits C:\.
|
|
278
321
|
const branch = worktreeMap.get(normalizeWorktreeKey(wtPath)) || null;
|
|
279
322
|
|
|
323
|
+
// `closeIssue` rides on the SAME outcome that decides removal (kernel c5ab529e).
|
|
324
|
+
// Closing is irreversible — `done` is terminal — so it may never be recomputed
|
|
325
|
+
// independently and drift away from what cleanup actually decided to do.
|
|
280
326
|
if (!branch || !isMergedFn(branch)) {
|
|
281
|
-
return { status: 'active', path: wtPath, branch };
|
|
327
|
+
return { status: 'active', path: wtPath, branch, closeIssue: false };
|
|
282
328
|
}
|
|
283
329
|
|
|
284
330
|
// Merged, but never blow away uncommitted local edits. Checked BEFORE the dry-run
|
|
285
331
|
// branch so a dry run reports the same "dirty" skip the real run would take.
|
|
332
|
+
// Held back for safety => the work is not confirmed finished => never closed.
|
|
286
333
|
if (isWorktreeDirty(wtPath, runFile)) {
|
|
287
|
-
return { status: 'dirty', path: wtPath, branch };
|
|
334
|
+
return { status: 'dirty', path: wtPath, branch, closeIssue: false };
|
|
288
335
|
}
|
|
289
336
|
|
|
337
|
+
// A dry run reports what it would do and mutates nothing, kernel included.
|
|
290
338
|
if (dryRun) {
|
|
291
|
-
return { status: 'cleaned', path: wtPath, branch };
|
|
339
|
+
return { status: 'cleaned', path: wtPath, branch, closeIssue: false };
|
|
292
340
|
}
|
|
293
341
|
|
|
294
342
|
const outcome = await removeWorktreeRobust(wtPath, runFile, fsApi, opts);
|
|
295
343
|
if (outcome.removed) {
|
|
296
|
-
return { status: 'cleaned', path: wtPath, branch, method: outcome.method };
|
|
344
|
+
return { status: 'cleaned', path: wtPath, branch, method: outcome.method, closeIssue: true };
|
|
297
345
|
}
|
|
298
|
-
|
|
346
|
+
// Merged with a clean tree; only the directory removal lost to an FS lock. The
|
|
347
|
+
// work IS in the default branch, so the issue is finished either way.
|
|
348
|
+
return { status: 'survivor', path: wtPath, branch, error: outcome.error, closeIssue: true };
|
|
299
349
|
}
|
|
300
350
|
|
|
301
351
|
/**
|
|
@@ -449,6 +499,13 @@ function renderSurvivors(survivors, lines) {
|
|
|
449
499
|
for (const s of survivors) lines.push(` - ${s.path}${s.error ? ` (${s.error})` : ''}`);
|
|
450
500
|
}
|
|
451
501
|
|
|
502
|
+
/** Append the closed-linked-issue lines. */
|
|
503
|
+
function renderClosedIssues(closedIssues, lines) {
|
|
504
|
+
if (!closedIssues || closedIssues.length === 0) return;
|
|
505
|
+
lines.push(`Closed ${closedIssues.length} linked issue(s) on merge:`);
|
|
506
|
+
for (const c of closedIssues) lines.push(` - ${c.issueId} (${c.branch})`);
|
|
507
|
+
}
|
|
508
|
+
|
|
452
509
|
/** Append the master-sync outcome line(s). */
|
|
453
510
|
function renderMasterSync(masterSync, lines) {
|
|
454
511
|
if (!masterSync || !masterSync.attempted) return;
|
|
@@ -472,10 +529,94 @@ function formatOutput(summary) {
|
|
|
472
529
|
lines.push(`${verb} ${summary.cleaned} merged worktree(s); ${summary.active} active kept.`);
|
|
473
530
|
renderDirty(summary.dirty, lines);
|
|
474
531
|
renderSurvivors(summary.survivors, lines);
|
|
532
|
+
renderClosedIssues(summary.closedIssues, lines);
|
|
475
533
|
renderMasterSync(summary.masterSync, lines);
|
|
476
534
|
return lines.join('\n');
|
|
477
535
|
}
|
|
478
536
|
|
|
537
|
+
/**
|
|
538
|
+
* Strip the merged-PR record down to the evidence cited on the kernel issue.
|
|
539
|
+
* @param {Map<string, object>} ghMergedPrs
|
|
540
|
+
* @param {string} branch
|
|
541
|
+
* @returns {{number: number, title: string, mergeCommitOid: string|null}|null}
|
|
542
|
+
*/
|
|
543
|
+
function prEvidenceFor(ghMergedPrs, branch) {
|
|
544
|
+
const pr = ghMergedPrs && ghMergedPrs.get(branch);
|
|
545
|
+
if (!pr || !pr.number) return null;
|
|
546
|
+
return { number: pr.number, title: pr.title, mergeCommitOid: pr.mergeCommitOid };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Bridge from a merged branch to the close-on-merge primitive (kernel 18f1988e).
|
|
551
|
+
*
|
|
552
|
+
* The kernel driver + issue runner are built ONCE and only on the first merged
|
|
553
|
+
* branch, so a clean run that removes nothing pays nothing. The driver is closed
|
|
554
|
+
* by `dispose()` — and ONLY when this helper owns it, since an injected driver
|
|
555
|
+
* belongs to the caller. Leaving an owned sqlite handle open keeps the kernel file
|
|
556
|
+
* locked and fails Windows cleanup.
|
|
557
|
+
*
|
|
558
|
+
* @param {string} projectRoot
|
|
559
|
+
* @param {Function} runFile - execFileSync-compatible function (repo probe)
|
|
560
|
+
* @param {object} opts - DI (`_closeLinkedIssue`, `_kernelDriver`, `_runIssueOperation`)
|
|
561
|
+
* @returns {{ close: Function, dispose: Function }}
|
|
562
|
+
*/
|
|
563
|
+
function createIssueCloser(projectRoot, runFile, opts) {
|
|
564
|
+
const injected = opts._closeLinkedIssue;
|
|
565
|
+
let built = null;
|
|
566
|
+
const unavailable = { driver: null, run: null, owned: false };
|
|
567
|
+
|
|
568
|
+
async function resolveDeps() {
|
|
569
|
+
if (built) return built;
|
|
570
|
+
if (opts._kernelDriver && opts._runIssueOperation) {
|
|
571
|
+
built = { driver: opts._kernelDriver, run: opts._runIssueOperation, owned: false };
|
|
572
|
+
return built;
|
|
573
|
+
}
|
|
574
|
+
// Only reach for the kernel inside a REAL git repo. buildMigratedKernelIssueDeps
|
|
575
|
+
// CREATES the store at <git-common-dir>/forge/kernel.sqlite, so probing first is
|
|
576
|
+
// what stops a clean run pointed at a non-repo path from minting a stray kernel
|
|
577
|
+
// there. An empty result (rev-parse failed) means "not a repo".
|
|
578
|
+
if (!tryRun(runFile, 'git', ['-C', projectRoot, 'rev-parse', '--git-common-dir'])) {
|
|
579
|
+
built = unavailable;
|
|
580
|
+
return built;
|
|
581
|
+
}
|
|
582
|
+
try {
|
|
583
|
+
const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
|
|
584
|
+
const { runIssueOperation } = require('../forge-issues');
|
|
585
|
+
const { kernelDriver, kernelBroker } = await buildMigratedKernelIssueDeps({ projectRoot });
|
|
586
|
+
// Thread the broker through so every op reuses THIS driver instead of opening
|
|
587
|
+
// a fresh kernel handle per call.
|
|
588
|
+
built = {
|
|
589
|
+
driver: kernelDriver,
|
|
590
|
+
run: (operation, args, root) => runIssueOperation(operation, args, root, { kernelBroker }),
|
|
591
|
+
owned: true,
|
|
592
|
+
};
|
|
593
|
+
} catch (_e) { /* intentional: no kernel → close nothing, clean normally */ // NOSONAR S2486
|
|
594
|
+
built = unavailable;
|
|
595
|
+
}
|
|
596
|
+
return built;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return {
|
|
600
|
+
async close(branch, pr) {
|
|
601
|
+
try {
|
|
602
|
+
if (injected) return await injected({ branch, pr, projectRoot });
|
|
603
|
+
const { driver, run } = await resolveDeps();
|
|
604
|
+
if (!driver || !run) return { closed: false, reason: 'unavailable' };
|
|
605
|
+
const { closeLinkedIssueOnMerge } = require('../kernel/close-on-merge');
|
|
606
|
+
return await closeLinkedIssueOnMerge({ branch, projectRoot, pr, driver, runIssueOperation: run });
|
|
607
|
+
} catch (error) {
|
|
608
|
+
// Best-effort: cleanup must never fail because tracking did.
|
|
609
|
+
return { closed: false, reason: 'error', error: error && error.message };
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
dispose() {
|
|
613
|
+
if (built && built.owned && built.driver && typeof built.driver.close === 'function') {
|
|
614
|
+
try { built.driver.close(); } catch (_e) { /* best-effort */ } // NOSONAR S2486
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
479
620
|
/**
|
|
480
621
|
* Main handler for the clean command.
|
|
481
622
|
* @param {string[]} _args - Positional arguments (unused)
|
|
@@ -492,10 +633,11 @@ function formatOutput(summary) {
|
|
|
492
633
|
* Scan .worktrees/ and remove the merged ones (squash-aware). Returns the tallies;
|
|
493
634
|
* a no-op ({0,0,[],[]}) when .worktrees/ is absent or empty. Extracted from handler
|
|
494
635
|
* so the top-level orchestration stays under the complexity gate.
|
|
495
|
-
* @
|
|
636
|
+
* @param {{close: Function}|null} [closer] - Close-on-merge bridge (null in dry-run)
|
|
637
|
+
* @returns {Promise<{ cleaned: number, active: number, survivors: object[], dirty: string[], closedIssues: object[] }>}
|
|
496
638
|
*/
|
|
497
|
-
async function cleanWorktrees(worktreesDir, runFile, fsApi, dryRun, opts) {
|
|
498
|
-
const acc = { cleaned: 0, active: 0, survivors: [], dirty: [] };
|
|
639
|
+
async function cleanWorktrees(worktreesDir, runFile, fsApi, dryRun, opts, closer = null) {
|
|
640
|
+
const acc = { cleaned: 0, active: 0, survivors: [], dirty: [], closedIssues: [] };
|
|
499
641
|
if (!fsApi.existsSync(worktreesDir)) return acc;
|
|
500
642
|
|
|
501
643
|
const entries = fsApi.readdirSync(worktreesDir, { withFileTypes: true });
|
|
@@ -512,8 +654,8 @@ async function cleanWorktrees(worktreesDir, runFile, fsApi, dryRun, opts) {
|
|
|
512
654
|
// ancestry fast path instead of falling through to the slower squash tier.
|
|
513
655
|
mergedBranches = mergedOut.split('\n').map(b => b.trim().replace(/^[*+]\s*/, '')).filter(Boolean);
|
|
514
656
|
}
|
|
515
|
-
const
|
|
516
|
-
const ctx = { defaultBranch, mergedBranches,
|
|
657
|
+
const ghMergedPrs = getGhMergedPrs(runFile);
|
|
658
|
+
const ctx = { defaultBranch, mergedBranches, ghMergedPrs, runFile };
|
|
517
659
|
const isMergedFn = opts._isMerged || (branch => detectMerged(branch, ctx));
|
|
518
660
|
|
|
519
661
|
const listOutput = tryRun(runFile, 'git', ['worktree', 'list', '--porcelain']);
|
|
@@ -525,6 +667,16 @@ async function cleanWorktrees(worktreesDir, runFile, fsApi, dryRun, opts) {
|
|
|
525
667
|
else if (res.status === 'survivor') acc.survivors.push(res);
|
|
526
668
|
else if (res.status === 'dirty') acc.dirty.push(res.path);
|
|
527
669
|
else acc.active++;
|
|
670
|
+
|
|
671
|
+
// Close only what THIS outcome authorized (18f1988e, tightened by c5ab529e):
|
|
672
|
+
// `closeIssue` is set by cleanWorktree alongside the removal decision, so a
|
|
673
|
+
// worktree held back for any reason cannot have its issue closed.
|
|
674
|
+
if (closer && res.branch && res.closeIssue) {
|
|
675
|
+
const outcome = await closer.close(res.branch, prEvidenceFor(ghMergedPrs, res.branch));
|
|
676
|
+
if (outcome && outcome.closed) {
|
|
677
|
+
acc.closedIssues.push({ branch: res.branch, issueId: outcome.issueId });
|
|
678
|
+
}
|
|
679
|
+
}
|
|
528
680
|
}
|
|
529
681
|
|
|
530
682
|
if (!dryRun && acc.cleaned > 0) {
|
|
@@ -541,7 +693,15 @@ async function handler(_args, flags, projectRoot, opts = {}) {
|
|
|
541
693
|
const worktreesDir = path.resolve(projectRoot, '.worktrees');
|
|
542
694
|
|
|
543
695
|
// Worktree cleanup (no-op when .worktrees/ absent); master auto-update is independent.
|
|
544
|
-
|
|
696
|
+
// A dry run detects merges but must not mutate the kernel, so it gets no closer.
|
|
697
|
+
const closer = dryRun ? null : createIssueCloser(projectRoot, runFile, opts);
|
|
698
|
+
let scan;
|
|
699
|
+
try {
|
|
700
|
+
scan = await cleanWorktrees(worktreesDir, runFile, fsApi, dryRun, opts, closer);
|
|
701
|
+
} finally {
|
|
702
|
+
if (closer) closer.dispose();
|
|
703
|
+
}
|
|
704
|
+
const { cleaned, active, survivors, dirty, closedIssues } = scan;
|
|
545
705
|
|
|
546
706
|
// Post-merge master auto-update (default-on; skipped in dry-run).
|
|
547
707
|
let masterSync = null;
|
|
@@ -550,7 +710,7 @@ async function handler(_args, flags, projectRoot, opts = {}) {
|
|
|
550
710
|
masterSync = await doSync();
|
|
551
711
|
}
|
|
552
712
|
|
|
553
|
-
return finalize({ success: true, cleaned, active, dryRun, survivors, dirty }, masterSync);
|
|
713
|
+
return finalize({ success: true, cleaned, active, dryRun, survivors, dirty, closedIssues }, masterSync);
|
|
554
714
|
}
|
|
555
715
|
|
|
556
716
|
/**
|
|
@@ -576,8 +736,12 @@ module.exports = {
|
|
|
576
736
|
normalizeWorktreeKey,
|
|
577
737
|
parseWorktreeList,
|
|
578
738
|
parseMainWorktree,
|
|
579
|
-
|
|
739
|
+
getGhMergedPrs,
|
|
740
|
+
prEvidenceFor,
|
|
741
|
+
createIssueCloser,
|
|
580
742
|
isSquashMerged,
|
|
743
|
+
hasMergedPrEvidence,
|
|
744
|
+
isUnstartedBranch,
|
|
581
745
|
detectMerged,
|
|
582
746
|
isWorktreeDirty,
|
|
583
747
|
removeWorktreeRobust,
|
package/lib/commands/dev.js
CHANGED
|
@@ -460,50 +460,21 @@ function withDevAuditEvidence(featureName, phase, result, options = {}) {
|
|
|
460
460
|
},
|
|
461
461
|
};
|
|
462
462
|
|
|
463
|
+
// Evidence is best-effort: it records what the dev cycle did, so a log the
|
|
464
|
+
// process could not write never changes the outcome it was recording.
|
|
463
465
|
try {
|
|
464
|
-
const auditEvidence = emitImplementerAuditEvidence(auditEvent, options.auditOptions || {});
|
|
465
|
-
if (auditEvidence.record?.success === false) {
|
|
466
|
-
const auditError = auditEvidence.record.error || 'bd audit record failed';
|
|
467
|
-
return {
|
|
468
|
-
...result,
|
|
469
|
-
success: false,
|
|
470
|
-
error: result.success === false ? result.error : `Audit evidence persistence failed: ${auditError}`,
|
|
471
|
-
auditEvidence,
|
|
472
|
-
};
|
|
473
|
-
}
|
|
474
|
-
|
|
475
466
|
return {
|
|
476
467
|
...result,
|
|
477
|
-
auditEvidence,
|
|
468
|
+
auditEvidence: emitImplementerAuditEvidence(auditEvent, options.auditOptions || {}),
|
|
478
469
|
};
|
|
479
470
|
} catch (error) {
|
|
480
|
-
if (isAuditCommandUnavailableError(error)) {
|
|
481
|
-
return {
|
|
482
|
-
...result,
|
|
483
|
-
auditEvidence: {
|
|
484
|
-
success: false,
|
|
485
|
-
skipped: true,
|
|
486
|
-
error: error.message,
|
|
487
|
-
},
|
|
488
|
-
};
|
|
489
|
-
}
|
|
490
|
-
|
|
491
471
|
return {
|
|
492
472
|
...result,
|
|
493
|
-
success: false,
|
|
494
|
-
error: result.success === false ? result.error : `Audit evidence persistence failed: ${error.message}`,
|
|
495
|
-
auditEvidence: {
|
|
496
|
-
success: false,
|
|
497
|
-
error: error.message,
|
|
498
|
-
},
|
|
473
|
+
auditEvidence: { success: false, error: error.message },
|
|
499
474
|
};
|
|
500
475
|
}
|
|
501
476
|
}
|
|
502
477
|
|
|
503
|
-
function isAuditCommandUnavailableError(error) {
|
|
504
|
-
return error?.code === 'ENOENT' || /\bbd\b.*not found/i.test(error?.message || '');
|
|
505
|
-
}
|
|
506
|
-
|
|
507
478
|
/**
|
|
508
479
|
* Decision gate route constants
|
|
509
480
|
*
|