forgedock 1.17.0 → 1.19.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 (143) hide show
  1. package/bin/engine/outcome-codes.mjs +37 -2
  2. package/bin/engine/phases.mjs +207 -106
  3. package/bin/engine/runlog.mjs +17 -2
  4. package/bin/engine/telemetry-seam.mjs +3 -0
  5. package/bin/engine/telemetry.test.mjs +3 -2
  6. package/bin/engine-cli.mjs +37 -2
  7. package/bin/engine.mjs +221 -15
  8. package/bin/forgedock.mjs +1 -1
  9. package/bin/hooks/pre-tool-use.mjs +65 -32
  10. package/bin/runner.mjs +125 -6
  11. package/bin/stats/aggregate.mjs +7 -3
  12. package/bin/stats/cache.mjs +7 -3
  13. package/bin/stats/github-source.mjs +203 -24
  14. package/bin/stats/index.mjs +46 -9
  15. package/bin/stats/render.mjs +1 -1
  16. package/bin/stats/stats.test.mjs +264 -15
  17. package/commands/cleanup.md +10 -3
  18. package/commands/deploy-pr.md +64 -0
  19. package/commands/fix-ci.md +20 -6
  20. package/commands/milestone.md +34 -17
  21. package/commands/orchestrate/phase-4-execution.md +11 -4
  22. package/commands/review-pr-staging.md +145 -522
  23. package/commands/review-pr.md +496 -3124
  24. package/commands/test-gate.md +25 -10
  25. package/commands/work-on/build/architect.md +66 -1
  26. package/commands/work-on/build/implement.md +21 -1
  27. package/commands/work-on/build/validate.md +322 -28
  28. package/commands/work-on/build.md +173 -95
  29. package/commands/work-on/close.md +50 -15
  30. package/commands/work-on/decompose.md +72 -34
  31. package/commands/work-on/investigate.md +16 -12
  32. package/commands/work-on/remediate.md +58 -25
  33. package/commands/work-on/review.md +36 -21
  34. package/commands/work-on.md +1 -2
  35. package/docs/CONFIG.md +132 -26
  36. package/docs/OUTCOME-CODES.md +12 -3
  37. package/docs/RELEASE-NOTES.md +12 -0
  38. package/docs/STATS.md +13 -5
  39. package/docs/TELEMETRY.md +7 -4
  40. package/docs/WORK-ON-RUNTIME.md +24 -0
  41. package/docs/spec/forge-protocol-v1.md +2 -2
  42. package/package.json +2 -2
  43. package/packages/protocol/src/phases.js +5 -5
  44. package/packages/protocol/src/types.js +1 -1
  45. package/scripts/acceptance-red-green.sh +90 -0
  46. package/scripts/acceptance-red-green.test.sh +74 -0
  47. package/scripts/cap-findings.sh +1 -1
  48. package/scripts/cap-findings.test.sh +3 -1
  49. package/scripts/check-contract-scope.test.sh +2 -0
  50. package/scripts/check-edge-cases.sh +143 -0
  51. package/scripts/check-edge-cases.test.sh +175 -0
  52. package/scripts/check-spec-bash.sh +5 -1
  53. package/scripts/check-spec-bash.test.sh +20 -0
  54. package/scripts/check-spec-markers.sh +2 -0
  55. package/scripts/check-test-tmp-cleanup.sh +56 -0
  56. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  57. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  58. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  59. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  60. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  61. package/scripts/ci-local/adapters/common.mjs +115 -0
  62. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  63. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  64. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  65. package/scripts/ci-local/adapters/portability.mjs +104 -0
  66. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  67. package/scripts/ci-local/cli.mjs +50 -0
  68. package/scripts/ci-local/lib.mjs +371 -0
  69. package/scripts/ci-local/plan.mjs +186 -0
  70. package/scripts/ci-local/run.mjs +345 -0
  71. package/scripts/ci-local/scoping.mjs +508 -0
  72. package/scripts/ci-local.sh +97 -0
  73. package/scripts/ci-local.test.sh +919 -0
  74. package/scripts/classify-finding.sh +32 -6
  75. package/scripts/classify-finding.test.sh +23 -0
  76. package/scripts/classify-test-run.sh +69 -0
  77. package/scripts/classify-test-run.test.sh +90 -0
  78. package/scripts/close-parent-c3.test.sh +71 -0
  79. package/scripts/consumer-sweep.sh +569 -0
  80. package/scripts/consumer-sweep.test.sh +357 -0
  81. package/scripts/detect-append-registries.sh +106 -0
  82. package/scripts/detect-append-registries.test.sh +64 -0
  83. package/scripts/diff-size.sh +93 -4
  84. package/scripts/diff-size.test.sh +59 -2
  85. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  86. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  87. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  88. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  89. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  90. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  91. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  92. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  93. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  94. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  95. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  96. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  97. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  98. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  99. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  100. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  101. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  102. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  103. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  104. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  105. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  106. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  107. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  108. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  109. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  110. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  111. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  112. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  113. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  114. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  115. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  116. package/scripts/forge-root.test.sh +14 -12
  117. package/scripts/lib/telemetry.sh +1 -0
  118. package/scripts/publish-staging-sync.test.sh +48 -0
  119. package/scripts/remediate-approved-head.test.sh +2 -0
  120. package/scripts/remediate-basesync.test.sh +1 -1
  121. package/scripts/resolve-deploy-gate.sh +32 -6
  122. package/scripts/resolve-deploy-gate.test.sh +42 -1
  123. package/scripts/review-acceptance-gate.sh +2 -2
  124. package/scripts/review-acceptance-gate.test.sh +20 -0
  125. package/scripts/review-delta.sh +8 -1
  126. package/scripts/review-delta.test.sh +19 -0
  127. package/scripts/review-finalize.sh +1579 -0
  128. package/scripts/review-finalize.test.sh +1040 -0
  129. package/scripts/review-pr-delta-wiring.test.sh +32 -72
  130. package/scripts/review-pr-domain-map.test.sh +18 -13
  131. package/scripts/review-pr-gate2.test.sh +9 -65
  132. package/scripts/review-pr-staging-scope.test.sh +14 -98
  133. package/scripts/review-prepare.sh +53 -15
  134. package/scripts/review-prepare.test.sh +37 -3
  135. package/scripts/review-roster.sh +16 -2
  136. package/scripts/review-roster.test.sh +22 -0
  137. package/scripts/run-shell-tests.sh +5 -0
  138. package/scripts/telemetry.test.sh +2 -0
  139. package/scripts/trusted-comments.test.sh +11 -3
  140. package/scripts/verify-phase-trail.test.sh +8 -0
  141. package/scripts/wait-ci-green.sh +61 -9
  142. package/scripts/wait-ci-green.test.sh +44 -6
  143. package/scripts/with-run-tmpdir.sh +20 -0
@@ -1,8 +1,9 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { tmpDir } from "../tests/_tmp.mjs";
3
4
  import { describe, it, beforeEach, afterEach } from "node:test";
4
5
  import assert from "node:assert/strict";
5
- import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
6
+ import { readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
6
7
  import { tmpdir } from "node:os";
7
8
  import { dirname, join } from "node:path";
8
9
  import { fileURLToPath } from "node:url";
@@ -10,7 +11,7 @@ import { spawnSync } from "node:child_process";
10
11
  import { aggregate, compareVersions, percentile } from "./aggregate.mjs";
11
12
  import { cacheDir } from "./cache.mjs";
12
13
  import { fetchGithubRecords, parseTelemetryComments, runsFromRecords, toModel } from "./github-source.mjs";
13
- import { buildReport, parseArgs, parseSince, runStats } from "./index.mjs";
14
+ import { buildReport, makeProgressPrinter, parseArgs, parseSince, runStats, sinceToTimestamp } from "./index.mjs";
14
15
  import { readLocalRuns } from "./local-source.mjs";
15
16
  import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
16
17
 
@@ -29,6 +30,10 @@ function fakeGh() {
29
30
  const path = args.find((a) => a.startsWith("repos/"));
30
31
  const m = /issues\/(\d+)\/comments$/.exec(path);
31
32
  if (m) return JSON.stringify(comments[m[1]] ?? []);
33
+ if (/issues\/comments\?/.test(path)) { // repo-wide endpoint: every comment, tagged with its issue (fixtures carry no updated_at)
34
+ const all = Object.entries(comments).flatMap(([n, list]) => list.map((c) => ({ ...c, issue_url: `https://api.github.com/repos/o/r/issues/${n}` })));
35
+ return JSON.stringify(/page=1(&|$)/.test(path) ? all : []);
36
+ }
32
37
  const since = /since=([^&]+)/.exec(path)?.[1];
33
38
  return JSON.stringify(issues.filter((i) => !since || i.updated_at >= since));
34
39
  };
@@ -36,7 +41,7 @@ function fakeGh() {
36
41
  }
37
42
 
38
43
  let td;
39
- beforeEach(() => { td = mkdtempSync(join(tmpdir(), "stats-")); });
44
+ beforeEach(() => { td = tmpDir("stats-"); });
40
45
  afterEach(() => rmSync(td, { recursive: true, force: true }));
41
46
 
42
47
  describe("telemetry comment parsing", () => {
@@ -58,9 +63,9 @@ describe("telemetry comment parsing", () => {
58
63
  });
59
64
 
60
65
  describe("GitHub-only report (fresh machine)", () => {
61
- it("rebuilds cost, time, flow, funnel, gates and issue flow from GitHub alone", () => {
66
+ it("rebuilds cost, time, flow, funnel, gates and issue flow from GitHub alone", async () => {
62
67
  const { gh } = fakeGh();
63
- const report = buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
68
+ const report = await buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
64
69
  const g = report.groups.find((x) => x.version === "1.17.0");
65
70
  assert.equal(g.n, 1);
66
71
  assert.equal(g.cost.cacheReadMean, 3000000);
@@ -85,6 +90,8 @@ describe("GitHub-only report (fresh machine)", () => {
85
90
  assert.equal(u.funnel.falsePositive, 1);
86
91
  assert.equal(u.issueFlow.net, 2);
87
92
  assert.equal(u.issueFlow.findingsPerMerge, null);
93
+ assert.equal(g.funnel.introduced, 2, "only events carrying scope=introduced are counted");
94
+ assert.equal(g.issueFlow.introducedPerMerge, (g.issueFlow.closed.merged ?? 0) > 0 ? 2 / g.issueFlow.closed.merged : null);
88
95
  assert.ok(!JSON.stringify(report).includes("999999999"), "untrusted record leaked into the report");
89
96
  });
90
97
  it("matches the run-record field names written by the engine seam", () => {
@@ -93,6 +100,27 @@ describe("GitHub-only report (fresh machine)", () => {
93
100
  assert.equal(runs[0].finished, true);
94
101
  assert.equal(runs[0].phases.review.cr, 500000);
95
102
  });
103
+ const rec = (run, ts, event, x = {}) => ({ event, repo: "o/r", issue: 500, run, ts, eid: run + ts + event, ...x });
104
+ it("collapses a retried issue (two run ids) to the latest run", () => {
105
+ const runs = runsFromRecords([
106
+ rec("r_500_1", "2026-01-01T00:00:00Z", "phase_attempt", { phase: "build", cache_read_tokens: 2000000 }),
107
+ rec("r_500_1", "2026-01-01T00:01:00Z", "run_terminal", { reason: "engine-error" }),
108
+ rec("r_500_2", "2026-01-02T00:00:00Z", "phase_attempt", { phase: "build", cache_read_tokens: 1000000 }),
109
+ rec("r_500_2", "2026-01-02T00:01:00Z", "run_terminal", { reason: "merged" }),
110
+ ]);
111
+ assert.equal(runs.length, 1);
112
+ assert.equal(runs[0].reason, "merged");
113
+ assert.equal(runs[0].phases.build.cr, 1000000);
114
+ });
115
+ it("lets an unfinished latest run win over an earlier finished retried run", () => {
116
+ const runs = runsFromRecords([
117
+ rec("r_500_1", "2026-01-01T00:00:00Z", "phase_attempt", { phase: "build", cache_read_tokens: 2000000 }),
118
+ rec("r_500_1", "2026-01-01T00:01:00Z", "run_terminal", { reason: "merged" }),
119
+ rec("r_500_2", "2026-01-02T00:00:00Z", "phase_attempt", { phase: "build", cache_read_tokens: 1000000 }),
120
+ ]);
121
+ assert.equal(runs.length, 1);
122
+ assert.equal(runs[0].finished, false);
123
+ });
96
124
  });
97
125
 
98
126
  describe("local runs, joins, version delta and low-n", () => {
@@ -117,6 +145,15 @@ describe("local runs, joins, version delta and low-n", () => {
117
145
  assert.equal(legacy.cost.cacheReadMean, 4000000, "legacy PHASE_COMMIT usage counted when no attempt events exist");
118
146
  assert.equal(legacy.flow.codesN, 0, "old logs have no outcome codes");
119
147
  });
148
+ it("reports introduced findings per merged issue", () => {
149
+ const ev = (eid, scope) => ({ event: "finding_disposition", eid, repo: "o/r", pr: 1, issue: eid === "d" ? 2 : 1, version: "1.18.0", severity: "MEDIUM", disposition: "INPR_FIX", ...(scope ? { scope } : {}) });
150
+ const issue = (number) => ({ number, repo: "o/r", isPr: false, labels: ["workflow:merged"], createdAt: "2026-10-06T00:00:00Z", closedAt: "2026-10-07T00:00:00Z" });
151
+ const r = aggregate({ events: [ev("a", "introduced"), ev("b", "introduced"), ev("c", "pre-existing"), ev("d")], issues: [issue(1), issue(2)] }, { releaseTimes: [] });
152
+ const g = r.groups.find((x) => x.version === "1.18.0");
153
+ assert.equal(g.funnel.introduced, 2);
154
+ assert.equal(g.issueFlow.introducedPerMerge, 1);
155
+ assert.match(renderMarkdown(r), /introduced findings per merged issue/);
156
+ });
120
157
  it("computes the delta against the previous version of the same repo", () => {
121
158
  const g = agg().groups.find((x) => x.version === "1.17.0");
122
159
  assert.equal(g.delta.against, "1.16.0");
@@ -157,9 +194,9 @@ describe("n/a degradation and rendering", () => {
157
194
  assert.match(renderMarkdown(aggregate({})), /No data/);
158
195
  assert.equal(percentile([], 90), null);
159
196
  });
160
- it("json output is parseable and carries every section", () => {
197
+ it("json output is parseable and carries every section", async () => {
161
198
  const { gh } = fakeGh();
162
- const report = buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
199
+ const report = await buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
163
200
  const parsed = JSON.parse(renderJson(report));
164
201
  for (const k of ["cost", "time", "flow", "funnel", "gates", "issueFlow"]) assert.ok(k in parsed.groups[0]);
165
202
  const md = renderMarkdown(report);
@@ -172,32 +209,218 @@ describe("n/a degradation and rendering", () => {
172
209
  });
173
210
 
174
211
  describe("cache", () => {
175
- it("refreshes incrementally from the updated_at watermark and survives deletion or corruption", () => {
212
+ it("refreshes incrementally from the updated_at watermark and survives deletion or corruption", async () => {
176
213
  const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache") };
177
214
  const a = fakeGh();
178
- const first = fetchGithubRecords({ repo: "o/r", gh: a.gh, env });
215
+ const first = await fetchGithubRecords({ repo: "o/r", gh: a.gh, env });
179
216
  assert.equal(first.fromCache, false);
180
217
  assert.ok(!a.calls[0].includes("since="));
181
218
  const b = fakeGh();
182
- const second = fetchGithubRecords({ repo: "o/r", gh: b.gh, env });
219
+ const second = await fetchGithubRecords({ repo: "o/r", gh: b.gh, env });
183
220
  assert.equal(second.fromCache, true);
184
- assert.match(b.calls[0], /since=2026-10-06T13:00:00Z/);
221
+ assert.match(b.calls[0], /since=2026-10-06T12:55:00Z/); // watermark minus the 5 minute overlap window
185
222
  assert.deepEqual(second.records.map((r) => r.eid).sort(), first.records.map((r) => r.eid).sort());
186
223
  // corrupt cache -> treated as empty -> same result from a full fetch
187
224
  writeFileSync(join(cacheDir("o/r", env), "cache.json"), "{not json");
188
225
  const c = fakeGh();
189
- const third = fetchGithubRecords({ repo: "o/r", gh: c.gh, env });
226
+ const third = await fetchGithubRecords({ repo: "o/r", gh: c.gh, env });
190
227
  assert.equal(third.fromCache, false);
191
228
  assert.equal(third.records.length, first.records.length);
192
229
  // --refresh ignores a good cache
193
230
  const d = fakeGh();
194
- fetchGithubRecords({ repo: "o/r", gh: d.gh, env, refresh: true });
231
+ await fetchGithubRecords({ repo: "o/r", gh: d.gh, env, refresh: true });
195
232
  assert.ok(!d.calls[0].includes("since="));
196
233
  assert.equal(toModel("o/r", third).issues.length, 5);
197
234
  });
198
- it("rejects an unsafe repo before any gh call", () => {
235
+ // Page-aware fake of the issues API over a synthetic store; the repo-wide comments endpoint is empty unless `comments` is given.
236
+ const storeGh = (store, { comments = [], failOnListCall = 0 } = {}) => {
237
+ const calls = [];
238
+ let listCalls = 0;
239
+ const gh = (args) => {
240
+ const path = args.find((a) => a.startsWith("repos/"));
241
+ calls.push(path);
242
+ const q = new URLSearchParams(path.split("?")[1] ?? "");
243
+ if (/issues\/comments\?/.test(path)) {
244
+ const since = q.get("since"), page = Number(q.get("page") ?? 1);
245
+ return JSON.stringify(comments.filter((c) => !since || c.updated_at >= since).slice((page - 1) * 100, page * 100));
246
+ }
247
+ const m = /issues\/(\d+)\/comments$/.exec(path);
248
+ if (m) return JSON.stringify(comments.filter((c) => c.issue_url.endsWith(`/issues/${m[1]}`)));
249
+ const one = /issues\/(\d+)$/.exec(path);
250
+ if (one) return JSON.stringify(store.find((i) => i.number === Number(one[1])));
251
+ if (++listCalls === failOnListCall) throw new Error("rate limited");
252
+ const since = q.get("since");
253
+ const sorted = store.filter((i) => !since || i.updated_at >= since).sort((x, y) => x.updated_at.localeCompare(y.updated_at) || x.number - y.number);
254
+ const per = Number(q.get("per_page")), page = Number(q.get("page") ?? 1);
255
+ return JSON.stringify(sorted.slice((page - 1) * per, page * per));
256
+ };
257
+ return { gh, calls };
258
+ };
259
+ const ts = (n) => new Date(Date.UTC(2026, 9, 1) + n * 3600000).toISOString().replace(/\.\d{3}Z$/, "Z");
260
+ const mkItems = (n) => Array.from({ length: n }, (_, i) => ({ number: i + 1, updated_at: ts(i + 1), created_at: ts(0), state: "open", comments: 0, labels: [], user: { type: "Bot" } }));
261
+ const telemetryComment = (issue, eid, event, updated, extra = {}) => ({ id: issue * 1000 + Number(eid.replace(/\D/g, "")), updated_at: updated, user: { type: "Bot" },
262
+ issue_url: `https://api.github.com/repos/o/r/issues/${issue}`,
263
+ body: `<!-- FORGE:TELEMETRY v1 -->\n\`\`\`jsonl\n${JSON.stringify({ v: 1, ts: updated, forge_version: "1.18.0", repo: "o/r", issue, run: `r_${issue}`, eid, event, phase: "build", cache_read_tokens: 100, ...extra })}\n\`\`\`` });
264
+
265
+ it("saves a partial checkpoint on a mid-crawl failure and the next run resumes from it", async () => {
266
+ const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-partial") };
267
+ const store = mkItems(250);
268
+ await assert.rejects(() => fetchGithubRecords({ repo: "o/r", gh: storeGh(store, { failOnListCall: 3 }).gh, env }), /rate limited/);
269
+ const saved = JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8"));
270
+ assert.ok(saved.watermark, "partial watermark persisted");
271
+ assert.ok(Object.keys(saved.items).length >= 100 && Object.keys(saved.items).length < 250);
272
+ const b = storeGh(store);
273
+ const resumed = await fetchGithubRecords({ repo: "o/r", gh: b.gh, env });
274
+ assert.equal(resumed.fromCache, true);
275
+ assert.ok(b.calls[0].includes("since="), "resumes from the watermark");
276
+ assert.equal(resumed.items.length, 250);
277
+ });
278
+ it("a mid-crawl failure never advances the watermark past unpersisted telemetry (resume still finds it)", async () => {
279
+ const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-pending-telemetry") };
280
+ const store = mkItems(250);
281
+ // A telemetry comment on the NEWEST issue (listed last), posted before the failing run starts.
282
+ const comments = [telemetryComment(250, "z1", "phase_attempt", ts(250))];
283
+ await assert.rejects(() => fetchGithubRecords({ repo: "o/r", gh: storeGh(store, { comments, failOnListCall: 3 }).gh, env }), /rate limited/);
284
+ const saved = JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8"));
285
+ assert.ok(!saved.watermark, "watermark must not be persisted while telemetry records are unpersisted");
286
+ // Resume: the comment is older than any watermark a buggy checkpoint would have saved.
287
+ const resumed = await fetchGithubRecords({ repo: "o/r", gh: storeGh(store, { comments }).gh, env });
288
+ assert.equal(resumed.items.length, 250);
289
+ assert.deepEqual(resumed.records.map((r) => r.eid), ["z1"]);
290
+ assert.equal(resumed.items.find((i) => i.number === 250).records.length, 1);
291
+ });
292
+ it("bounds the crawl by the window: API calls track the window, not the repo size", async () => {
293
+ const store = mkItems(3000); // 3000 items, one per hour
294
+ const from = ts(2990);
295
+ const windowItems = store.filter((i) => i.updated_at >= from).length;
296
+ const comments = store.slice(-5).map((i) => telemetryComment(i.number, `x${i.number}`, "phase_attempt", i.updated_at));
297
+ const { gh, calls } = storeGh(store, { comments });
298
+ const r = await fetchGithubRecords({ repo: "o/r", gh, useCache: false, since: from, env: ENV });
299
+ assert.equal(r.items.length, windowItems);
300
+ // 1 comments page + one refetch per telemetry issue + ceil(window / 100) list pages; never one call per repo item.
301
+ const bound = 1 + comments.length + Math.ceil(windowItems / 100) + 1;
302
+ assert.ok(r.apiCalls <= bound, `api calls ${r.apiCalls} exceed window bound ${bound}`);
303
+ assert.equal(calls.length, r.apiCalls);
304
+ assert.ok(calls.length < 30 && store.length > 100 * calls.length, `calls.length ${calls.length} must be far below the repo size`);
305
+ assert.ok(calls.some((c) => /issues\/comments\?.*since=/.test(c)), "comments come from the repo-wide since= endpoint");
306
+ assert.ok(!calls.some((c) => /issues\/\d+\/comments$/.test(c) && !comments.some((x) => x.issue_url.endsWith(c.match(/issues\/(\d+)\//)[1]))), "no comment call for items without telemetry");
307
+ assert.equal(r.records.length, comments.length);
308
+ });
309
+ it("re-reads every part of an issue whose telemetry was touched in the window, and only those", async () => {
310
+ const store = mkItems(20);
311
+ const comments = [telemetryComment(7, "e1", "phase_attempt", ts(1)), telemetryComment(7, "e2", "run_terminal", ts(15), { reason: "merged" }),
312
+ telemetryComment(9, "e1", "phase_attempt", ts(2))];
313
+ const { gh, calls } = storeGh(store, { comments });
314
+ const r = await fetchGithubRecords({ repo: "o/r", gh, useCache: false, since: ts(10), env: ENV });
315
+ assert.deepEqual(r.records.filter((x) => x.issue === 7).map((x) => x.event).sort(), ["phase_attempt", "run_terminal"], "the pre-window part is fetched too");
316
+ assert.equal(calls.filter((c) => /issues\/7\/comments$/.test(c)).length, 1);
317
+ assert.equal(calls.filter((c) => /issues\/9\/comments$/.test(c)).length, 0, "issue 9 has no telemetry in the window");
318
+ });
319
+ it("accepts an async gh and awaits it", async () => {
320
+ const store = mkItems(5);
321
+ const { gh } = storeGh(store);
322
+ const r = await fetchGithubRecords({ repo: "o/r", gh: async (a) => gh(a), useCache: false, env: ENV });
323
+ assert.equal(r.items.length, 5);
324
+ });
325
+ it("records the coverage floor, crawls only the gap for a wider window and resets on --refresh", async () => {
326
+ const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-floor") };
327
+ const store = mkItems(100);
328
+ const w1 = storeGh(store);
329
+ const narrow = await fetchGithubRecords({ repo: "o/r", gh: w1.gh, env, since: ts(90) });
330
+ assert.equal(narrow.items.length, 11);
331
+ assert.equal(JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8")).coveredFrom, ts(90));
332
+ // same or narrower window: incremental from the watermark, floor unchanged
333
+ const same = storeGh(store);
334
+ await fetchGithubRecords({ repo: "o/r", gh: same.gh, env, since: ts(95) });
335
+ assert.ok(/since=/.test(same.calls[0]) && !same.calls[0].includes(`since=${ts(95)}`), "resumes from the watermark, not the window start");
336
+ assert.equal(JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8")).coveredFrom, ts(90));
337
+ // wider window: the uncovered gap is crawled (from the new start), then the floor is lowered
338
+ const wide = storeGh(store);
339
+ const r = await fetchGithubRecords({ repo: "o/r", gh: wide.gh, env, since: ts(50) });
340
+ assert.ok(wide.calls[0].includes(`since=${ts(50)}`));
341
+ assert.equal(r.items.length, 51, "the pre-window gap is not silently treated as covered");
342
+ assert.equal(JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8")).coveredFrom, ts(50));
343
+ // no --since after a windowed run: full history is crawled and the floor becomes null
344
+ const all = storeGh(store);
345
+ assert.equal((await fetchGithubRecords({ repo: "o/r", gh: all.gh, env })).items.length, 100);
346
+ assert.equal(JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8")).coveredFrom, null);
347
+ // --refresh ignores the cache and its floor
348
+ const fresh = storeGh(store);
349
+ await fetchGithubRecords({ repo: "o/r", gh: fresh.gh, env, refresh: true, since: ts(80) });
350
+ assert.equal(JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8")).coveredFrom, ts(80));
351
+ });
352
+ it("does not trust a legacy cache (no coveredFrom) for a window and never lowers the floor on failure", async () => {
353
+ const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-legacy") };
354
+ const store = mkItems(250);
355
+ mkdirSync(cacheDir("o/r", env), { recursive: true });
356
+ writeFileSync(join(cacheDir("o/r", env), "cache.json"), JSON.stringify({ v: 1, watermark: ts(250), items: {} }));
357
+ const g = storeGh(store, { failOnListCall: 2 });
358
+ await assert.rejects(() => fetchGithubRecords({ repo: "o/r", gh: g.gh, env, since: ts(10) }), /rate limited/);
359
+ const saved = JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8"));
360
+ assert.equal(saved.watermark, ts(250), "failed gap crawl keeps the old watermark");
361
+ assert.ok(!("coveredFrom" in saved) || saved.coveredFrom == null || saved.coveredFrom === undefined);
362
+ const h = storeGh(store);
363
+ const r = await fetchGithubRecords({ repo: "o/r", gh: h.gh, env, since: ts(10) });
364
+ assert.ok(h.calls[0].includes(`since=${ts(10)}`));
365
+ assert.equal(r.items.length, 241);
366
+ });
367
+ it("reports progress through onProgress and backs off when the rate limit is low", async () => {
368
+ const store = mkItems(6000);
369
+ const events = [];
370
+ await fetchGithubRecords({ repo: "o/r", gh: storeGh(store).gh, useCache: false, env: ENV, onProgress: (e) => events.push(e) });
371
+ assert.ok(events.some((e) => e.phase === "comments") && events.some((e) => e.phase === "issues" && e.done === 6000));
372
+ const slept = [];
373
+ const base = storeGh(store).gh;
374
+ const gh = (args) => (args.join(" ") === "api rate_limit" ? JSON.stringify({ resources: { core: { remaining: 3, reset: 1_000_100 } } }) : base(args));
375
+ await fetchGithubRecords({ repo: "o/r", gh, useCache: false, env: ENV, rateCheck: true, now: () => 1_000_000_000, sleep: async (ms) => { slept.push(ms); } });
376
+ assert.ok(slept.length > 0 && slept[0] === 101000, `slept ${slept}`);
377
+ });
378
+ it("never skips an item updated mid-crawl (keyset crawl, not page-number pagination)", async () => {
379
+ const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-midcrawl") };
380
+ const ts = (n) => new Date(Date.UTC(2026, 9, 1) + n * 60000).toISOString().replace(/\.\d{3}Z$/, "Z");
381
+ 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" } }));
382
+ let listCalls = 0;
383
+ let maxUpdated = 250;
384
+ // Page-aware fake of the real API: asc by updated_at, honours since (inclusive), per_page and page. After the first
385
+ // listing call an already-served item is bumped, which moves it to the end of the ordering.
386
+ const gh = (args) => {
387
+ const path = args.find((a) => a.startsWith("repos/"));
388
+ if (/issues\/comments\?/.test(path)) return "[]";
389
+ const q = new URLSearchParams(path.split("?")[1]);
390
+ listCalls++;
391
+ const since = q.get("since");
392
+ const sorted = store.filter((i) => !since || i.updated_at >= since).sort((a, b) => a.updated_at.localeCompare(b.updated_at) || a.number - b.number);
393
+ const per = Number(q.get("per_page")), page = Number(q.get("page") ?? 1);
394
+ const out = JSON.stringify(sorted.slice((page - 1) * per, page * per));
395
+ if (listCalls === 1) store[4].updated_at = ts(++maxUpdated);
396
+ return out;
397
+ };
398
+ const first = await fetchGithubRecords({ repo: "o/r", gh, env });
399
+ assert.equal(first.items.length, 250, "every item is cached after the crawl, none skipped by the shift");
400
+ assert.equal(new Set(first.items.map((i) => i.number)).size, 250);
401
+ // The bumped item moved past the crawl cursor, so the next incremental run (and its overlap window) still sees it.
402
+ store[9].updated_at = ts(++maxUpdated); // another item updated after the first run
403
+ const second = await fetchGithubRecords({ repo: "o/r", gh, env });
404
+ assert.equal(second.fromCache, true);
405
+ assert.equal(second.items.length, 250);
406
+ assert.equal(second.items.find((i) => i.number === 10).updatedAt, store[9].updated_at);
407
+ assert.equal(second.items.find((i) => i.number === 5).updatedAt, store[4].updated_at);
408
+ });
409
+ it("terminates and drains a full page whose items all share one updated_at", async () => {
410
+ const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-sameTs") };
411
+ 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" } }));
412
+ const gh = (args) => {
413
+ if (/issues\/comments\?/.test(args.find((a) => a.startsWith("repos/")))) return "[]";
414
+ const q = new URLSearchParams(args.find((a) => a.startsWith("repos/")).split("?")[1]);
415
+ const per = Number(q.get("per_page")), page = Number(q.get("page") ?? 1);
416
+ const since = q.get("since");
417
+ return JSON.stringify(store.filter((i) => !since || i.updated_at >= since).slice((page - 1) * per, page * per));
418
+ };
419
+ assert.equal((await fetchGithubRecords({ repo: "o/r", gh, env })).items.length, 230);
420
+ });
421
+ it("rejects an unsafe repo before any gh call", async () => {
199
422
  const { gh, calls } = fakeGh();
200
- assert.throws(() => fetchGithubRecords({ repo: "o/r; rm -rf /", gh }), /invalid repo/);
423
+ await assert.rejects(() => fetchGithubRecords({ repo: "o/r; rm -rf /", gh }), /invalid repo/);
201
424
  assert.equal(calls.length, 0);
202
425
  });
203
426
  });
@@ -210,6 +433,32 @@ describe("cli", () => {
210
433
  assert.throws(() => parseArgs(["--bogus"]), /unknown argument/);
211
434
  assert.throws(() => parseSince("yesterday"), /--since must be/);
212
435
  });
436
+ it("resolves --since to a crawl lower bound (date as is, version via release time minus a margin)", () => {
437
+ assert.equal(sinceToTimestamp("2026-10-01").since, "2026-10-01T00:00:00Z");
438
+ assert.equal(sinceToTimestamp(undefined).since, null);
439
+ const v = sinceToTimestamp("v1.16.0", [{ v: "1.16.0", t: Date.parse("2026-10-05T00:00:00Z") / 1000 }]);
440
+ assert.equal(v.since, "2026-10-04T00:00:00Z");
441
+ const unknown = sinceToTimestamp("1.99.0", []);
442
+ assert.equal(unknown.since, null);
443
+ assert.match(unknown.note, /full history/);
444
+ });
445
+ it("prints progress to stderr only, throttled, and keeps --json stdout parseable", async () => {
446
+ const out = [];
447
+ let t = 0;
448
+ const print = makeProgressPrinter((x) => out.push(x), () => t);
449
+ print({ phase: "issues", done: 100, total: null });
450
+ t += 500; print({ phase: "issues", done: 200, total: null });
451
+ t += 2000; print({ phase: "issues", done: 300, total: null });
452
+ assert.equal(out.length, 2);
453
+ const { gh } = fakeGh();
454
+ const realOut = process.stdout.write, realErr = process.stderr.write;
455
+ let stdout = "", stderr = "";
456
+ process.stdout.write = (c) => { stdout += c; return true; };
457
+ process.stderr.write = (c) => { stderr += c; return true; };
458
+ try { assert.equal(await runStats(["--repo", "o/r", "--json", "--no-cache", "--no-local"], { gh, env: ENV, releaseTimes: [] }), 0); } finally { process.stdout.write = realOut; process.stderr.write = realErr; }
459
+ assert.ok(JSON.parse(stdout).groups, "stdout is only the JSON report");
460
+ assert.match(stderr, /forgedock stats: (comments|issues) /);
461
+ });
213
462
  it("--help exits 0 and importing the module did not run it", () => {
214
463
  const r = spawnSync(process.execPath, [join(here, "index.mjs"), "--help"], { encoding: "utf8" });
215
464
  assert.equal(r.status, 0);
@@ -241,6 +241,7 @@ Each `/orchestrate` batch creates one claims board (`orchestrate: claims board f
241
241
  A board is closed only when **all** identity checks pass and **one** closing condition holds:
242
242
 
243
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.
244
245
  - Close when the last `FORGE:LEASE*` comment is a `FORGE:LEASE_RELEASED`.
245
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).
246
247
  - Close when there is no lease/claim comment and the issue itself is older than `BOARD_STALE_HOURS`.
@@ -272,11 +273,17 @@ while IFS=$'\t' read -r BNUM BCREATED; do
272
273
 
273
274
  # --paginate emits one array per page: slurp (jq -s) and flatten BEFORE taking `last`,
274
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.
275
280
  LAST_LEASE=$(gh api --paginate "repos/{GH_REPO}/issues/${BNUM}/comments" 2>/dev/null \
276
- | jq -s '[.[][] | select(.body | contains("FORGE:LEASE"))] | last // empty
277
- | {released: (.body | contains("FORGE:LEASE_RELEASED")), at: .created_at}' 2>/dev/null || echo "")
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 "")
278
284
  LAST_ACTIVITY=$(gh api --paginate "repos/{GH_REPO}/issues/${BNUM}/comments" 2>/dev/null \
279
- | jq -rs '[.[][] | select(.body | contains("FORGE:LEASE") or contains("FORGE:CLAIM"))] | last // empty | .created_at' 2>/dev/null || echo "")
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 "")
280
287
 
281
288
  REASON=""
282
289
  if [ -n "$LAST_LEASE" ] && [ "$(echo "$LAST_LEASE" | jq -r '.released')" = "true" ]; then
@@ -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"
@@ -412,11 +412,18 @@ Before shipping, ensure the milestone branch is up to date with `staging` (picks
412
412
  ```bash
413
413
  cd {REPO_PATH}
414
414
  git fetch origin {STAGING_BRANCH} milestone/{slug}
415
- git checkout milestone/{slug}
416
- git merge origin/{STAGING_BRANCH} --no-edit
417
- # If conflicts, report to user and STOP — do not auto-resolve
418
- git push origin milestone/{slug}
419
- git checkout {STAGING_BRANCH}
415
+ # Never mutate the main checkout: sync in a detached worktree at the milestone tip
416
+ SYNC_WT=$(mktemp -d)
417
+ git worktree add --detach "$SYNC_WT" origin/milestone/{slug}
418
+ if git -C "$SYNC_WT" merge origin/{STAGING_BRANCH} --no-edit; then
419
+ git -C "$SYNC_WT" push origin HEAD:refs/heads/milestone/{slug}
420
+ git worktree remove --force "$SYNC_WT"
421
+ else
422
+ # Conflicts: report to user and STOP — do not auto-resolve
423
+ git -C "$SYNC_WT" merge --abort 2>/dev/null || true
424
+ git worktree remove --force "$SYNC_WT"
425
+ echo "STOP: merge conflict syncing milestone/{slug} with {STAGING_BRANCH}"; exit 1
426
+ fi
420
427
  ```
421
428
 
422
429
  ### Step 2.5: Pre-Merge Hunk-Loss Audit (MANDATORY before creating PR)
@@ -425,6 +432,7 @@ git checkout {STAGING_BRANCH}
425
432
 
426
433
  This step identifies "at-risk hunks" — staging-only content in files the milestone also modifies — and absorbs them into the milestone branch via rebase before the PR is created. After the rebase, even a squash merge will preserve all staging content because it's now part of the milestone branch's commits.
427
434
 
435
+ <!-- allowlist:check-spec-bash -->
428
436
  ```bash
429
437
  cd {REPO_PATH}
430
438
  git fetch origin {STAGING_BRANCH} milestone/{slug}
@@ -453,18 +461,19 @@ else
453
461
  echo "$AT_RISK" | grep '^--- a/' | sed 's|^--- a/||'
454
462
 
455
463
  echo "Rebasing milestone/{slug} onto origin/{STAGING_BRANCH} to absorb at-risk hunks..."
456
- git checkout milestone/{slug}
457
- git rebase origin/{STAGING_BRANCH}
464
+ REBASE_WT=$(mktemp -d)
465
+ git worktree add --detach "$REBASE_WT" origin/milestone/{slug}
466
+ git -C "$REBASE_WT" rebase origin/{STAGING_BRANCH}
458
467
 
459
468
  REBASE_EXIT=$?
460
469
  if [ "$REBASE_EXIT" -ne 0 ]; then
461
470
  # Capture conflicting files BEFORE aborting (after abort, unmerged state is gone)
462
- CONFLICTING_FILES=$(git diff --name-only --diff-filter=U 2>/dev/null)
471
+ CONFLICTING_FILES=$(git -C "$REBASE_WT" diff --name-only --diff-filter=U 2>/dev/null)
463
472
  [ -z "$CONFLICTING_FILES" ] && CONFLICTING_FILES="unknown — check git status after resolving manually"
464
473
 
465
474
  # Abort the failed rebase and return to a clean state
466
- git rebase --abort 2>/dev/null || true
467
- git checkout {STAGING_BRANCH}
475
+ git -C "$REBASE_WT" rebase --abort 2>/dev/null || true
476
+ git worktree remove --force "$REBASE_WT"
468
477
 
469
478
  # Truncate AT_RISK to safe size for issue body (avoid shell quoting issues with large diffs)
470
479
  AT_RISK_SNIPPET=$(echo "$AT_RISK" | head -60 | sed "s/'/'\\\\''/g")
@@ -519,6 +528,7 @@ ISSUE_EOF
519
528
  ISSUE_SKILL_OUTPUT=$(Skill(skill="{FORGE_SKILL_PREFIX}issue", args="--title \"fix(milestone): rebase conflict during hunk-loss audit for milestone/{slug}\" --body-file \"${REBASE_ISSUE_BODY_FILE}\" --label \"bug\" --label \"priority:P0\" --label \"needs-human\""))
520
529
  ```
521
530
 
531
+ <!-- allowlist:check-spec-bash -->
522
532
  ```bash
523
533
  echo "STOP: Rebase conflict. Created issue for manual resolution (see /issue output above). Do NOT proceed to PR creation."
524
534
  echo "Run '/milestone ship {slug}' again after resolving the conflict."
@@ -526,9 +536,9 @@ ISSUE_SKILL_OUTPUT=$(Skill(skill="{FORGE_SKILL_PREFIX}issue", args="--title \"fi
526
536
  fi
527
537
 
528
538
  # Rebase succeeded — push the rebased branch
529
- git push origin milestone/{slug} --force-with-lease
539
+ git -C "$REBASE_WT" push origin HEAD:refs/heads/milestone/{slug} --force-with-lease
540
+ git worktree remove --force "$REBASE_WT"
530
541
  echo "Rebase complete. Milestone branch now includes all $AT_RISK_COUNT at-risk staging hunks."
531
- git checkout {STAGING_BRANCH}
532
542
  fi
533
543
  fi
534
544
  ```
@@ -716,11 +726,18 @@ Syncs the milestone branch with the latest default branch to pick up fast-lane f
716
726
  cd {REPO_PATH}
717
727
  DEFAULT_BRANCH=$(yq '.branches.default' "$CONFIG_FILE")
718
728
  git fetch origin $DEFAULT_BRANCH milestone/{slug}
719
- git checkout milestone/{slug}
720
- git merge origin/$DEFAULT_BRANCH --no-edit
721
- # If conflicts, report to user and STOP
722
- git push origin milestone/{slug}
723
- git checkout $DEFAULT_BRANCH
729
+ # Never mutate the main checkout: sync in a detached worktree at the milestone tip
730
+ SYNC_WT=$(mktemp -d)
731
+ git worktree add --detach "$SYNC_WT" origin/milestone/{slug}
732
+ if git -C "$SYNC_WT" merge origin/$DEFAULT_BRANCH --no-edit; then
733
+ git -C "$SYNC_WT" push origin HEAD:refs/heads/milestone/{slug}
734
+ git worktree remove --force "$SYNC_WT"
735
+ else
736
+ # If conflicts, report to user and STOP
737
+ git -C "$SYNC_WT" merge --abort 2>/dev/null || true
738
+ git worktree remove --force "$SYNC_WT"
739
+ echo "STOP: merge conflict syncing milestone/{slug} with $DEFAULT_BRANCH"; exit 1
740
+ fi
724
741
  ```
725
742
 
726
743
  Report: "Synced milestone/{slug} with latest `{DEFAULT_BRANCH}`. {N} new commits incorporated."