forgedock 1.10.3 → 1.12.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/phases-args.test.mjs +112 -0
- package/bin/engine/phases.mjs +152 -45
- package/bin/engine/runlog.mjs +9 -0
- package/bin/engine-cli.mjs +13 -2
- package/bin/engine.mjs +144 -18
- package/bin/forgedock.mjs +2 -2
- package/bin/runner.mjs +89 -12
- package/commands/ci-audit.md +3 -3
- package/commands/compat-audit.md +1 -1
- package/commands/deploy-pr.md +6 -6
- package/commands/explain.md +2 -2
- package/commands/fix-ci.md +2 -2
- package/commands/forge-stats.md +1 -1
- package/commands/forgedock-init.md +3 -3
- package/commands/optimize.md +5 -5
- package/commands/orchestrate/phase-1-resolve.md +2 -2
- package/commands/orchestrate/phase-4-execution.md +208 -48
- package/commands/orchestrate/phase-6-report.md +29 -0
- package/commands/quality-gate.md +97 -13
- package/commands/recover-orphans.md +6 -6
- package/commands/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-staging.md +28 -6
- package/commands/review-pr.md +509 -132
- package/commands/security-audit.md +1 -1
- package/commands/spec-doctor.md +2 -2
- package/commands/test-gate.md +7 -7
- package/commands/work-on/build/architect.md +21 -0
- package/commands/work-on/build/context.md +1 -1
- package/commands/work-on/build/implement.md +17 -0
- package/commands/work-on/build/validate.md +60 -9
- package/commands/work-on/build.md +98 -9
- package/commands/work-on/close.md +33 -11
- package/commands/work-on/decompose.md +19 -5
- package/commands/work-on/investigate.md +34 -4
- package/commands/work-on/remediate.md +117 -13
- package/commands/work-on/review.md +66 -26
- package/commands/work-on.md +32 -15
- package/docs/CONFIG.md +30 -0
- package/docs/RELEASE-NOTES.md +9 -0
- package/docs/WORK-ON-RUNTIME.md +24 -4
- package/docs/site/architecture.md +1 -1
- package/docs/spec/forge-protocol-v1.md +10 -0
- package/docs/spec/review-protocol.md +1 -0
- package/package.json +1 -1
- package/packages/protocol/src/phases.js +6 -1
- package/packages/protocol/src/types.js +17 -0
- package/scripts/check-branch-ancestry.sh +82 -0
- package/scripts/check-branch-ancestry.test.sh +78 -0
- package/scripts/check-spec-bash.sh +45 -2
- package/scripts/check-spec-bash.test.sh +24 -1
- package/scripts/check-spec-markers.sh +55 -0
- package/scripts/diff-size.sh +84 -0
- package/scripts/diff-size.test.sh +117 -0
- package/scripts/forge-root.test.sh +16 -1
- package/scripts/remediation-marker-match.test.sh +51 -0
- package/scripts/review-pr-arguments.test.sh +180 -0
- package/scripts/spec-no-worktree-script.test.sh +46 -0
- package/scripts/trusted-comments.test.sh +23 -0
package/bin/engine.mjs
CHANGED
|
@@ -29,6 +29,12 @@ export const DEFAULT_LEASE_RENEW_INTERVAL_MS = 240000;
|
|
|
29
29
|
// need at most one or two pauses in practice.
|
|
30
30
|
export const DEFAULT_MAX_SESSION_LIMIT_PAUSES = 5;
|
|
31
31
|
|
|
32
|
+
// forge#3511: bounded wait applied ONLY when the runner flagged a confirmed
|
|
33
|
+
// usage-limit failure (`err.usageLimit`) but could not produce a parseable
|
|
34
|
+
// reset epoch. The runner never fabricates a reset time; this default lives in
|
|
35
|
+
// the engine and is still capped by `maxSessionLimitPauses`.
|
|
36
|
+
export const DEFAULT_SESSION_LIMIT_WAIT_MS = 30 * 60 * 1000;
|
|
37
|
+
|
|
32
38
|
/**
|
|
33
39
|
* Validates the subset of `runIssue()`'s options that must fail fast,
|
|
34
40
|
* synchronously, before any state I/O or timer creation — `backend` and the
|
|
@@ -44,7 +50,7 @@ export const DEFAULT_MAX_SESSION_LIMIT_PAUSES = 5;
|
|
|
44
50
|
* @param {number} params.maxSessionLimitPauses - forge#2524: bound on consecutive session-limit pauses.
|
|
45
51
|
* @throws {Error & {code: "INVALID_BACKEND"|"INVALID_LEASE_CONFIG"|"INVALID_SESSION_LIMIT_CONFIG"}}
|
|
46
52
|
*/
|
|
47
|
-
function validateRunIssueOptions({ backend, leaseTtlMs, leaseRenewIntervalMs, maxSessionLimitPauses }) {
|
|
53
|
+
function validateRunIssueOptions({ backend, leaseTtlMs, leaseRenewIntervalMs, maxSessionLimitPauses, sessionLimitDefaultWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS }) {
|
|
48
54
|
// forge#2054: validate `backend` before anything else — before state is
|
|
49
55
|
// read/written and before the phase/retry loop begins below. An invalid
|
|
50
56
|
// value must fail fast and non-retryably. Without this check, an invalid
|
|
@@ -128,6 +134,15 @@ function validateRunIssueOptions({ backend, leaseTtlMs, leaseRenewIntervalMs, ma
|
|
|
128
134
|
{ code: "INVALID_SESSION_LIMIT_CONFIG" },
|
|
129
135
|
);
|
|
130
136
|
}
|
|
137
|
+
// forge#3511: same guard for the unknown-reset default wait.
|
|
138
|
+
if (typeof sessionLimitDefaultWaitMs !== "number" || !Number.isFinite(sessionLimitDefaultWaitMs) || sessionLimitDefaultWaitMs < 0) {
|
|
139
|
+
throw Object.assign(
|
|
140
|
+
new Error(
|
|
141
|
+
`Invalid session-limit config: sessionLimitDefaultWaitMs must be a finite number >= 0, got ${typeof sessionLimitDefaultWaitMs === "number" ? sessionLimitDefaultWaitMs : `${typeof sessionLimitDefaultWaitMs} (${JSON.stringify(sessionLimitDefaultWaitMs)})`}.`,
|
|
142
|
+
),
|
|
143
|
+
{ code: "INVALID_SESSION_LIMIT_CONFIG" },
|
|
144
|
+
);
|
|
145
|
+
}
|
|
131
146
|
}
|
|
132
147
|
|
|
133
148
|
/**
|
|
@@ -204,6 +219,14 @@ function makeProgressEmitter(onProgress) {
|
|
|
204
219
|
* Defaults to DEFAULT_MAX_SESSION_LIMIT_PAUSES. Purely a defensive backstop
|
|
205
220
|
* against a persistently-misreported reset time; a genuine reset is
|
|
206
221
|
* expected to need at most one or two pauses in practice.
|
|
222
|
+
* @param {number} [opts.sessionLimitDefaultWaitMs] - forge#3511: how long to
|
|
223
|
+
* pause when the failure is a confirmed usage limit but no reset time could
|
|
224
|
+
* be parsed. Defaults to DEFAULT_SESSION_LIMIT_WAIT_MS; counts against
|
|
225
|
+
* `maxSessionLimitPauses`.
|
|
226
|
+
* @param {boolean} [opts.retry] - forge#3511: explicit opt-in. When the run's
|
|
227
|
+
* terminal reason is `engine-error`, reopen it (RUN_REOPEN) and resume from
|
|
228
|
+
* the last committed phase. Any other terminal reason is left untouched and
|
|
229
|
+
* returns `not-retryable`.
|
|
207
230
|
*/
|
|
208
231
|
export async function runIssue(opts) {
|
|
209
232
|
const { issue, dir, agentId, lane = "staging", io, runner,
|
|
@@ -214,12 +237,19 @@ export async function runIssue(opts) {
|
|
|
214
237
|
// callers keep runner.mjs's own "auto" ladder default unchanged —
|
|
215
238
|
// this is purely additive pass-through, not a new default.
|
|
216
239
|
backend, model,
|
|
240
|
+
// forge#3499: "owner/repo" the phase sub-skills must target
|
|
241
|
+
// (`--repo` / `--gh-flag`). Optional override; otherwise resolved
|
|
242
|
+
// lazily — once, only if a phase's runner is actually invoked —
|
|
243
|
+
// from the cwd-resolved repo (`gh repo view`).
|
|
244
|
+
repo,
|
|
217
245
|
leaseTtlMs = DEFAULT_LEASE_TTL_MS,
|
|
218
246
|
leaseRenewIntervalMs = DEFAULT_LEASE_RENEW_INTERVAL_MS,
|
|
219
247
|
// forge#2524: real default is a genuine setTimeout-based wait —
|
|
220
248
|
// tests inject a fast/no-op replacement (see opts.sleep doc above).
|
|
221
249
|
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
222
250
|
maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES,
|
|
251
|
+
sessionLimitDefaultWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS,
|
|
252
|
+
retry = false,
|
|
223
253
|
onProgress = () => {} } = opts;
|
|
224
254
|
|
|
225
255
|
// forge#2452: extracted into makeProgressEmitter() — see its docstring for
|
|
@@ -230,7 +260,7 @@ export async function runIssue(opts) {
|
|
|
230
260
|
// for the forge#2054/#2313/#2329 rationale and required call-order (before
|
|
231
261
|
// any state I/O or timer creation, unchanged from the inline block this
|
|
232
262
|
// replaces).
|
|
233
|
-
validateRunIssueOptions({ backend, leaseTtlMs, leaseRenewIntervalMs, maxSessionLimitPauses });
|
|
263
|
+
validateRunIssueOptions({ backend, leaseTtlMs, leaseRenewIntervalMs, maxSessionLimitPauses, sessionLimitDefaultWaitMs });
|
|
234
264
|
|
|
235
265
|
const projector = makeProjector(io);
|
|
236
266
|
|
|
@@ -260,6 +290,25 @@ export async function runIssue(opts) {
|
|
|
260
290
|
rewriteLog(dir, issue, eventsFromIndex(state));
|
|
261
291
|
state = deriveState(readLog(dir, issue));
|
|
262
292
|
}
|
|
293
|
+
|
|
294
|
+
// forge#3511: explicit `--retry` reopens a transient `engine-error` terminal
|
|
295
|
+
// so the run resumes from its last committed phase. Placed AFTER the I3
|
|
296
|
+
// lease guard (never weaken that ordering) and BEFORE the unconditional
|
|
297
|
+
// lease write below, which then publishes `terminal:false` at the reopen's
|
|
298
|
+
// higher `v`. Only `engine-error` is ever reopened; needs-human / merged /
|
|
299
|
+
// decomposed / etc. terminals are left untouched.
|
|
300
|
+
let reopened = false;
|
|
301
|
+
if (retry && state?.terminal) {
|
|
302
|
+
if (state.terminalReason !== "engine-error") {
|
|
303
|
+
return {
|
|
304
|
+
terminalReason: "not-retryable",
|
|
305
|
+
detail: `issue ${issue} ended ${state.terminalReason}; --retry only reopens an engine-error run`,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
appendEvent(dir, issue, { event: "RUN_REOPEN", issue });
|
|
309
|
+
state = deriveState(readLog(dir, issue));
|
|
310
|
+
reopened = true;
|
|
311
|
+
}
|
|
263
312
|
// forge#2239: claim the lease unconditionally here — before the phase loop
|
|
264
313
|
// starts, for EVERY reconcile action ("fresh", "remirror", "hydrate", and
|
|
265
314
|
// "local"). Previously only "fresh"/"remirror"/"hydrate" wrote any state at
|
|
@@ -273,6 +322,17 @@ export async function runIssue(opts) {
|
|
|
273
322
|
// check above (commit 541a3e5 fixed the reverse ordering as a real bug —
|
|
274
323
|
// do not reintroduce it).
|
|
275
324
|
await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } });
|
|
325
|
+
// forge#3511: clear the stale label only after the reopened state is
|
|
326
|
+
// published, so a crash in between leaves the run still marked engine-error.
|
|
327
|
+
// On `--retry` this runs whether or not THIS invocation did the reopen: a prior
|
|
328
|
+
// retry may have published the reopen and died before removing the label, so the
|
|
329
|
+
// next one sees a non-terminal state (reopened=false) with the label still set.
|
|
330
|
+
// Best-effort — a transient `gh` error must not abort a reopen already published.
|
|
331
|
+
// Removing an absent label is a harmless no-op.
|
|
332
|
+
if (retry) {
|
|
333
|
+
try { await projector.removeLabel(issue, "workflow:engine-error"); }
|
|
334
|
+
catch (err) { console.warn(`forge#3511: could not clear workflow:engine-error on #${issue}: ${err?.message || err}`); }
|
|
335
|
+
}
|
|
276
336
|
|
|
277
337
|
// 2. Drive phases until terminal.
|
|
278
338
|
//
|
|
@@ -284,8 +344,26 @@ export async function runIssue(opts) {
|
|
|
284
344
|
// against a nonexistent ref and silently evaluated to 0 (forge#2174). The
|
|
285
345
|
// build phase's `reconcile`/`detectOutcome` (bin/engine/phases.mjs) resolve
|
|
286
346
|
// the real branch from the `FORGE:BUILDER` comment instead.
|
|
347
|
+
let repoPromise = null;
|
|
348
|
+
const resolveRepo = () => (repoPromise ??= (async () => {
|
|
349
|
+
if (repo) return String(repo).trim();
|
|
350
|
+
try {
|
|
351
|
+
const out = await io.gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
|
|
352
|
+
return String(out || "").trim() || null;
|
|
353
|
+
} catch { return null; }
|
|
354
|
+
})());
|
|
355
|
+
|
|
287
356
|
let phase;
|
|
288
357
|
while ((phase = pickPhase(state))) {
|
|
358
|
+
// forge#3506 (SPEC-1/SPEC-2): a crash after a terminal-after phase committed (remediate or
|
|
359
|
+
// decompose) and before RUN_TERMINAL leaves its persisted reason on the commit event. Resume
|
|
360
|
+
// must honour that reason instead of picking the next phase (close would throw in buildArgs
|
|
361
|
+
// and end as engine-error; build would run on a decomposed parent). Review's `needs-human`
|
|
362
|
+
// is a handoff to remediate, so only these two committed phases trigger the guard.
|
|
363
|
+
if (state.terminalReason && TERMINAL_REASONS.includes(state.terminalReason) &&
|
|
364
|
+
((state.committed.includes("remediate") && !["merged", "decomposed", "invalid"].includes(state.terminalReason)) ||
|
|
365
|
+
(state.committed.includes("decompose") && state.terminalReason === "decomposed")))
|
|
366
|
+
return await terminate(state, state.terminalReason);
|
|
289
367
|
// forge#2352: state-vs-GitHub divergence guard. Every phase's own
|
|
290
368
|
// `entryCondition` only ever checked `state.committed` (local run-log
|
|
291
369
|
// progress) — never the issue's live GitHub state/labels — so a phase
|
|
@@ -428,18 +506,21 @@ export async function runIssue(opts) {
|
|
|
428
506
|
let pauseCount = 0;
|
|
429
507
|
for (;;) {
|
|
430
508
|
try {
|
|
431
|
-
outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model });
|
|
509
|
+
outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo });
|
|
432
510
|
break;
|
|
433
511
|
} catch (e) {
|
|
434
512
|
const resetAtMs = e.resetAtEpochMs;
|
|
513
|
+
const hasResetEpoch =
|
|
514
|
+
typeof resetAtMs === "number" && Number.isFinite(resetAtMs) && resetAtMs > now();
|
|
515
|
+
// forge#3511: a confirmed usage limit whose reset time is
|
|
516
|
+
// missing/unparseable/already past still pauses, by a bounded default.
|
|
435
517
|
const canPause =
|
|
436
518
|
e.code === "CLI_BACKEND_FAILED" &&
|
|
437
|
-
|
|
438
|
-
resetAtMs > now() &&
|
|
519
|
+
(hasResetEpoch || e.usageLimit === true) &&
|
|
439
520
|
pauseCount < maxSessionLimitPauses;
|
|
440
521
|
if (!canPause) throw e; // falls through to the unchanged outer catch below
|
|
441
522
|
pauseCount += 1;
|
|
442
|
-
const waitMs = Math.max(0, resetAtMs - now());
|
|
523
|
+
const waitMs = hasResetEpoch ? Math.max(0, resetAtMs - now()) : sessionLimitDefaultWaitMs;
|
|
443
524
|
// forge#2524 AC4: append a run-log event so a crash mid-wait
|
|
444
525
|
// leaves a durable record of the pause (deriveState() folds this
|
|
445
526
|
// into RunState.lastRateLimit — see bin/engine/runlog.mjs). This
|
|
@@ -449,15 +530,17 @@ export async function runIssue(opts) {
|
|
|
449
530
|
// was ever written.
|
|
450
531
|
appendEvent(dir, issue, {
|
|
451
532
|
event: "PHASE_RATE_LIMITED", phase: phase.id,
|
|
452
|
-
resetAt: resetAtMs, resetAtDisplay: e.resetAt || null, waitMs,
|
|
533
|
+
resetAt: hasResetEpoch ? resetAtMs : null, resetAtDisplay: e.resetAt || null, waitMs,
|
|
453
534
|
});
|
|
454
535
|
// forge#2524 AC6: progress observer sees the pause so a caller
|
|
455
536
|
// (bin/engine-cli.mjs) can render the wait state instead of the
|
|
456
537
|
// process going silent for the duration.
|
|
457
538
|
emitProgress({
|
|
458
539
|
event: "phase_paused", phase: phase.id, reason: "session-limit",
|
|
459
|
-
resetAt: resetAtMs,
|
|
460
|
-
detail:
|
|
540
|
+
resetAt: hasResetEpoch ? resetAtMs : null,
|
|
541
|
+
detail: hasResetEpoch
|
|
542
|
+
? `session limit hit — pausing until ${e.resetAt || new Date(resetAtMs).toISOString()}`
|
|
543
|
+
: `session limit hit — reset time unknown, waiting default ${Math.round(waitMs / 60000)} min`,
|
|
461
544
|
});
|
|
462
545
|
await sleep(waitMs);
|
|
463
546
|
// Loop back and retry the same phase with a fresh attempt budget
|
|
@@ -522,8 +605,14 @@ export async function runIssue(opts) {
|
|
|
522
605
|
}
|
|
523
606
|
}
|
|
524
607
|
|
|
608
|
+
// forge#3499: remediate takes the PR number as its positional argument, so
|
|
609
|
+
// the handoff only applies when review resolved one. A review that blocked
|
|
610
|
+
// without ever resolving a PR (e.g. retries exhausted before one existed)
|
|
611
|
+
// has nothing to remediate and stays a plain needs-human stop — it must not
|
|
612
|
+
// fall through to `close`, which would otherwise be the next eligible phase.
|
|
525
613
|
const isRemediationHandoff = outcome.status === "blocked" &&
|
|
526
|
-
phase.id === "review" && (outcome.reason || "needs-human") === "needs-human"
|
|
614
|
+
phase.id === "review" && (outcome.reason || "needs-human") === "needs-human" && outcome.handoff !== false &&
|
|
615
|
+
(outcome.outputs?.pr ?? state.pr) != null;
|
|
527
616
|
if (outcome.status === "blocked") {
|
|
528
617
|
emitProgress({ event: "phase_exit", phase: phase.id, status: "blocked", detail: outcome.detail });
|
|
529
618
|
if (!isRemediationHandoff)
|
|
@@ -547,11 +636,24 @@ export async function runIssue(opts) {
|
|
|
547
636
|
// the "committed" path, so that case doesn't apply here). Kept as a
|
|
548
637
|
// sibling of `outputs` rather than nested inside it, since `outputs` is
|
|
549
638
|
// owned by phase.detectOutcome() (bin/engine/phases.mjs).
|
|
550
|
-
|
|
639
|
+
// forge#3503/#3504: a blocked review hands off to remediate, which reads the PR
|
|
640
|
+
// number and the needs-human reason from state re-derived from the run log. Persist
|
|
641
|
+
// both in the PHASE_COMMIT event itself so a rebuild (the next line, or a resume)
|
|
642
|
+
// sees them; in-memory-only values are discarded by deriveState.
|
|
643
|
+
const commitOutputs = { ...(outcome.outputs || {}) };
|
|
644
|
+
if (outcome.status === "blocked" && commitOutputs.pr == null && state.pr != null)
|
|
645
|
+
commitOutputs.pr = state.pr;
|
|
646
|
+
const blockedReason = outcome.status === "blocked" ? (outcome.reason || "needs-human") : null;
|
|
647
|
+
// forge#3506: committed outcomes also carry handoff/terminal reasons (investigate
|
|
648
|
+
// `decomposed`, remediate `merged`/`awaiting-merge`/`needs-human`). Persist them on the
|
|
649
|
+
// commit event too, so a crash before RUN_TERMINAL or a rebuild cannot drop them.
|
|
650
|
+
const commitReason = blockedReason ?? outcome.terminalReason ?? null;
|
|
651
|
+
appendEvent(dir, issue, {
|
|
652
|
+
event: "PHASE_COMMIT", phase: phase.id, outputs: commitOutputs, usage: outcome.usage ?? null,
|
|
653
|
+
...(commitReason ? { terminalReason: commitReason } : {}),
|
|
654
|
+
});
|
|
551
655
|
state = deriveState(readLog(dir, issue));
|
|
552
|
-
const terminalReason = outcome.
|
|
553
|
-
? (outcome.reason || "needs-human")
|
|
554
|
-
: outcome.terminalReason;
|
|
656
|
+
const terminalReason = blockedReason ?? outcome.terminalReason;
|
|
555
657
|
if (terminalReason) state.terminalReason = terminalReason;
|
|
556
658
|
await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } });
|
|
557
659
|
|
|
@@ -597,7 +699,21 @@ export async function runIssue(opts) {
|
|
|
597
699
|
}
|
|
598
700
|
|
|
599
701
|
async function runPhaseWithRetry(phase, state, ctx) {
|
|
600
|
-
const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model } = ctx;
|
|
702
|
+
const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo } = ctx;
|
|
703
|
+
// forge#3499: build the phase's full argument set ONCE, up front. Every
|
|
704
|
+
// work-on phase sub-skill stops with BLOCKED when a required flag is absent,
|
|
705
|
+
// so an incomplete/invalid set is a deterministic engine failure — fail
|
|
706
|
+
// closed here (no runner call, no retries) instead of sending a partial
|
|
707
|
+
// `[issue]`-only invocation that burns the attempt budget.
|
|
708
|
+
let phaseArgs;
|
|
709
|
+
try {
|
|
710
|
+
phaseArgs = await phase.buildArgs(state, { repo: await resolveRepo() }, io);
|
|
711
|
+
} catch (e) {
|
|
712
|
+
if (e?.code !== "PHASE_ARGS_INVALID") throw e;
|
|
713
|
+
const detail = `phase ${phase.id} args unresolved: ${e.message}`;
|
|
714
|
+
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt: 0, reason: detail, maxAttempts });
|
|
715
|
+
return { status: "blocked", detail, reason: "engine-error", usage: null };
|
|
716
|
+
}
|
|
601
717
|
// forge#2261: true only if EVERY attempt failed by the runner itself
|
|
602
718
|
// throwing (never once reached phase.detectOutcome()). This is the signal
|
|
603
719
|
// that distinguishes an engine/tool crash (the tool never even produced a
|
|
@@ -621,7 +737,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
621
737
|
let result;
|
|
622
738
|
try {
|
|
623
739
|
result = await runner({
|
|
624
|
-
commandsDir, commandName: phase.command, args:
|
|
740
|
+
commandsDir, commandName: phase.command, args: phaseArgs,
|
|
625
741
|
// Only forwarded when explicitly provided — omitting them preserves
|
|
626
742
|
// runner.mjs's existing default ("auto" backend / DEFAULT_MODEL).
|
|
627
743
|
...(backend ? { backend } : {}),
|
|
@@ -656,7 +772,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
656
772
|
}
|
|
657
773
|
allAttemptsThrew = false;
|
|
658
774
|
lastUsage = result?.usage ?? null;
|
|
659
|
-
const outcome = await phase.detectOutcome(state, io);
|
|
775
|
+
const outcome = await phase.detectOutcome(state, io, result);
|
|
660
776
|
if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: lastUsage };
|
|
661
777
|
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail, maxAttempts, usage: lastUsage });
|
|
662
778
|
// forge#2176: a phase's detectOutcome can mark a failure as a known,
|
|
@@ -670,7 +786,9 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
670
786
|
// for every phase that doesn't opt in — investigate/context/architect/
|
|
671
787
|
// review/close — is unchanged, preserving transient-failure retries).
|
|
672
788
|
if (outcome.retryable === false) {
|
|
673
|
-
|
|
789
|
+
// forge#3521: keep outcome.outputs (e.g. the PR number) so the blocked
|
|
790
|
+
// commit persists it and isRemediationHandoff can see it.
|
|
791
|
+
return { status: "blocked", detail: outcome.detail, outputs: outcome.outputs, handoff: outcome.handoff, usage: lastUsage };
|
|
674
792
|
}
|
|
675
793
|
}
|
|
676
794
|
// Exhausted transient retries → escalate (spec §7).
|
|
@@ -706,6 +824,14 @@ function eventsFromIndex(idx) {
|
|
|
706
824
|
if (phase === "review" && idx.pr != null) outputs.pr = idx.pr;
|
|
707
825
|
events.push({ event: "PHASE_COMMIT", phase, outputs });
|
|
708
826
|
}
|
|
827
|
+
// forge#3506: a non-terminal reason is a handoff (e.g. review -> remediate). Replay it on
|
|
828
|
+
// the last committed PHASE_COMMIT (deriveState is last-wins), never as RUN_TERMINAL, which
|
|
829
|
+
// would terminate the run. Nothing to attach it to when no phase is committed.
|
|
830
|
+
if (!idx.terminal && idx.terminalReason && idx.committed.length > 0) {
|
|
831
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
832
|
+
if (events[i].event === "PHASE_COMMIT") { events[i].terminalReason = idx.terminalReason; break; }
|
|
833
|
+
}
|
|
834
|
+
}
|
|
709
835
|
if (idx.terminal) events.push({ event: "RUN_TERMINAL", reason: idx.terminalReason });
|
|
710
836
|
return events;
|
|
711
837
|
}
|
package/bin/forgedock.mjs
CHANGED
|
@@ -3207,7 +3207,7 @@ function help() {
|
|
|
3207
3207
|
["npx forgedock disable [dir]", "Opt a directory out of ForgeDock"],
|
|
3208
3208
|
["npx forgedock status [dir]", "Show ForgeDock state for a directory"],
|
|
3209
3209
|
["npx forgedock run <cmd> [args]", "Run a command headlessly (local claude CLI or Anthropic API)"],
|
|
3210
|
-
["npx forgedock run-issue <issue>", "Drive one issue through the durable engine"],
|
|
3210
|
+
["npx forgedock run-issue <issue> --lane <lane> [--retry]", "Drive one issue through the durable engine (--retry reopens an engine-error run)"],
|
|
3211
3211
|
["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
|
|
3212
3212
|
["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
|
|
3213
3213
|
["npx forgedock labels [setup] [--repo owner/repo]", "Bootstrap ForgeDock-managed labels on a GitHub repo (idempotent)"],
|
|
@@ -3971,7 +3971,7 @@ switch (command) {
|
|
|
3971
3971
|
// an uncaught throw, which already set exitCode = 1 below. Now that
|
|
3972
3972
|
// runIssue() resolves cleanly with a terminalReason instead of throwing,
|
|
3973
3973
|
// preserve that same non-zero exit code for this reason too.
|
|
3974
|
-
if (result && (result.terminalReason === "needs-human" || result.terminalReason === "engine-error")) exitCode = 1;
|
|
3974
|
+
if (result && (result.terminalReason === "needs-human" || result.terminalReason === "engine-error" || result.terminalReason === "not-retryable")) exitCode = 1;
|
|
3975
3975
|
} catch (err) {
|
|
3976
3976
|
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
3977
3977
|
exitCode = 1;
|
package/bin/runner.mjs
CHANGED
|
@@ -933,6 +933,53 @@ export function sanitizeOutputExcerptForLog(output) {
|
|
|
933
933
|
return sanitizeAndCap(String(output), MAX_LOGGED_OUTPUT_EXCERPT_LEN);
|
|
934
934
|
}
|
|
935
935
|
|
|
936
|
+
// Shared usage-limit phrasing: detection and reset extraction must agree on it.
|
|
937
|
+
const USAGE_LIMIT_PHRASE = "(?:session|weekly|usage) limit";
|
|
938
|
+
const USAGE_LIMIT_RE = new RegExp(USAGE_LIMIT_PHRASE, "i");
|
|
939
|
+
// `... limit · resets 3pm (Asia/Kolkata)` — capture stops at the zone's `)`.
|
|
940
|
+
const USAGE_LIMIT_RESET_PAREN_RE = new RegExp(
|
|
941
|
+
`${USAGE_LIMIT_PHRASE}[^\\n"]*?resets?\\s+([^\\n"]+?\\))`, "i");
|
|
942
|
+
// Zone-less fallback: capture to end of line, but never across a quote.
|
|
943
|
+
const USAGE_LIMIT_RESET_LINE_RE = new RegExp(
|
|
944
|
+
`${USAGE_LIMIT_PHRASE}[^\\n"]*?resets?\\s+([^\\n"]+?)\\s*$`, "im");
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Parses a `claude --output-format json` single-result envelope from `text`.
|
|
948
|
+
* Returns the parsed object, or `undefined` when `text` is not exactly one
|
|
949
|
+
* JSON object. Never throws.
|
|
950
|
+
*/
|
|
951
|
+
function parseCliEnvelope(text) {
|
|
952
|
+
if (typeof text !== "string") return undefined;
|
|
953
|
+
const trimmed = text.trim();
|
|
954
|
+
if (!trimmed.startsWith("{")) return undefined;
|
|
955
|
+
try {
|
|
956
|
+
const parsed = JSON.parse(trimmed);
|
|
957
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
|
|
958
|
+
} catch {
|
|
959
|
+
return undefined;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Detects whether a failed CLI run was a Claude usage/session/weekly limit.
|
|
965
|
+
* Prefers the envelope's structured fields (`api_error: "usage_limit_reached"`,
|
|
966
|
+
* `api_error_status: 429`), then the envelope's `result` text, then the plain
|
|
967
|
+
* combined output. `stdout` is parsed ALONE (forge#2422 invariant); never throws.
|
|
968
|
+
*
|
|
969
|
+
* @param {string} stdout - raw stdout of the CLI run
|
|
970
|
+
* @param {string} [output] - combined stdout+stderr, for the plain-text fallback
|
|
971
|
+
* @returns {boolean}
|
|
972
|
+
*/
|
|
973
|
+
export function detectUsageLimit(stdout, output = "") {
|
|
974
|
+
const env = parseCliEnvelope(stdout);
|
|
975
|
+
if (env) {
|
|
976
|
+
if (env.api_error === "usage_limit_reached" || env.api_error_status === 429) return true;
|
|
977
|
+
if (typeof env.result === "string" && USAGE_LIMIT_RE.test(env.result)) return true;
|
|
978
|
+
return false;
|
|
979
|
+
}
|
|
980
|
+
return USAGE_LIMIT_RE.test(output || "") && /resets?\s/i.test(output || "");
|
|
981
|
+
}
|
|
982
|
+
|
|
936
983
|
/**
|
|
937
984
|
* Best-effort extraction of a Claude CLI session-limit reset time from
|
|
938
985
|
* captured stdout/stderr (forge#2241). The CLI's own session-limit message
|
|
@@ -952,7 +999,15 @@ export function sanitizeOutputExcerptForLog(output) {
|
|
|
952
999
|
*/
|
|
953
1000
|
export function extractSessionLimitResetTime(output) {
|
|
954
1001
|
if (!output) return undefined;
|
|
955
|
-
|
|
1002
|
+
// The engine runs phases with `--output-format json`, so the limit message
|
|
1003
|
+
// usually arrives inside a one-line JSON envelope. Read the structured
|
|
1004
|
+
// `result` text when the whole input parses; otherwise fall back to the raw
|
|
1005
|
+
// text. The plain-text captures below never cross a quote or newline, so a
|
|
1006
|
+
// JSON-wrapped message cannot swallow the trailing `","type":...` fields.
|
|
1007
|
+
const text = parseCliEnvelope(output)?.result ?? output;
|
|
1008
|
+
const source = typeof text === "string" ? text : output;
|
|
1009
|
+
const match =
|
|
1010
|
+
USAGE_LIMIT_RESET_PAREN_RE.exec(source) ?? USAGE_LIMIT_RESET_LINE_RE.exec(source);
|
|
956
1011
|
if (!match) return undefined;
|
|
957
1012
|
const resetAt = match[1].trim();
|
|
958
1013
|
if (resetAt.length === 0) return undefined;
|
|
@@ -1036,19 +1091,26 @@ function wallTimeInZoneToEpochMs(y, mo, d, h, mi, s, timeZone) {
|
|
|
1036
1091
|
*/
|
|
1037
1092
|
export function parseSessionLimitResetEpochMs(resetAtText, nowMs = Date.now()) {
|
|
1038
1093
|
if (!resetAtText) return undefined;
|
|
1039
|
-
const match = /^(\d{1,2})
|
|
1094
|
+
const match = /^(\d{1,2})(?::(\d{2}))?\s*([ap]m)?\s*\(([^)]+)\)\s*$/i.exec(resetAtText.trim());
|
|
1040
1095
|
if (!match) return undefined;
|
|
1041
1096
|
const [, hourStr, minuteStr, ampm, timeZone] = match;
|
|
1042
1097
|
const rawHour = parseInt(hourStr, 10);
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1098
|
+
let hour;
|
|
1099
|
+
if (ampm) {
|
|
1100
|
+
// Reject out-of-range hours BEFORE the `% 12` conversion below — otherwise
|
|
1101
|
+
// malformed input like "13:30pm" or "99:30pm" silently aliases to a
|
|
1102
|
+
// plausible-but-wrong hour (13 % 12 = 1, 99 % 12 = 3) instead of being
|
|
1103
|
+
// rejected, violating this function's own "never fabricate a value"
|
|
1104
|
+
// contract. Mirrors the `minute > 59` guard further down.
|
|
1105
|
+
if (rawHour < 1 || rawHour > 12) return undefined;
|
|
1106
|
+
hour = rawHour % 12;
|
|
1107
|
+
if (/pm/i.test(ampm)) hour += 12;
|
|
1108
|
+
} else {
|
|
1109
|
+
// 24-hour form: minutes are required (a bare "15" is too ambiguous).
|
|
1110
|
+
if (minuteStr === undefined || rawHour > 23) return undefined;
|
|
1111
|
+
hour = rawHour;
|
|
1112
|
+
}
|
|
1113
|
+
const minute = minuteStr === undefined ? 0 : parseInt(minuteStr, 10);
|
|
1052
1114
|
if (!Number.isFinite(hour) || !Number.isFinite(minute) || minute > 59) return undefined;
|
|
1053
1115
|
|
|
1054
1116
|
let todayParts;
|
|
@@ -1261,7 +1323,14 @@ export function runCliBackend({
|
|
|
1261
1323
|
// present) so callers (bin/engine.mjs) can surface *when* a
|
|
1262
1324
|
// quota-exhaustion failure will clear without reading raw logs. Only
|
|
1263
1325
|
// ever set when the pattern actually matches — never fabricated.
|
|
1264
|
-
|
|
1326
|
+
// The reset text is read from stdout's parsed envelope first (stdout
|
|
1327
|
+
// ALONE, forge#2422), then from the combined output.
|
|
1328
|
+
const resetAt =
|
|
1329
|
+
extractSessionLimitResetTime(stdout) ?? extractSessionLimitResetTime(output);
|
|
1330
|
+
// Structured usage-limit flag: lets the engine pause by a bounded
|
|
1331
|
+
// default when the reset time itself is missing or unparseable. The
|
|
1332
|
+
// engine, not the runner, owns that default — no epoch is fabricated here.
|
|
1333
|
+
if (detectUsageLimit(stdout, output)) err.usageLimit = true;
|
|
1265
1334
|
if (resetAt) {
|
|
1266
1335
|
err.resetAt = resetAt;
|
|
1267
1336
|
// forge#2524: also attach a machine-usable epoch-ms timestamp so
|
|
@@ -1392,6 +1461,9 @@ export function runCliBackend({
|
|
|
1392
1461
|
usage,
|
|
1393
1462
|
model: "cli",
|
|
1394
1463
|
backend: "cli",
|
|
1464
|
+
// forge#3521: the phase's final reply (the CLI envelope's `.result`), so
|
|
1465
|
+
// callers can read its structured `*_RESULT` block. Additive; "" when absent.
|
|
1466
|
+
text: parsedResult ?? "",
|
|
1395
1467
|
};
|
|
1396
1468
|
} finally {
|
|
1397
1469
|
if (tmpDir) {
|
|
@@ -2377,6 +2449,11 @@ export async function runCommand(opts = {}) {
|
|
|
2377
2449
|
usage,
|
|
2378
2450
|
model,
|
|
2379
2451
|
backend: "api",
|
|
2452
|
+
// forge#3521: final assistant text (see the CLI backend's `text`).
|
|
2453
|
+
text: (response.content || [])
|
|
2454
|
+
.filter((blk) => blk.type === "text" && typeof blk.text === "string")
|
|
2455
|
+
.map((blk) => blk.text)
|
|
2456
|
+
.join("\n"),
|
|
2380
2457
|
};
|
|
2381
2458
|
}
|
|
2382
2459
|
|
package/commands/ci-audit.md
CHANGED
|
@@ -253,7 +253,7 @@ For each gap detected (tool present in repo but no CI validation step found), fi
|
|
|
253
253
|
**Skip issue creation if `--dry-run` flag is passed** (or `forge.yaml → ci_audit.dry_run: true`):
|
|
254
254
|
```bash
|
|
255
255
|
DRY_RUN=$(yq '.ci_audit.dry_run // "false"' forge.yaml 2>/dev/null || echo "false")
|
|
256
|
-
[ "$1" = "--dry-run" ] || [ "$2" = "--dry-run" ] && DRY_RUN="true"
|
|
256
|
+
[ "${1}" = "--dry-run" ] || [ "${2}" = "--dry-run" ] && DRY_RUN="true"
|
|
257
257
|
```
|
|
258
258
|
|
|
259
259
|
### 3A: Determine validator recommendation
|
|
@@ -262,8 +262,8 @@ For each gap, compose a concrete, version-aware recommendation:
|
|
|
262
262
|
|
|
263
263
|
```bash
|
|
264
264
|
get_validator_recommendation() {
|
|
265
|
-
local tool="$1"
|
|
266
|
-
local version="$2" # may be empty if unknown
|
|
265
|
+
local tool="${1}"
|
|
266
|
+
local version="${2}" # may be empty if unknown
|
|
267
267
|
|
|
268
268
|
case "$tool" in
|
|
269
269
|
traefik)
|
package/commands/compat-audit.md
CHANGED
|
@@ -28,7 +28,7 @@ fi
|
|
|
28
28
|
# Fallback: resolve from this command file's own location if yq is unavailable
|
|
29
29
|
# or forge.yaml is absent. The commands/ directory is always inside FORGE_HOME.
|
|
30
30
|
if [ -z "$FORGE_HOME_PATH" ] || [ ! -d "$FORGE_HOME_PATH" ]; then
|
|
31
|
-
FORGE_HOME_PATH="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." 2>/dev/null && pwd)"
|
|
31
|
+
FORGE_HOME_PATH="$(cd "$(dirname "${BASH_SOURCE[0]:-${0}}")/.." 2>/dev/null && pwd)"
|
|
32
32
|
fi
|
|
33
33
|
export FORGE_HOME_PATH
|
|
34
34
|
```
|
package/commands/deploy-pr.md
CHANGED
|
@@ -93,12 +93,12 @@ DRY_RUN=false
|
|
|
93
93
|
# Parse optional flags
|
|
94
94
|
shift 2>/dev/null || true
|
|
95
95
|
while [[ $# -gt 0 ]]; do
|
|
96
|
-
case "$1" in
|
|
97
|
-
--target) TARGET="$2"; shift 2 ;;
|
|
98
|
-
--issue) ISSUE_NUMBER="$2"; shift 2 ;;
|
|
99
|
-
--repo) GH_REPO="$2"; GH_FLAG="-R $GH_REPO"; shift 2 ;;
|
|
100
|
-
--max-ci-iterations) MAX_CI_ITER="$2"; shift 2 ;;
|
|
101
|
-
--max-review-iterations) MAX_REVIEW_ITER="$2"; shift 2 ;;
|
|
96
|
+
case "${1}" in
|
|
97
|
+
--target) TARGET="${2}"; shift 2 ;;
|
|
98
|
+
--issue) ISSUE_NUMBER="${2}"; shift 2 ;;
|
|
99
|
+
--repo) GH_REPO="${2}"; GH_FLAG="-R $GH_REPO"; shift 2 ;;
|
|
100
|
+
--max-ci-iterations) MAX_CI_ITER="${2}"; shift 2 ;;
|
|
101
|
+
--max-review-iterations) MAX_REVIEW_ITER="${2}"; shift 2 ;;
|
|
102
102
|
--dry-run) DRY_RUN=true; shift ;;
|
|
103
103
|
*) shift ;;
|
|
104
104
|
esac
|
package/commands/explain.md
CHANGED
|
@@ -140,10 +140,10 @@ Extract each annotation block from the comments. Each `<!-- FORGE:TYPE -->` bloc
|
|
|
140
140
|
```bash
|
|
141
141
|
# Helper: extract comment body containing a FORGE marker
|
|
142
142
|
extract_forge() {
|
|
143
|
-
local marker="$1"
|
|
143
|
+
local marker="${1}"
|
|
144
144
|
echo "$ALL_COMMENTS" | awk "
|
|
145
145
|
/<!-- ${marker}(:[A-Z]*)? -->/{found=1; buffer=\"\"}
|
|
146
|
-
found {buffer = buffer \$0 ORS}
|
|
146
|
+
found {buffer = buffer \$(0) ORS}
|
|
147
147
|
/<!-- ${marker}[^>]*:COMPLETE -->/{if(found){print buffer; found=0; buffer=\"\"}}
|
|
148
148
|
END {if(found && length(buffer)>0) print buffer}
|
|
149
149
|
"
|
package/commands/fix-ci.md
CHANGED
|
@@ -49,7 +49,7 @@ Parse `$ARGUMENTS`:
|
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
51
|
# Extract PR number (accept raw number or full GitHub URL)
|
|
52
|
-
PR_INPUT=$(echo "$ARGUMENTS" | awk '{print $1}')
|
|
52
|
+
PR_INPUT=$(echo "$ARGUMENTS" | awk '{print $(1)}')
|
|
53
53
|
if echo "$PR_INPUT" | grep -qE '^https?://'; then
|
|
54
54
|
PR_NUMBER=$(echo "$PR_INPUT" | grep -oE '[0-9]+$')
|
|
55
55
|
else
|
|
@@ -58,7 +58,7 @@ fi
|
|
|
58
58
|
|
|
59
59
|
MAX_ATTEMPTS=5
|
|
60
60
|
if echo "$ARGUMENTS" | grep -q -- "--max-attempts"; then
|
|
61
|
-
MAX_ATTEMPTS=$(echo "$ARGUMENTS" | grep -oE -- '--max-attempts [0-9]+' | awk '{print $2}')
|
|
61
|
+
MAX_ATTEMPTS=$(echo "$ARGUMENTS" | grep -oE -- '--max-attempts [0-9]+' | awk '{print $(2)}')
|
|
62
62
|
fi
|
|
63
63
|
|
|
64
64
|
if [ -z "$PR_NUMBER" ]; then
|
package/commands/forge-stats.md
CHANGED
|
@@ -104,7 +104,7 @@ for cmd in commands/*.md; do
|
|
|
104
104
|
done
|
|
105
105
|
|
|
106
106
|
echo ""
|
|
107
|
-
echo "Total: $(wc -l commands/*.md | tail -1 | awk '{print $1}') lines across $(ls commands/*.md | wc -l) commands"
|
|
107
|
+
echo "Total: $(wc -l commands/*.md | tail -1 | awk '{print $(1)}') lines across $(ls commands/*.md | wc -l) commands"
|
|
108
108
|
```
|
|
109
109
|
|
|
110
110
|
**After running, flag any command where:**
|
|
@@ -576,7 +576,7 @@ gh repo view "${OWNER}/${REPO}" --json name,url 2>/dev/null \
|
|
|
576
576
|
### 6B: Path existence
|
|
577
577
|
|
|
578
578
|
```bash
|
|
579
|
-
[ -d "$(grep 'root:' "$FORGE_YAML" | head -1 | awk '{print $2}' | tr -d '"')" ] \
|
|
579
|
+
[ -d "$(grep 'root:' "$FORGE_YAML" | head -1 | awk '{print $(2)}' | tr -d '"')" ] \
|
|
580
580
|
&& echo "✓ paths.root exists" \
|
|
581
581
|
|| echo "WARNING: paths.root does not exist on this machine"
|
|
582
582
|
```
|
|
@@ -588,7 +588,7 @@ gh repo view "${OWNER}/${REPO}" --json name,url 2>/dev/null \
|
|
|
588
588
|
```bash
|
|
589
589
|
# Only run if project_board section was written
|
|
590
590
|
if grep -q "^project_board:" "$FORGE_YAML"; then
|
|
591
|
-
PROJECT_ID=$(grep "project_id:" "$FORGE_YAML" | head -1 | awk '{print $2}' | tr -d '"')
|
|
591
|
+
PROJECT_ID=$(grep "project_id:" "$FORGE_YAML" | head -1 | awk '{print $(2)}' | tr -d '"')
|
|
592
592
|
# Validate PVT_ prefix before GraphQL call — any other format will produce a confusing API error
|
|
593
593
|
if [[ "$PROJECT_ID" != PVT_* ]]; then
|
|
594
594
|
echo "ERROR: project_board.project_id must start with 'PVT_' (got: '$PROJECT_ID')"
|
|
@@ -610,7 +610,7 @@ fi
|
|
|
610
610
|
if grep -q "^repos:" "$FORGE_YAML"; then
|
|
611
611
|
# Extract satellite repo names and verify each is accessible
|
|
612
612
|
grep -A 20 "^repos:" "$FORGE_YAML" | grep "repo:" | tail -n +2 | while read -r line; do
|
|
613
|
-
SAT_REPO=$(echo "$line" | awk '{print $2}' | tr -d '"')
|
|
613
|
+
SAT_REPO=$(echo "$line" | awk '{print $(2)}' | tr -d '"')
|
|
614
614
|
gh repo view "$SAT_REPO" --json name 2>/dev/null \
|
|
615
615
|
&& echo "✓ satellite repo ${SAT_REPO} accessible" \
|
|
616
616
|
|| echo "WARNING: satellite repo ${SAT_REPO} not accessible — check prefix and repo name"
|
package/commands/optimize.md
CHANGED
|
@@ -250,7 +250,7 @@ declare -A LABEL_MAP=(
|
|
|
250
250
|
)
|
|
251
251
|
|
|
252
252
|
map_label() {
|
|
253
|
-
local canonical="$1"
|
|
253
|
+
local canonical="${1}"
|
|
254
254
|
echo "${LABEL_MAP[$canonical]:-$canonical}"
|
|
255
255
|
}
|
|
256
256
|
SCRIPT_EOF
|
|
@@ -514,8 +514,8 @@ for candidate in "${TIER_A_PROMOTIONS[@]}"; do
|
|
|
514
514
|
# Generated: $(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
515
515
|
#
|
|
516
516
|
# CONTRACT:
|
|
517
|
-
# \$1 = absolute path to unified diff (git diff HEAD)
|
|
518
|
-
# \$2 = absolute path to worktree root
|
|
517
|
+
# \${1} = absolute path to unified diff (git diff HEAD)
|
|
518
|
+
# \${2} = absolute path to worktree root
|
|
519
519
|
# stdout = findings lines: SLUG | SEVERITY | FILE | MESSAGE
|
|
520
520
|
# exit 0 = pass, exit 1 = findings, exit 2 = inapplicable
|
|
521
521
|
# 30s timeout enforced by quality-gate (fail-closed)
|
|
@@ -612,8 +612,8 @@ ${CITATIONS_BODY}
|
|
|
612
612
|
## gate.d contract
|
|
613
613
|
|
|
614
614
|
Each script receives:
|
|
615
|
-
- \$1 = path to unified diff
|
|
616
|
-
- \$2 = path to worktree root
|
|
615
|
+
- \${1} = path to unified diff
|
|
616
|
+
- \${2} = path to worktree root
|
|
617
617
|
- exits 0 (pass), 1 (findings on stdout), or 2 (inapplicable)
|
|
618
618
|
- 30-second timeout enforced by quality-gate (fail-closed on timeout)
|
|
619
619
|
|