forgedock 1.17.0 → 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 (60) hide show
  1. package/bin/engine/outcome-codes.mjs +24 -2
  2. package/bin/engine/phases.mjs +153 -102
  3. package/bin/engine/runlog.mjs +7 -0
  4. package/bin/engine-cli.mjs +5 -0
  5. package/bin/engine.mjs +84 -10
  6. package/bin/forgedock.mjs +1 -1
  7. package/bin/hooks/pre-tool-use.mjs +65 -32
  8. package/bin/runner.mjs +63 -4
  9. package/bin/stats/github-source.mjs +89 -18
  10. package/bin/stats/stats.test.mjs +89 -1
  11. package/commands/cleanup.md +10 -3
  12. package/commands/deploy-pr.md +64 -0
  13. package/commands/fix-ci.md +20 -6
  14. package/commands/milestone.md +34 -17
  15. package/commands/orchestrate/phase-4-execution.md +11 -4
  16. package/commands/review-pr-staging.md +142 -519
  17. package/commands/review-pr.md +490 -3128
  18. package/commands/test-gate.md +25 -10
  19. package/commands/work-on/build/architect.md +13 -1
  20. package/commands/work-on/build/implement.md +2 -1
  21. package/commands/work-on/build/validate.md +2 -0
  22. package/commands/work-on/build.md +107 -86
  23. package/commands/work-on/close.md +50 -15
  24. package/commands/work-on/decompose.md +72 -34
  25. package/commands/work-on/investigate.md +9 -12
  26. package/commands/work-on/remediate.md +27 -13
  27. package/commands/work-on/review.md +5 -3
  28. package/commands/work-on.md +1 -2
  29. package/docs/CONFIG.md +44 -25
  30. package/docs/OUTCOME-CODES.md +7 -2
  31. package/docs/RELEASE-NOTES.md +12 -0
  32. package/docs/STATS.md +2 -1
  33. package/docs/TELEMETRY.md +2 -2
  34. package/docs/WORK-ON-RUNTIME.md +6 -0
  35. package/docs/spec/forge-protocol-v1.md +2 -2
  36. package/package.json +1 -1
  37. package/packages/protocol/src/phases.js +5 -5
  38. package/scripts/cap-findings.sh +1 -1
  39. package/scripts/cap-findings.test.sh +1 -1
  40. package/scripts/check-spec-bash.sh +5 -1
  41. package/scripts/check-spec-bash.test.sh +20 -0
  42. package/scripts/close-parent-c3.test.sh +69 -0
  43. package/scripts/diff-size.sh +93 -4
  44. package/scripts/diff-size.test.sh +59 -2
  45. package/scripts/forge-root.test.sh +13 -11
  46. package/scripts/publish-staging-sync.test.sh +48 -0
  47. package/scripts/resolve-deploy-gate.sh +32 -6
  48. package/scripts/resolve-deploy-gate.test.sh +42 -1
  49. package/scripts/review-finalize.sh +1427 -0
  50. package/scripts/review-finalize.test.sh +833 -0
  51. package/scripts/review-pr-delta-wiring.test.sh +19 -72
  52. package/scripts/review-pr-domain-map.test.sh +18 -13
  53. package/scripts/review-pr-gate2.test.sh +9 -65
  54. package/scripts/review-pr-staging-scope.test.sh +14 -98
  55. package/scripts/review-prepare.sh +48 -12
  56. package/scripts/review-prepare.test.sh +19 -1
  57. package/scripts/review-roster.sh +16 -2
  58. package/scripts/review-roster.test.sh +22 -0
  59. package/scripts/trusted-comments.test.sh +9 -2
  60. package/scripts/wait-ci-green.test.sh +3 -3
@@ -93,6 +93,27 @@ describe("GitHub-only report (fresh machine)", () => {
93
93
  assert.equal(runs[0].finished, true);
94
94
  assert.equal(runs[0].phases.review.cr, 500000);
95
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
+ });
96
117
  });
97
118
 
98
119
  describe("local runs, joins, version delta and low-n", () => {
@@ -181,7 +202,7 @@ describe("cache", () => {
181
202
  const b = fakeGh();
182
203
  const second = fetchGithubRecords({ repo: "o/r", gh: b.gh, env });
183
204
  assert.equal(second.fromCache, true);
184
- assert.match(b.calls[0], /since=2026-10-06T13:00:00Z/);
205
+ assert.match(b.calls[0], /since=2026-10-06T12:55:00Z/); // watermark minus the 5 minute overlap window
185
206
  assert.deepEqual(second.records.map((r) => r.eid).sort(), first.records.map((r) => r.eid).sort());
186
207
  // corrupt cache -> treated as empty -> same result from a full fetch
187
208
  writeFileSync(join(cacheDir("o/r", env), "cache.json"), "{not json");
@@ -195,6 +216,73 @@ describe("cache", () => {
195
216
  assert.ok(!d.calls[0].includes("since="));
196
217
  assert.equal(toModel("o/r", third).issues.length, 5);
197
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
+ });
198
286
  it("rejects an unsafe repo before any gh call", () => {
199
287
  const { gh, calls } = fakeGh();
200
288
  assert.throws(() => fetchGithubRecords({ repo: "o/r; rm -rf /", gh }), /invalid repo/);
@@ -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."
@@ -3943,7 +3943,10 @@ if [ -n "$TS_CHANGED" ]; then
3943
3943
  echo "=== Integration Build Gate (TypeScript): batch checkpoint ==="
3944
3944
  cd {REPO_PATH}
3945
3945
  git fetch origin ${MILESTONE_BRANCH}
3946
- git checkout origin/${MILESTONE_BRANCH} --detach 2>/dev/null
3946
+ # Detached worktree: never check out in the main checkout
3947
+ GATE_WT=$(mktemp -d)
3948
+ git worktree add --detach "$GATE_WT" "origin/${MILESTONE_BRANCH}" 2>/dev/null
3949
+ cd "$GATE_WT"
3947
3950
 
3948
3951
  if [ -n "$TS_TYPECHECK" ]; then
3949
3952
  eval "$TS_TYPECHECK" 2>&1 | head -30
@@ -3961,7 +3964,8 @@ if [ -n "$TS_CHANGED" ]; then
3961
3964
  BUILD_EXIT=0
3962
3965
  fi
3963
3966
 
3964
- git checkout - 2>/dev/null
3967
+ cd {REPO_PATH}
3968
+ git worktree remove --force "$GATE_WT" 2>/dev/null || true
3965
3969
 
3966
3970
  if [ "$TSC_EXIT" -ne 0 ]; then
3967
3971
  echo "BLOCKING: TypeScript errors on ${MILESTONE_BRANCH} after batch checkpoint."
@@ -3977,7 +3981,9 @@ PY_CHANGED=$(git diff origin/{DEFAULT_BRANCH}...origin/${MILESTONE_BRANCH} --nam
3977
3981
  if [ -n "$PY_CHANGED" ]; then
3978
3982
  echo "=== Integration Build Gate (Python): batch checkpoint ==="
3979
3983
  cd {REPO_PATH}
3980
- git checkout origin/${MILESTONE_BRANCH} --detach 2>/dev/null
3984
+ GATE_WT=$(mktemp -d)
3985
+ git worktree add --detach "$GATE_WT" "origin/${MILESTONE_BRANCH}" 2>/dev/null
3986
+ cd "$GATE_WT"
3981
3987
 
3982
3988
  if [ -n "$PYTHON_FORMAT" ]; then
3983
3989
  eval "$PYTHON_FORMAT" 2>&1 | tail -10
@@ -3987,7 +3993,8 @@ if [ -n "$PY_CHANGED" ]; then
3987
3993
  FORMAT_EXIT=0
3988
3994
  fi
3989
3995
 
3990
- git checkout - 2>/dev/null
3996
+ cd {REPO_PATH}
3997
+ git worktree remove --force "$GATE_WT" 2>/dev/null || true
3991
3998
 
3992
3999
  if [ "$FORMAT_EXIT" -ne 0 ]; then
3993
4000
  echo "WARNING: Python formatting issues on ${MILESTONE_BRANCH} after batch checkpoint."