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.
- package/bin/engine/outcome-codes.mjs +24 -2
- package/bin/engine/phases.mjs +153 -102
- package/bin/engine/runlog.mjs +7 -0
- package/bin/engine-cli.mjs +5 -0
- package/bin/engine.mjs +84 -10
- package/bin/forgedock.mjs +1 -1
- package/bin/hooks/pre-tool-use.mjs +65 -32
- package/bin/runner.mjs +63 -4
- package/bin/stats/github-source.mjs +89 -18
- package/bin/stats/stats.test.mjs +89 -1
- package/commands/cleanup.md +10 -3
- package/commands/deploy-pr.md +64 -0
- package/commands/fix-ci.md +20 -6
- package/commands/milestone.md +34 -17
- package/commands/orchestrate/phase-4-execution.md +11 -4
- package/commands/review-pr-staging.md +142 -519
- package/commands/review-pr.md +490 -3128
- package/commands/test-gate.md +25 -10
- package/commands/work-on/build/architect.md +13 -1
- package/commands/work-on/build/implement.md +2 -1
- package/commands/work-on/build/validate.md +2 -0
- package/commands/work-on/build.md +107 -86
- package/commands/work-on/close.md +50 -15
- package/commands/work-on/decompose.md +72 -34
- package/commands/work-on/investigate.md +9 -12
- package/commands/work-on/remediate.md +27 -13
- package/commands/work-on/review.md +5 -3
- package/commands/work-on.md +1 -2
- package/docs/CONFIG.md +44 -25
- package/docs/OUTCOME-CODES.md +7 -2
- package/docs/RELEASE-NOTES.md +12 -0
- package/docs/STATS.md +2 -1
- package/docs/TELEMETRY.md +2 -2
- package/docs/WORK-ON-RUNTIME.md +6 -0
- package/docs/spec/forge-protocol-v1.md +2 -2
- package/package.json +1 -1
- package/packages/protocol/src/phases.js +5 -5
- package/scripts/cap-findings.sh +1 -1
- package/scripts/cap-findings.test.sh +1 -1
- package/scripts/check-spec-bash.sh +5 -1
- package/scripts/check-spec-bash.test.sh +20 -0
- package/scripts/close-parent-c3.test.sh +69 -0
- package/scripts/diff-size.sh +93 -4
- package/scripts/diff-size.test.sh +59 -2
- package/scripts/forge-root.test.sh +13 -11
- package/scripts/publish-staging-sync.test.sh +48 -0
- package/scripts/resolve-deploy-gate.sh +32 -6
- package/scripts/resolve-deploy-gate.test.sh +42 -1
- package/scripts/review-finalize.sh +1427 -0
- package/scripts/review-finalize.test.sh +833 -0
- package/scripts/review-pr-delta-wiring.test.sh +19 -72
- package/scripts/review-pr-domain-map.test.sh +18 -13
- package/scripts/review-pr-gate2.test.sh +9 -65
- package/scripts/review-pr-staging-scope.test.sh +14 -98
- package/scripts/review-prepare.sh +48 -12
- package/scripts/review-prepare.test.sh +19 -1
- package/scripts/review-roster.sh +16 -2
- package/scripts/review-roster.test.sh +22 -0
- package/scripts/trusted-comments.test.sh +9 -2
- package/scripts/wait-ci-green.test.sh +3 -3
package/bin/stats/stats.test.mjs
CHANGED
|
@@ -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-
|
|
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/);
|
package/commands/cleanup.md
CHANGED
|
@@ -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(.
|
|
277
|
-
|
|
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(.
|
|
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
|
package/commands/deploy-pr.md
CHANGED
|
@@ -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.
|
package/commands/fix-ci.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
319
|
-
|
|
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 "
|
|
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
|
|
449
|
-
git
|
|
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"
|
package/commands/milestone.md
CHANGED
|
@@ -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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
git
|
|
419
|
-
git
|
|
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
|
-
|
|
457
|
-
git
|
|
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
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
git
|
|
723
|
-
git
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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."
|