forgedock 1.16.2 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/bin/engine/outcome-codes.mjs +114 -0
  2. package/bin/engine/phases.mjs +211 -100
  3. package/bin/engine/projector.mjs +48 -1
  4. package/bin/engine/runlog.mjs +20 -5
  5. package/bin/engine/telemetry-seam.mjs +62 -0
  6. package/bin/engine/telemetry-seam.test.mjs +60 -0
  7. package/bin/engine/telemetry.mjs +148 -0
  8. package/bin/engine/telemetry.test.mjs +120 -0
  9. package/bin/engine-cli.mjs +17 -1
  10. package/bin/engine.mjs +227 -48
  11. package/bin/forgedock.mjs +8 -2
  12. package/bin/hooks/interactive-engine.mjs +10 -0
  13. package/bin/hooks/pre-tool-use.mjs +220 -0
  14. package/bin/runner.mjs +70 -4
  15. package/bin/stats/aggregate.mjs +231 -0
  16. package/bin/stats/cache.mjs +40 -0
  17. package/bin/stats/github-source.mjs +211 -0
  18. package/bin/stats/index.mjs +154 -0
  19. package/bin/stats/local-source.mjs +79 -0
  20. package/bin/stats/package.json +6 -0
  21. package/bin/stats/render.mjs +82 -0
  22. package/bin/stats/stats.test.mjs +312 -0
  23. package/commands/cleanup.md +90 -2
  24. package/commands/deploy-info.md +8 -4
  25. package/commands/deploy-pr.md +64 -0
  26. package/commands/fix-ci.md +20 -6
  27. package/commands/milestone.md +34 -17
  28. package/commands/orchestrate/phase-1-resolve.md +4 -1
  29. package/commands/orchestrate/phase-4-execution.md +28 -6
  30. package/commands/orchestrate/phase-5-cleanup.md +18 -2
  31. package/commands/pipeline-health/phase-1-context.md +2 -0
  32. package/commands/pipeline-health/phase-2-metrics.md +12 -5
  33. package/commands/pipeline-status.md +21 -1
  34. package/commands/review-pr-agents/protocols.md +1 -0
  35. package/commands/review-pr-agents.md +10 -2
  36. package/commands/review-pr-staging.md +196 -496
  37. package/commands/review-pr.md +512 -3134
  38. package/commands/test-gate.md +25 -10
  39. package/commands/work-on/build/architect.md +13 -1
  40. package/commands/work-on/build/implement.md +28 -6
  41. package/commands/work-on/build/validate.md +5 -2
  42. package/commands/work-on/build.md +197 -145
  43. package/commands/work-on/close.md +66 -20
  44. package/commands/work-on/decompose.md +113 -34
  45. package/commands/work-on/investigate.md +15 -14
  46. package/commands/work-on/remediate.md +49 -19
  47. package/commands/work-on/review.md +7 -5
  48. package/commands/work-on.md +1 -2
  49. package/docs/CONFIG.md +76 -24
  50. package/docs/OUTCOME-CODES.md +53 -0
  51. package/docs/RELEASE-NOTES.md +24 -0
  52. package/docs/STATS.md +63 -0
  53. package/docs/TELEMETRY.md +65 -0
  54. package/docs/WORK-ON-RUNTIME.md +19 -0
  55. package/docs/site/command-reference.md +1 -1
  56. package/docs/spec/forge-protocol-v1.md +2 -2
  57. package/docs/spec/label-state-machine.md +1 -1
  58. package/package.json +3 -2
  59. package/packages/protocol/src/phases.js +5 -5
  60. package/scripts/builder-resume-match.test.sh +58 -0
  61. package/scripts/cap-findings.sh +110 -0
  62. package/scripts/cap-findings.test.sh +130 -0
  63. package/scripts/check-spec-bash.sh +5 -1
  64. package/scripts/check-spec-bash.test.sh +20 -0
  65. package/scripts/check-spec-markers.sh +2 -0
  66. package/scripts/classify-finding.sh +65 -16
  67. package/scripts/classify-finding.test.sh +43 -1
  68. package/scripts/classify-grep-exit.sh +20 -0
  69. package/scripts/classify-grep-exit.test.sh +46 -0
  70. package/scripts/close-parent-c3.test.sh +123 -0
  71. package/scripts/cost-report.sh +146 -0
  72. package/scripts/cost-report.test.sh +80 -0
  73. package/scripts/diff-size.sh +97 -3
  74. package/scripts/diff-size.test.sh +59 -2
  75. package/scripts/doctor-pipeline-state.sh +43 -0
  76. package/scripts/file-deferred-followups.sh +159 -0
  77. package/scripts/file-deferred-followups.test.sh +106 -0
  78. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  79. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  80. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  81. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  82. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  83. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  84. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  85. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  86. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  87. package/scripts/forge-root.test.sh +18 -11
  88. package/scripts/lib/fence-bootstrap.sh +4 -3
  89. package/scripts/lib/pr-issue-number.sh +18 -0
  90. package/scripts/lib/review-template-resolve.sh +41 -0
  91. package/scripts/lib/telemetry.sh +168 -0
  92. package/scripts/publish-staging-sync.test.sh +48 -0
  93. package/scripts/remediate-liveness.sh +42 -6
  94. package/scripts/resolve-deploy-gate.sh +32 -6
  95. package/scripts/resolve-deploy-gate.test.sh +42 -1
  96. package/scripts/review-acceptance-gate.sh +16 -7
  97. package/scripts/review-acceptance-gate.test.sh +13 -0
  98. package/scripts/review-finalize.sh +1427 -0
  99. package/scripts/review-finalize.test.sh +833 -0
  100. package/scripts/review-pr-delta-wiring.test.sh +19 -72
  101. package/scripts/review-pr-domain-map.test.sh +23 -13
  102. package/scripts/review-pr-gate2.test.sh +24 -65
  103. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  104. package/scripts/review-pr-staging-scope.test.sh +56 -80
  105. package/scripts/review-prepare.sh +553 -0
  106. package/scripts/review-prepare.test.sh +288 -0
  107. package/scripts/review-roster.sh +186 -0
  108. package/scripts/review-roster.test.sh +185 -0
  109. package/scripts/run-shell-tests.sh +3 -0
  110. package/scripts/telemetry.test.sh +106 -0
  111. package/scripts/trusted-comments.test.sh +9 -2
  112. package/scripts/wait-ci-green.sh +8 -1
  113. package/scripts/wait-ci-green.test.sh +3 -3
  114. package/templates/forge.yaml.minimal +9 -0
@@ -0,0 +1,312 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it, beforeEach, afterEach } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { spawnSync } from "node:child_process";
10
+ import { aggregate, compareVersions, percentile } from "./aggregate.mjs";
11
+ import { cacheDir } from "./cache.mjs";
12
+ import { fetchGithubRecords, parseTelemetryComments, runsFromRecords, toModel } from "./github-source.mjs";
13
+ import { buildReport, parseArgs, parseSince, runStats } from "./index.mjs";
14
+ import { readLocalRuns } from "./local-source.mjs";
15
+ import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
16
+
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+ const fx = (f) => join(here, "fixtures", f);
19
+ const load = (f) => JSON.parse(readFileSync(fx(f), "utf8"));
20
+ const ENV = { FORGE_TRAIL_TRUSTED_ASSOCIATIONS: "OWNER,MEMBER,COLLABORATOR" };
21
+
22
+ // A fake `gh`: lists the fixture issues (honouring `since`), serves comments per number. Counts every call.
23
+ function fakeGh() {
24
+ const comments = { 201: load("comments-201.json"), 301: load("comments-301.json") };
25
+ const issues = load("issues.json");
26
+ const calls = [];
27
+ const gh = (args) => {
28
+ calls.push(args.join(" "));
29
+ const path = args.find((a) => a.startsWith("repos/"));
30
+ const m = /issues\/(\d+)\/comments$/.exec(path);
31
+ if (m) return JSON.stringify(comments[m[1]] ?? []);
32
+ const since = /since=([^&]+)/.exec(path)?.[1];
33
+ return JSON.stringify(issues.filter((i) => !since || i.updated_at >= since));
34
+ };
35
+ return { gh, calls };
36
+ }
37
+
38
+ let td;
39
+ beforeEach(() => { td = mkdtempSync(join(tmpdir(), "stats-")); });
40
+ afterEach(() => rmSync(td, { recursive: true, force: true }));
41
+
42
+ describe("telemetry comment parsing", () => {
43
+ it("concatenates parts, dedupes on eid and ignores untrusted or unrelated comments", () => {
44
+ const recs = parseTelemetryComments(load("comments-201.json"), ENV);
45
+ assert.deepEqual(recs.map((r) => r.eid), ["e1", "e2", "e3", "e4", "e5"]);
46
+ assert.ok(!recs.some((r) => r.eid === "e90"), "untrusted User comment must not be read");
47
+ });
48
+ it("skips torn lines and keeps identical-looking events that have different eids", () => {
49
+ const body = "<!-- FORGE:TELEMETRY v1 -->\n```jsonl\n{\"event\":\"a\",\"eid\":\"1\"}\n{\"event\":\"a\",\"eid\":\"2\"}\n{torn\n";
50
+ const recs = parseTelemetryComments([{ body, user: { type: "Bot" } }], ENV);
51
+ assert.equal(recs.length, 2);
52
+ });
53
+ it("trusts OWNER/MEMBER/COLLABORATOR, not NONE users", () => {
54
+ const body = "<!-- FORGE:TELEMETRY v1 -->\n```jsonl\n{\"event\":\"a\",\"eid\":\"1\"}\n```";
55
+ assert.equal(parseTelemetryComments([{ body, author_association: "MEMBER", user: { type: "User" } }], ENV).length, 1);
56
+ assert.equal(parseTelemetryComments([{ body, author_association: "NONE", user: { type: "User" } }], ENV).length, 0);
57
+ });
58
+ });
59
+
60
+ describe("GitHub-only report (fresh machine)", () => {
61
+ it("rebuilds cost, time, flow, funnel, gates and issue flow from GitHub alone", () => {
62
+ const { gh } = fakeGh();
63
+ const report = buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
64
+ const g = report.groups.find((x) => x.version === "1.17.0");
65
+ assert.equal(g.n, 1);
66
+ assert.equal(g.cost.cacheReadMean, 3000000);
67
+ assert.equal(g.cost.usdMean, 1.2);
68
+ assert.equal(g.time.wall.p50, 900000);
69
+ assert.equal(g.time.phases.build.p50, 500000);
70
+ assert.equal(g.flow.mergedPct, 1);
71
+ assert.deepEqual(g.flow.codes.map((c) => c.code), ["completed"]);
72
+ assert.equal(g.funnel.reviews, 1);
73
+ assert.equal(g.funnel.findings, 3);
74
+ assert.equal(g.funnel.bySeverity.HIGH.total, 2);
75
+ assert.equal(g.funnel.bySeverity.HIGH.ISSUE, 1);
76
+ assert.equal(g.funnel.bySeverity.HIGH.INPR_FIX, 1);
77
+ assert.equal(g.gates.acceptance.rate, 1, "duplicate eid counted once");
78
+ assert.equal(g.gates.malformedRate, 1);
79
+ assert.equal(g.gates.ci.rate, 0.5);
80
+ assert.deepEqual(g.issueFlow.created, { pipeline: 1 });
81
+ assert.equal(g.issueFlow.net, 0);
82
+ const u = report.groups.find((x) => x.version === "unknown");
83
+ assert.deepEqual(u.issueFlow.created, { "review-finding": 2, human: 1 });
84
+ assert.equal(u.funnel.validated, 1);
85
+ assert.equal(u.funnel.falsePositive, 1);
86
+ assert.equal(u.issueFlow.net, 2);
87
+ assert.equal(u.issueFlow.findingsPerMerge, null);
88
+ assert.ok(!JSON.stringify(report).includes("999999999"), "untrusted record leaked into the report");
89
+ });
90
+ it("matches the run-record field names written by the engine seam", () => {
91
+ const runs = runsFromRecords(parseTelemetryComments(load("comments-201.json"), ENV));
92
+ assert.equal(runs.length, 1);
93
+ assert.equal(runs[0].finished, true);
94
+ assert.equal(runs[0].phases.review.cr, 500000);
95
+ });
96
+ const rec = (run, ts, event, x = {}) => ({ event, repo: "o/r", issue: 500, run, ts, eid: run + ts + event, ...x });
97
+ it("collapses a retried issue (two run ids) to the latest run", () => {
98
+ const runs = runsFromRecords([
99
+ rec("r_500_1", "2026-01-01T00:00:00Z", "phase_attempt", { phase: "build", cache_read_tokens: 2000000 }),
100
+ rec("r_500_1", "2026-01-01T00:01:00Z", "run_terminal", { reason: "engine-error" }),
101
+ rec("r_500_2", "2026-01-02T00:00:00Z", "phase_attempt", { phase: "build", cache_read_tokens: 1000000 }),
102
+ rec("r_500_2", "2026-01-02T00:01:00Z", "run_terminal", { reason: "merged" }),
103
+ ]);
104
+ assert.equal(runs.length, 1);
105
+ assert.equal(runs[0].reason, "merged");
106
+ assert.equal(runs[0].phases.build.cr, 1000000);
107
+ });
108
+ it("lets an unfinished latest run win over an earlier finished retried run", () => {
109
+ const runs = runsFromRecords([
110
+ rec("r_500_1", "2026-01-01T00:00:00Z", "phase_attempt", { phase: "build", cache_read_tokens: 2000000 }),
111
+ rec("r_500_1", "2026-01-01T00:01:00Z", "run_terminal", { reason: "merged" }),
112
+ rec("r_500_2", "2026-01-02T00:00:00Z", "phase_attempt", { phase: "build", cache_read_tokens: 1000000 }),
113
+ ]);
114
+ assert.equal(runs.length, 1);
115
+ assert.equal(runs[0].finished, false);
116
+ });
117
+ });
118
+
119
+ describe("local runs, joins, version delta and low-n", () => {
120
+ const runs = () => readLocalRuns(fx("runs"));
121
+ const agg = (opts = {}) => aggregate({ runs: runs() }, { releaseTimes: [], ...opts });
122
+
123
+ it("applies the cost-report counting rules", () => {
124
+ const r = agg();
125
+ assert.deepEqual(r.excluded, { unfinished: 1, noUsage: 1 });
126
+ const g = r.groups.find((x) => x.version === "1.16.0");
127
+ assert.equal(g.n, 2);
128
+ assert.equal(g.cost.cacheReadMean, 4500000);
129
+ assert.equal(g.cost.perPhase.build, 4000000);
130
+ assert.equal(g.cost.perPhase.remediate, 0);
131
+ assert.equal(g.cost.perMergedIssue, 9000000);
132
+ assert.equal(g.flow.mergedPct, 0.5);
133
+ assert.equal(g.flow.needsHumanPct, 0.5);
134
+ assert.equal(g.flow.remediationPct, 0.5);
135
+ assert.equal(g.cost.usdN, 1);
136
+ assert.equal(g.time.wall.p50, 1500000);
137
+ const legacy = r.groups.find((x) => x.version === "unknown");
138
+ assert.equal(legacy.cost.cacheReadMean, 4000000, "legacy PHASE_COMMIT usage counted when no attempt events exist");
139
+ assert.equal(legacy.flow.codesN, 0, "old logs have no outcome codes");
140
+ });
141
+ it("computes the delta against the previous version of the same repo", () => {
142
+ const g = agg().groups.find((x) => x.version === "1.17.0");
143
+ assert.equal(g.delta.against, "1.16.0");
144
+ assert.equal(g.delta.cacheReadMean, 2000000 - 4500000);
145
+ assert.equal(g.delta.mergedPct, 0.5);
146
+ assert.equal(agg().groups.find((x) => x.version === "1.16.0").delta, null);
147
+ });
148
+ it("flags samples under 10 and honours --min-n", () => {
149
+ assert.ok(agg().groups.every((g) => g.lowN));
150
+ assert.equal(agg({ minN: 2 }).groups.find((x) => x.version === "1.16.0").lowN, false);
151
+ assert.match(renderMarkdown(agg()), /low-n/);
152
+ });
153
+ it("agrees with scripts/cost-report.sh on the same logs", { skip: spawnSync("jq", ["--version"]).status !== 0 }, () => {
154
+ writeFileSync(join(td, "rel.txt"), "");
155
+ const out = spawnSync("bash", [join(here, "..", "..", "scripts", "cost-report.sh"), "--runs-dir", fx("runs"), "--release-times", join(td, "rel.txt")], { encoding: "utf8" }).stdout;
156
+ const row = out.split("\n").find((l) => l.startsWith("| 1.16.0 |"));
157
+ assert.ok(row?.includes("| 4500k |"), row);
158
+ assert.ok(row?.includes("| 9000k |"), row);
159
+ });
160
+ it("--since filters by version or date", () => {
161
+ assert.deepEqual(agg({ ...parseSince("1.17.0") }).groups.map((g) => g.version), ["1.17.0"]);
162
+ assert.ok(agg({ ...parseSince("2026-10-03") }).groups.every((g) => g.version !== "1.16.0"));
163
+ });
164
+ it("estimates versions of unversioned records from release times", () => {
165
+ const r = aggregate({ runs: runs().filter((x) => x.issue === 105) }, { releaseTimes: [{ v: "1.15.0", t: 1 }] });
166
+ assert.equal(r.groups[0].version, "1.15.0~");
167
+ });
168
+ });
169
+
170
+ describe("n/a degradation and rendering", () => {
171
+ it("renders n/a, never NaN or undefined, for missing data and empty input", () => {
172
+ const old = { source: "local", repo: "o/r", version: "1.0.0", issue: 1, finished: true, hydrated: false, usageEvents: 1, reason: "merged", code: null, remediated: false, phases: { build: { cr: 10, out: 0, usd: null, ms: null, attempts: 1 } } };
173
+ const r = aggregate({ runs: [old] });
174
+ const md = renderMarkdown(r);
175
+ assert.match(md, /n\/a/);
176
+ assert.doesNotMatch(md, /NaN|undefined|Infinity/);
177
+ assert.doesNotMatch(renderCompact(r), /NaN|undefined/);
178
+ assert.match(renderMarkdown(aggregate({})), /No data/);
179
+ assert.equal(percentile([], 90), null);
180
+ });
181
+ it("json output is parseable and carries every section", () => {
182
+ const { gh } = fakeGh();
183
+ const report = buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
184
+ const parsed = JSON.parse(renderJson(report));
185
+ for (const k of ["cost", "time", "flow", "funnel", "gates", "issueFlow"]) assert.ok(k in parsed.groups[0]);
186
+ const md = renderMarkdown(report);
187
+ for (const h of ["### Cost", "### Time", "### Flow", "### Review funnel", "### Gates", "### Issue flow"]) assert.ok(md.includes(h), h);
188
+ });
189
+ it("orders versions numerically", () => {
190
+ assert.ok(compareVersions("1.9.0", "1.10.0") < 0);
191
+ assert.ok(compareVersions("1.10.0~", "1.10.0") === 0);
192
+ });
193
+ });
194
+
195
+ describe("cache", () => {
196
+ it("refreshes incrementally from the updated_at watermark and survives deletion or corruption", () => {
197
+ const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache") };
198
+ const a = fakeGh();
199
+ const first = fetchGithubRecords({ repo: "o/r", gh: a.gh, env });
200
+ assert.equal(first.fromCache, false);
201
+ assert.ok(!a.calls[0].includes("since="));
202
+ const b = fakeGh();
203
+ const second = fetchGithubRecords({ repo: "o/r", gh: b.gh, env });
204
+ assert.equal(second.fromCache, true);
205
+ assert.match(b.calls[0], /since=2026-10-06T12:55:00Z/); // watermark minus the 5 minute overlap window
206
+ assert.deepEqual(second.records.map((r) => r.eid).sort(), first.records.map((r) => r.eid).sort());
207
+ // corrupt cache -> treated as empty -> same result from a full fetch
208
+ writeFileSync(join(cacheDir("o/r", env), "cache.json"), "{not json");
209
+ const c = fakeGh();
210
+ const third = fetchGithubRecords({ repo: "o/r", gh: c.gh, env });
211
+ assert.equal(third.fromCache, false);
212
+ assert.equal(third.records.length, first.records.length);
213
+ // --refresh ignores a good cache
214
+ const d = fakeGh();
215
+ fetchGithubRecords({ repo: "o/r", gh: d.gh, env, refresh: true });
216
+ assert.ok(!d.calls[0].includes("since="));
217
+ assert.equal(toModel("o/r", third).issues.length, 5);
218
+ });
219
+ it("saves a partial checkpoint on a mid-crawl failure and the next run resumes from it", () => {
220
+ const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-partial") };
221
+ const ok = fakeGh();
222
+ const full = fetchGithubRecords({ repo: "o/r", gh: ok.gh, env: { ...ENV }, useCache: false });
223
+ const withComments = full.items.filter((i) => ok.calls.some((c) => c.includes(`/issues/${i.number}/comments`)));
224
+ assert.ok(withComments.length >= 2, "fixture needs at least two items with comments");
225
+ let commentCalls = 0;
226
+ // The real API honours sort=updated&direction=asc, which the watermark resume relies on; the fixture is unsorted.
227
+ const asc = (gh) => (args) => {
228
+ const out = gh(args);
229
+ return args.some((a) => /\/issues\?/.test(a)) ? JSON.stringify(JSON.parse(out).sort((x, y) => x.updated_at.localeCompare(y.updated_at))) : out;
230
+ };
231
+ const flaky = asc((args) => {
232
+ if (args[args.length - 1].endsWith("/comments") && ++commentCalls === 2) throw new Error("rate limited");
233
+ return ok.gh(args);
234
+ });
235
+ assert.throws(() => fetchGithubRecords({ repo: "o/r", gh: flaky, env }), /rate limited/);
236
+ const saved = JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8"));
237
+ assert.ok(saved.watermark, "partial watermark persisted");
238
+ assert.ok(Object.keys(saved.items).length > 0 && Object.keys(saved.items).length < full.items.length);
239
+ const b = fakeGh();
240
+ const resumed = fetchGithubRecords({ repo: "o/r", gh: asc(b.gh), env });
241
+ assert.equal(resumed.fromCache, true);
242
+ assert.match(b.calls[0], /since=/);
243
+ assert.deepEqual(resumed.records.map((r) => r.eid).sort(), full.records.map((r) => r.eid).sort());
244
+ });
245
+ it("never skips an item updated mid-crawl (keyset crawl, not page-number pagination)", () => {
246
+ const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-midcrawl") };
247
+ const ts = (n) => new Date(Date.UTC(2026, 9, 1) + n * 60000).toISOString().replace(/\.\d{3}Z$/, "Z");
248
+ const store = Array.from({ length: 250 }, (_, i) => ({ number: i + 1, updated_at: ts(i + 1), created_at: ts(0), state: "open", comments: 0, labels: [], user: { type: "Bot" } }));
249
+ let listCalls = 0;
250
+ let maxUpdated = 250;
251
+ // Page-aware fake of the real API: asc by updated_at, honours since (inclusive), per_page and page. After the first
252
+ // listing call an already-served item is bumped, which moves it to the end of the ordering.
253
+ const gh = (args) => {
254
+ const path = args.find((a) => a.startsWith("repos/"));
255
+ const q = new URLSearchParams(path.split("?")[1]);
256
+ listCalls++;
257
+ const since = q.get("since");
258
+ const sorted = store.filter((i) => !since || i.updated_at >= since).sort((a, b) => a.updated_at.localeCompare(b.updated_at) || a.number - b.number);
259
+ const per = Number(q.get("per_page")), page = Number(q.get("page") ?? 1);
260
+ const out = JSON.stringify(sorted.slice((page - 1) * per, page * per));
261
+ if (listCalls === 1) store[4].updated_at = ts(++maxUpdated);
262
+ return out;
263
+ };
264
+ const first = fetchGithubRecords({ repo: "o/r", gh, env });
265
+ assert.equal(first.items.length, 250, "every item is cached after the crawl, none skipped by the shift");
266
+ assert.equal(new Set(first.items.map((i) => i.number)).size, 250);
267
+ // The bumped item moved past the crawl cursor, so the next incremental run (and its overlap window) still sees it.
268
+ store[9].updated_at = ts(++maxUpdated); // another item updated after the first run
269
+ const second = fetchGithubRecords({ repo: "o/r", gh, env });
270
+ assert.equal(second.fromCache, true);
271
+ assert.equal(second.items.length, 250);
272
+ assert.equal(second.items.find((i) => i.number === 10).updatedAt, store[9].updated_at);
273
+ assert.equal(second.items.find((i) => i.number === 5).updatedAt, store[4].updated_at);
274
+ });
275
+ it("terminates and drains a full page whose items all share one updated_at", () => {
276
+ const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-sameTs") };
277
+ const store = Array.from({ length: 230 }, (_, i) => ({ number: i + 1, updated_at: "2026-10-02T00:00:00Z", created_at: "2026-10-01T00:00:00Z", comments: 0, labels: [], user: { type: "Bot" } }));
278
+ const gh = (args) => {
279
+ const q = new URLSearchParams(args.find((a) => a.startsWith("repos/")).split("?")[1]);
280
+ const per = Number(q.get("per_page")), page = Number(q.get("page") ?? 1);
281
+ const since = q.get("since");
282
+ return JSON.stringify(store.filter((i) => !since || i.updated_at >= since).slice((page - 1) * per, page * per));
283
+ };
284
+ assert.equal(fetchGithubRecords({ repo: "o/r", gh, env }).items.length, 230);
285
+ });
286
+ it("rejects an unsafe repo before any gh call", () => {
287
+ const { gh, calls } = fakeGh();
288
+ assert.throws(() => fetchGithubRecords({ repo: "o/r; rm -rf /", gh }), /invalid repo/);
289
+ assert.equal(calls.length, 0);
290
+ });
291
+ });
292
+
293
+ describe("cli", () => {
294
+ it("parses flags and rejects bad input", () => {
295
+ const o = parseArgs(["--since", "1.2.0", "--repo=o/r", "--json", "--refresh", "--min-n", "3"]);
296
+ assert.deepEqual([o.since, o.repo, o.json, o.refresh, o.minN], ["1.2.0", "o/r", true, true, 3]);
297
+ assert.throws(() => parseArgs(["--since"]), /needs a value/);
298
+ assert.throws(() => parseArgs(["--bogus"]), /unknown argument/);
299
+ assert.throws(() => parseSince("yesterday"), /--since must be/);
300
+ });
301
+ it("--help exits 0 and importing the module did not run it", () => {
302
+ const r = spawnSync(process.execPath, [join(here, "index.mjs"), "--help"], { encoding: "utf8" });
303
+ assert.equal(r.status, 0);
304
+ assert.match(r.stdout, /--since/);
305
+ assert.match(r.stdout, /--refresh/);
306
+ });
307
+ it("runStats returns 2 for an unusable repo", async () => {
308
+ const real = process.stderr.write;
309
+ process.stderr.write = () => true;
310
+ try { assert.equal(await runStats(["--repo", "bad repo"]), 2); } finally { process.stderr.write = real; }
311
+ });
312
+ });
@@ -10,7 +10,7 @@ install: extras
10
10
 
11
11
  **Input**: $ARGUMENTS
12
12
 
13
- Scan the entire development environment for rot and fix it. This is a maintenance command — run periodically or after large orchestration batches. It covers 6 domains: stale labels, orphaned issues, worktree/branch pruning, milestone hygiene, and Project board sync.
13
+ Scan the entire development environment for rot and fix it. This is a maintenance command — run periodically or after large orchestration batches. It covers 6 domains: stale labels, orphaned issues and dead claims boards, worktree/branch pruning, milestone hygiene, and Project board sync.
14
14
 
15
15
  **NEVER use plan mode (EnterPlanMode)** — it breaks execution context.
16
16
 
@@ -46,7 +46,7 @@ All `{GH_REPO}`, `{GH_FLAG}`, `{REPO_PATH}`, `{STAGING_BRANCH}`, `{PROJECT_BOARD
46
46
  | Input | Action |
47
47
  |-------|--------|
48
48
  | `labels` or empty | Fix stale/missing workflow labels on closed issues; detect OPEN issues stuck in intermediate states (Phase 1D) |
49
- | `orphans` | Close open issues whose PRs are already merged |
49
+ | `orphans` | Close open issues whose PRs are already merged (Phase 2A/2B), and close dead orchestrate claims boards (Phase 2C) |
50
50
  | `branches` | Prune worktrees and remote branches for merged PRs |
51
51
  | `milestones` | Report milestones with 0 open issues (advisory — never closes) |
52
52
  | `board` | Sync closed issues to Project board with correct terminal state |
@@ -234,6 +234,89 @@ gh issue edit $NUM {GH_FLAG} --remove-label "workflow:in-review,workflow:remedia
234
234
 
235
235
  Also check open issues with `workflow:building` — same pattern (search for merged PRs referencing them).
236
236
 
237
+ ### 2C: Close dead orchestrate claims boards <!-- Added: forge#3699 -->
238
+
239
+ Each `/orchestrate` batch creates one claims board (`orchestrate: claims board for batch <id>`, label `automation`) with no PR of its own, so 2A/2B never match it. `/orchestrate` Phase 5 Step 5C closes the board on a clean finish; batches that end by compaction, freeze, crash or a fresh-context resume never reach it. This sweep is the durable safety net. Runs for `orphans` and `all`; respects `DRY_RUN`.
240
+
241
+ A board is closed only when **all** identity checks pass and **one** closing condition holds:
242
+
243
+ - Identity: title starts with `orchestrate: claims board for batch`, body contains `<!-- FORGE:COORD_ISSUE -->`, and the author is trusted: `author_association` `OWNER`, `MEMBER` or `COLLABORATOR`, or a Bot account (the pipeline's GitHub App, reported as `CONTRIBUTOR`). A look-alike issue from an outside account is never closed.
244
+ - Lease/claim comments count only when trusted (same `author_association` `OWNER`/`MEMBER`/`COLLABORATOR` or Bot predicate as the board, mirroring `scripts/trusted-comments.sh`) and when the marker starts the comment body; an untrusted or quoted marker is ignored, so a board falls back to its trusted events or its creation time. Unlike `/orchestrate`'s no-authorship lease check (forge#2644, a bounded self-expiring dispatch refusal), cleanup's action is a destructive issue close, so it must not trust arbitrary commenters.
245
+ - Close when the last `FORGE:LEASE*` comment is a `FORGE:LEASE_RELEASED`.
246
+ - Close when the last `FORGE:LEASE*`/`FORGE:CLAIM*` comment is older than `BOARD_STALE_HOURS` (default 6, far above the 15 minute lease TTL).
247
+ - Close when there is no lease/claim comment and the issue itself is older than `BOARD_STALE_HOURS`.
248
+ - Never close a board whose last lease event is an unreleased `FORGE:LEASE` younger than `BOARD_STALE_HOURS`.
249
+
250
+ ```bash
251
+ BOARD_STALE_HOURS=${BOARD_STALE_HOURS:-6}
252
+ BOARD_CUTOFF=$(date -u -d "${BOARD_STALE_HOURS} hours ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
253
+ || date -u -v-${BOARD_STALE_HOURS}H +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
254
+ || echo "")
255
+ BOARDS_CLOSED=0
256
+
257
+ if [ -z "$BOARD_CUTOFF" ]; then
258
+ echo "WARNING: cannot compute BOARD_STALE_HOURS cutoff — only released boards will be closed"
259
+ fi
260
+
261
+ # REST (not `gh issue list`): it exposes author_association and user.type. Boards are created by the
262
+ # pipeline's GitHub App, which GitHub reports as CONTRIBUTOR, so trust is the same predicate as
263
+ # scripts/trusted-comments.sh: OWNER/MEMBER/COLLABORATOR or a Bot account.
264
+ BOARDS=$(gh api --paginate "repos/{GH_REPO}/issues?state=open&labels=automation&per_page=100" 2>/dev/null \
265
+ | jq -rs '.[][] | select(.pull_request == null)
266
+ | select(((.title // "") | startswith("orchestrate: claims board for batch"))
267
+ and ((.body // "") | contains("<!-- FORGE:COORD_ISSUE -->"))
268
+ and (((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")) or ((.user.type // "") == "Bot")))
269
+ | "\(.number)\t\(.created_at)"' 2>/dev/null || echo "")
270
+
271
+ while IFS=$'\t' read -r BNUM BCREATED; do
272
+ [ -z "$BNUM" ] && continue
273
+
274
+ # --paginate emits one array per page: slurp (jq -s) and flatten BEFORE taking `last`,
275
+ # otherwise a board with >30 comments reads a stale event from an earlier page.
276
+ # Lease/claim comments are trust-filtered with the same predicate as the board (OWNER/MEMBER/
277
+ # COLLABORATOR or Bot; mirrors scripts/trusted-comments.sh) and the marker must LEAD the body, so an
278
+ # outside commenter can neither post a fake FORGE:LEASE_RELEASED nor a fake fresh FORGE:LEASE/CLAIM,
279
+ # and a quoted marker in prose never matches.
280
+ LAST_LEASE=$(gh api --paginate "repos/{GH_REPO}/issues/${BNUM}/comments" 2>/dev/null \
281
+ | jq -s '[.[][] | select((((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")) or ((.user.type // "") == "Bot")))
282
+ | select((.body // "") | test("\\A\\s*<!-- FORGE:LEASE"))] | last // empty
283
+ | {released: (.body | test("\\A\\s*<!-- FORGE:LEASE_RELEASED")), at: .created_at}' 2>/dev/null || echo "")
284
+ LAST_ACTIVITY=$(gh api --paginate "repos/{GH_REPO}/issues/${BNUM}/comments" 2>/dev/null \
285
+ | jq -rs '[.[][] | select((((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")) or ((.user.type // "") == "Bot")))
286
+ | select((.body // "") | test("\\A\\s*<!-- FORGE:(LEASE|CLAIM)"))] | last // empty | .created_at' 2>/dev/null || echo "")
287
+
288
+ REASON=""
289
+ if [ -n "$LAST_LEASE" ] && [ "$(echo "$LAST_LEASE" | jq -r '.released')" = "true" ]; then
290
+ REASON="last lease event is FORGE:LEASE_RELEASED"
291
+ elif [ -n "$BOARD_CUTOFF" ]; then
292
+ # ISO-8601 UTC timestamps compare correctly as strings
293
+ LAST_SEEN="${LAST_ACTIVITY:-$BCREATED}"
294
+ if [ -n "$LAST_SEEN" ] && [ "$LAST_SEEN" \< "$BOARD_CUTOFF" ]; then
295
+ REASON="no lease/claim activity since ${LAST_SEEN} (older than ${BOARD_STALE_HOURS}h)"
296
+ fi
297
+ fi
298
+
299
+ if [ -z "$REASON" ]; then
300
+ echo "KEEP: board #$BNUM has a live or recent lease"
301
+ continue
302
+ fi
303
+
304
+ if [ "${DRY_RUN:-false}" = "true" ]; then
305
+ echo "DRY-RUN: would close claims board #$BNUM — $REASON"
306
+ continue
307
+ fi
308
+
309
+ # Closing an already-closed issue is a no-op; a gh failure is non-fatal.
310
+ gh issue close "$BNUM" {GH_FLAG} --comment "Closing dead claims board — ${REASON}.
311
+
312
+ Closed automatically by \`/cleanup\` Phase 2C." 2>/dev/null \
313
+ && { echo "Closed claims board #$BNUM ($REASON)"; BOARDS_CLOSED=$((BOARDS_CLOSED + 1)); } \
314
+ || echo "WARNING: failed to close claims board #$BNUM — non-fatal" # allowlist:check-command-side-effects
315
+ done <<< "$BOARDS"
316
+
317
+ echo "Claims boards closed: $BOARDS_CLOSED"
318
+ ```
319
+
237
320
  ---
238
321
 
239
322
  ## Phase 3: Worktree & Branch Pruning
@@ -599,6 +682,11 @@ fi
599
682
  |-------|-----------|--------|
600
683
  | #{N} | PR #{M} | Closed |
601
684
 
685
+ ### Claims Boards Closed
686
+ | Issue | Reason | Action |
687
+ |-------|--------|--------|
688
+ | #{N} | LEASE_RELEASED / stale > {BOARD_STALE_HOURS}h | Closed |
689
+
602
690
  ### Worktrees & Branches Pruned
603
691
  | Type | Count |
604
692
  |------|-------|
@@ -56,13 +56,15 @@ else
56
56
  # Step 2: Check for open review-finding issues referencing each PR
57
57
  OPEN_FINDINGS_SUMMARY=""
58
58
  for pr_num in $ALL_PR_NUMBERS; do
59
+ # Anchored post-filter (same as review-pr-staging.md): the search is a coarse candidate fetch only
60
+ FINDING_PR_FILTER='[.[] | select((.title | endswith("PR #" + $n + ")")) or ((.body // "") | test("(^|\\n)\\*\\*Source\\*\\*: PR #" + $n + "([^0-9]|$)")))] | .[] | " - #\(.number): \(.title)"' # FINDING_PR_FILTER
59
61
  OPEN_FINDINGS=$(gh issue list -R {GH_REPO} \
60
62
  --label "review-finding" \
61
63
  --state open \
62
64
  --search "PR #${pr_num}" \
63
65
  --limit 20 \
64
- --json number,title \
65
- --jq ".[] | \" - #\(.number): \(.title)\"" 2>/dev/null)
66
+ --json number,title,body 2>/dev/null \
67
+ | jq -r --arg n "${pr_num}" "$FINDING_PR_FILTER" 2>/dev/null) # uses FINDING_PR_FILTER
66
68
 
67
69
  if [ -n "$OPEN_FINDINGS" ]; then
68
70
  OPEN_FINDINGS_SUMMARY="${OPEN_FINDINGS_SUMMARY}
@@ -304,13 +306,15 @@ if [ -z "$TRAIN_PR_NUMBER" ]; then
304
306
  TRAIN_STATUS="NO_TRAIN — no open staging→main PR. Run /review-pr-staging to open one."
305
307
  else
306
308
  # Step 2: Check whether the train is held (has blocking findings)
309
+ # Anchored post-filter (same as review-pr-staging.md): the search is a coarse candidate fetch only
310
+ FINDING_PR_FILTER='[.[] | select((.title | endswith("PR #" + $n + ")")) or ((.body // "") | test("(^|\\n)\\*\\*Source\\*\\*: PR #" + $n + "([^0-9]|$)")))] | .[] | " - #\(.number): \(.title)"' # FINDING_PR_FILTER
307
311
  HELD_BY=$(gh issue list {GH_FLAG} \
308
312
  --label "review-finding" \
309
313
  --state open \
310
314
  --search "PR #${TRAIN_PR_NUMBER}" \
311
315
  --limit 20 \
312
- --json number,title \
313
- --jq '[.[] | "#\(.number): \(.title)"] | join("\n")' 2>/dev/null || true)
316
+ --json number,title,body 2>/dev/null \
317
+ | jq -r --arg n "${TRAIN_PR_NUMBER}" "$FINDING_PR_FILTER" 2>/dev/null | sed 's/^ - //' || true) # uses FINDING_PR_FILTER
314
318
 
315
319
  if [ -n "$HELD_BY" ]; then
316
320
  TRAIN_STATUS="HELD"
@@ -183,6 +183,70 @@ else
183
183
  fi
184
184
  ```
185
185
 
186
+ ### 2A.5: Version Sync Pre-flight
187
+
188
+ Applies ONLY when `SOURCE=$STAGING_BRANCH` and `PR_TARGET=$DEFAULT_BRANCH` (a staging deploy). Skip for every other lane. Publishing bumps the version on the default branch only, so the deploy PR fails the Deploy Version Guard until staging carries that version. The publish workflow normally syncs it; this pre-flight is the safety net when that sync failed. <!-- Added: forge#3695 -->
189
+
190
+ Rule (same as the publish workflow): sync only when the default branch `package.json` version is strictly ahead of the source branch's. If the source is already at or above it, skip. The sync is a version-only commit (`package.json` and `.claude-plugin/plugin.json`), never a merge of the default branch.
191
+
192
+ ```bash
193
+ CONFIG_FILE="${FORGE_CONFIG:-forge.yaml}"
194
+ STAGING_BRANCH=$(yq '.branches.staging // "staging"' "$CONFIG_FILE" 2>/dev/null || echo "staging")
195
+ DEFAULT_BRANCH=$(yq '.branches.default // "main"' "$CONFIG_FILE" 2>/dev/null || echo "main")
196
+ REPO_PATH=$(yq '.paths.root' "$CONFIG_FILE" 2>/dev/null || echo "$(git rev-parse --show-toplevel)")
197
+
198
+ if [ "$SOURCE" = "$STAGING_BRANCH" ] && [ "$PR_TARGET" = "$DEFAULT_BRANCH" ]; then
199
+ cd "$REPO_PATH"
200
+ git fetch origin "$SOURCE" "$DEFAULT_BRANCH" || { echo "version sync: fetch failed — continuing (version guard will report)"; }
201
+ MAIN_VERSION=$(git show "origin/$DEFAULT_BRANCH:package.json" | node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(0, 'utf8')).version)")
202
+ SRC_VERSION=$(git show "origin/$SOURCE:package.json" | node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(0, 'utf8')).version)")
203
+
204
+ # Numeric semver compare in node (never a string compare: 1.10.0 > 1.9.0).
205
+ if MAIN_VERSION="$MAIN_VERSION" SRC_VERSION="$SRC_VERSION" node -e "
206
+ const p = (v) => v.split('.').map(Number);
207
+ const [m, s] = [p(process.env.MAIN_VERSION), p(process.env.SRC_VERSION)];
208
+ for (let i = 0; i < 3; i++) { if (m[i] !== s[i]) process.exit(m[i] > s[i] ? 0 : 1); }
209
+ process.exit(1);
210
+ "; then
211
+ echo "$DEFAULT_BRANCH is at $MAIN_VERSION, ahead of $SOURCE ($SRC_VERSION) — version sync needed"
212
+ if [ "${DRY_RUN:-false}" = "true" ]; then
213
+ echo "[dry-run] would sync version $MAIN_VERSION to $SOURCE"
214
+ else
215
+ SYNC_DIR=$(mktemp -d)
216
+ git worktree add --detach "$SYNC_DIR" "origin/$SOURCE"
217
+ (
218
+ cd "$SYNC_DIR" || exit 1
219
+ VERSION="$MAIN_VERSION" node -e "
220
+ const fs = require('fs');
221
+ for (const f of ['package.json', '.claude-plugin/plugin.json']) {
222
+ const j = JSON.parse(fs.readFileSync(f, 'utf8'));
223
+ j.version = process.env.VERSION;
224
+ fs.writeFileSync(f, JSON.stringify(j, null, 2) + '\\n');
225
+ }
226
+ "
227
+ git add package.json .claude-plugin/plugin.json
228
+ git commit -m "chore: sync version $MAIN_VERSION to $SOURCE"
229
+ if ! git push origin "HEAD:$SOURCE"; then
230
+ # Push rejected (protected branch or race): fall back to a sync PR.
231
+ SYNC_BRANCH="chore/sync-main-into-staging-$MAIN_VERSION"
232
+ git push -f origin "HEAD:refs/heads/$SYNC_BRANCH" \
233
+ && SYNC_PR=$(gh pr create $GH_FLAG --base "$SOURCE" --head "$SYNC_BRANCH" \
234
+ --title "chore: sync version $MAIN_VERSION to $SOURCE" \
235
+ --body "Version-only sync so the deploy PR passes the Deploy Version Guard. Generated by deploy-pr 2A.5.") \
236
+ && echo "Opened sync PR $SYNC_PR — wait for CI green and merge (never bypass gating), then re-run"
237
+ fi
238
+ )
239
+ git worktree remove --force "$SYNC_DIR" 2>/dev/null || true
240
+ git fetch origin "$SOURCE" 2>/dev/null || true # re-fetch before 2B/2C
241
+ fi
242
+ else
243
+ echo "$SOURCE ($SRC_VERSION) is at or above $DEFAULT_BRANCH ($MAIN_VERSION) — no version sync needed"
244
+ fi
245
+ fi
246
+ ```
247
+
248
+ If a sync PR was opened instead of a direct push, wait for it to pass CI and merge through the normal gates before continuing to 2B; do not create or update the deploy PR against a stale source version. Phase 2B onward and the `PR_NUMBER` handoff are unchanged.
249
+
186
250
  ### 2B: Gather Commit Summary (for new PR body)
187
251
 
188
252
  Skip if PR already exists.
@@ -312,11 +312,18 @@ echo "Failure detail: ${FAILURE_DETAIL}"
312
312
 
313
313
  ### Phase 1D: Apply targeted fix
314
314
 
315
- Work on the PR's head branch directly. Check out the branch in the repo root (not a worktree — we push to the existing PR head):
315
+ Work on the PR's head in a dedicated detached worktree, never in the main checkout (it is shared with the operator and other agents). The fix is pushed to the existing PR head with `HEAD:refs/heads/...`. Create the worktree once (reuse it on later attempts) and run every fix, format, test, commit and push step from inside it (`cd "$FIX_WT"`); Phase 2A removes it on every exit path:
316
316
 
317
317
  ```bash
318
- git checkout "$FIX_TARGET" 2>/dev/null || git checkout -b "$FIX_TARGET" "origin/${FIX_TARGET}" 2>/dev/null
319
- git pull origin "$FIX_TARGET" --rebase 2>/dev/null || true
318
+ if [ -z "${FIX_WT:-}" ] || [ ! -d "$FIX_WT" ]; then
319
+ FIX_WT=$(mktemp -d)
320
+ git -C "$REPO_PATH" fetch origin "$FIX_TARGET" \
321
+ && git -C "$REPO_PATH" worktree add --detach "$FIX_WT" "origin/${FIX_TARGET}" \
322
+ || { echo "ERROR: could not create fix worktree"; rmdir "$FIX_WT" 2>/dev/null; LOOP_STATUS="blocked"; break; }
323
+ fi
324
+ git -C "$FIX_WT" fetch origin "$FIX_TARGET" 2>/dev/null || true
325
+ git -C "$FIX_WT" rebase "origin/${FIX_TARGET}" 2>/dev/null || true
326
+ cd "$FIX_WT"
320
327
  ```
321
328
 
322
329
  **Apply fix based on failure class**:
@@ -440,13 +447,14 @@ echo "Committed fix for attempt ${ATTEMPT}"
440
447
  **Push** (normal push only — no force):
441
448
 
442
449
  ```bash
443
- git push origin "$FIX_TARGET" 2>&1
450
+ git -C "$FIX_WT" push origin HEAD:"refs/heads/${FIX_TARGET}" 2>&1
444
451
  PUSH_EXIT=$?
445
452
 
446
453
  if [ "$PUSH_EXIT" -ne 0 ]; then
447
454
  echo "Push failed (exit ${PUSH_EXIT}) — attempting pull-rebase then re-push..."
448
- git pull origin "$FIX_TARGET" --rebase 2>&1 && \
449
- git push origin "$FIX_TARGET" 2>&1
455
+ git -C "$FIX_WT" fetch origin "$FIX_TARGET" 2>&1 && \
456
+ git -C "$FIX_WT" rebase "origin/${FIX_TARGET}" 2>&1 && \
457
+ git -C "$FIX_WT" push origin HEAD:"refs/heads/${FIX_TARGET}" 2>&1
450
458
  PUSH_EXIT=$?
451
459
  fi
452
460
 
@@ -476,6 +484,12 @@ After the loop exits, determine the final status and report.
476
484
  ### 2A: Determine final status
477
485
 
478
486
  ```bash
487
+ # Remove the fix worktree on every exit path (success, failure, timeout, blocked).
488
+ cd "$REPO_PATH"
489
+ if [ -n "${FIX_WT:-}" ] && [ -d "$FIX_WT" ]; then
490
+ git -C "$REPO_PATH" worktree remove --force "$FIX_WT" 2>/dev/null || true
491
+ fi
492
+
479
493
  case "$LOOP_STATUS" in
480
494
  "success")
481
495
  FINAL_STATUS="success"