forge-workflow 0.1.0-beta.4 → 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 +20 -0
- package/bin/forge.js +16 -374
- 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 +8 -5
- 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 +54 -25
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +344 -142
- package/lib/audit-evidence.js +71 -110
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/commands/_registry.js +2 -2
- package/lib/commands/clean.js +196 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/hooks.js +223 -25
- package/lib/commands/insights.js +8 -3
- package/lib/commands/merge.js +600 -40
- package/lib/commands/pr.js +1 -1
- package/lib/commands/preflight.js +11 -2
- package/lib/commands/prime.js +21 -8
- 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 +17 -2
- package/lib/commands/setup.js +191 -94
- package/lib/commands/shepherd.js +13 -1
- package/lib/commands/ship.js +22 -23
- package/lib/commands/skill.js +119 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/test.js +24 -34
- package/lib/commands/worktree.js +220 -42
- package/lib/core/runtime-graph.js +1 -1
- 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 +56 -32
- package/lib/git-defaults.js +56 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +93 -4
- package/lib/insights.js +96 -80
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +16 -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/sqlite-driver.js +153 -29
- 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 +71 -10
- package/lib/merge-rules.js +8 -4
- package/lib/npm-publish-workflow.js +272 -0
- package/lib/orientation.js +68 -43
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/journal.js +18 -2
- package/lib/pr-monitor/reconcile-executor.js +224 -41
- package/lib/pr-monitor/render-summary.js +196 -0
- package/lib/pr-monitor/shepherd-lease.js +10 -1
- package/lib/pr-monitor/watch-lifecycle.js +13 -1
- package/lib/pr-pull.js +33 -14
- 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 +33 -1
- 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/shell-utils.js +1 -1
- package/lib/skills-sync.js +6 -3
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/package.json +6 -7
- package/scripts/doc-asserting-tests.js +158 -0
- 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 +176 -43
- package/skills/review/SKILL.md +4 -11
- package/skills/review/evals/scorecard.json +3 -3
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +20 -14
- package/skills/shepherd/evals/scorecard.json +2 -2
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- 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/lib/beads-migrate-to-dolt.mjs +0 -503
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
|
*
|