forgedock 1.7.1 → 1.8.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/admission.mjs +242 -7
- package/bin/engine/admission.test.mjs +183 -2
- package/bin/engine/phases.mjs +31 -22
- package/bin/engine/resolve.mjs +8 -1
- package/bin/engine/resolve.test.mjs +5 -0
- package/bin/engine.mjs +26 -17
- package/bin/forgedock.mjs +15 -2
- package/bin/hooks/pre-tool-use.mjs +573 -2
- package/bin/journey.mjs +11 -7
- package/bin/labels.json +5 -0
- package/bin/opencode-adapter.mjs +47 -13
- package/bin/orchestrate-preflight.mjs +434 -0
- package/bin/runner.mjs +30 -4
- package/commands/audit-agents.md +75 -26
- package/commands/autopilot.md +4 -4
- package/commands/cleanup.md +10 -5
- package/commands/issue.md +95 -35
- package/commands/orchestrate/phase-1-resolve.md +70 -32
- package/commands/orchestrate/phase-3-dependency.md +141 -13
- package/commands/orchestrate/phase-4-execution.md +660 -70
- package/commands/orchestrate/phase-5-cleanup.md +5 -11
- package/commands/orchestrate/phase-6-report.md +27 -1
- package/commands/orchestrate/safety.md +1 -1
- package/commands/orchestrate.md +39 -1
- package/commands/quality-gate.md +30 -10
- package/commands/review-pr-agents/protocols.md +13 -0
- package/commands/review-pr-agents.md +1 -1
- package/commands/review-pr-staging.md +100 -18
- package/commands/review-pr.md +78 -24
- package/commands/work-on/build/validate.md +25 -4
- package/commands/work-on/close.md +40 -10
- package/commands/work-on/decompose.md +33 -1
- package/commands/work-on/investigate.md +33 -0
- package/commands/work-on/remediate.md +40 -5
- package/commands/work-on/review.md +27 -0
- package/commands/work-on.md +48 -5
- package/docs/CONFIG.md +45 -10
- package/docs/OPENCODE.md +21 -6
- package/docs/spec/review-protocol.md +7 -0
- package/package.json +1 -1
- package/scripts/check-admission-parity.mjs +1 -0
- package/scripts/check-command-side-effects.sh +14 -13
- package/scripts/check-shell-portability.sh +36 -0
- package/scripts/check-spec-markers.sh +21 -11
- package/scripts/checkbox-sections.test.sh +67 -0
- package/scripts/extract-affected-files.sh +85 -5
- package/scripts/extract-affected-files.test.sh +154 -2
package/bin/engine/phases.mjs
CHANGED
|
@@ -116,6 +116,23 @@ async function commitsAhead(lane, branch, io) {
|
|
|
116
116
|
*/
|
|
117
117
|
function has(blob, marker) { return blob.includes(marker); }
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The interactive workflow persists its conservative complexity decision in a
|
|
121
|
+
* FORGE:FAST_PATH comment. The engine must consume that decision too; otherwise
|
|
122
|
+
* its separate context/architect phases negate the documented trivial path.
|
|
123
|
+
* Only an exact TRIVIAL value is eligible to skip work, so malformed or absent
|
|
124
|
+
* annotations continue through the full pipeline.
|
|
125
|
+
*/
|
|
126
|
+
function complexityBand(comments) {
|
|
127
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
128
|
+
const body = comments[i];
|
|
129
|
+
if (!body?.includes("FORGE:FAST_PATH")) continue;
|
|
130
|
+
const match = body.match(/\*\*COMPLEXITY_BAND\*\*:\s*([A-Z_]+)/);
|
|
131
|
+
if (match) return match[1];
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
119
136
|
/**
|
|
120
137
|
* Fetch the issue's live `state` (OPEN/CLOSED) and `labels` in one call.
|
|
121
138
|
*
|
|
@@ -254,10 +271,13 @@ export const PHASES = [
|
|
|
254
271
|
command: "work-on/build/context",
|
|
255
272
|
entryCondition: (s) => s.committed.includes("investigate"),
|
|
256
273
|
async reconcile(state, io) {
|
|
274
|
+
const markers = await issueMarkers(state.issue, io);
|
|
275
|
+
if (complexityBand(markers.comments) === "TRIVIAL") {
|
|
276
|
+
return { satisfied: true, outputs: { skipped: "trivial-complexity-band", phase: "context" } };
|
|
277
|
+
}
|
|
257
278
|
// Idempotent resume: FORGE:CONTEXT:COMPLETE present → skip the LLM re-run.
|
|
258
279
|
// Bare FORGE:CONTEXT matches a partial/interrupted annotation — require :COMPLETE.
|
|
259
|
-
|
|
260
|
-
return has(blob, PHASE_MARKERS.context.completionMarker) ? { satisfied: true } : { satisfied: false };
|
|
280
|
+
return has(markers.blob, PHASE_MARKERS.context.completionMarker) ? { satisfied: true } : { satisfied: false };
|
|
261
281
|
},
|
|
262
282
|
async detectOutcome(state, io) {
|
|
263
283
|
const { blob } = await issueMarkers(state.issue, io);
|
|
@@ -271,10 +291,13 @@ export const PHASES = [
|
|
|
271
291
|
command: "work-on/build/architect",
|
|
272
292
|
entryCondition: (s) => s.committed.includes("context"),
|
|
273
293
|
async reconcile(state, io) {
|
|
294
|
+
const markers = await issueMarkers(state.issue, io);
|
|
295
|
+
if (complexityBand(markers.comments) === "TRIVIAL") {
|
|
296
|
+
return { satisfied: true, outputs: { skipped: "trivial-complexity-band", phase: "architect" } };
|
|
297
|
+
}
|
|
274
298
|
// Idempotent resume: FORGE:ARCHITECT:COMPLETE present → skip the LLM re-run.
|
|
275
299
|
// Bare FORGE:ARCHITECT matches a partial/interrupted annotation — require :COMPLETE.
|
|
276
|
-
|
|
277
|
-
return has(blob, PHASE_MARKERS.architect.completionMarker) ? { satisfied: true } : { satisfied: false };
|
|
300
|
+
return has(markers.blob, PHASE_MARKERS.architect.completionMarker) ? { satisfied: true } : { satisfied: false };
|
|
278
301
|
},
|
|
279
302
|
async detectOutcome(state, io) {
|
|
280
303
|
const { blob } = await issueMarkers(state.issue, io);
|
|
@@ -356,7 +379,7 @@ export const PHASES = [
|
|
|
356
379
|
if (!pr) return { status: "failed", detail: "no PR created" };
|
|
357
380
|
if (pr.merged) return { status: "committed", outputs: { pr: pr.number } };
|
|
358
381
|
if (pr.needsHuman) return { status: "blocked", detail: "review escalated", outputs: { pr: pr.number } };
|
|
359
|
-
return { status: "failed", detail: "PR open, not merged" };
|
|
382
|
+
return { status: "failed", detail: "PR open, not merged", retryable: false };
|
|
360
383
|
},
|
|
361
384
|
},
|
|
362
385
|
{
|
|
@@ -370,23 +393,9 @@ export const PHASES = [
|
|
|
370
393
|
// phase table (closing the literal "remediate appears nowhere in the
|
|
371
394
|
// engine" gap) and is fully unit-tested via `pickPhase`/`detectOutcome`.
|
|
372
395
|
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
// `terminate()` immediately, before `review` is ever added to
|
|
377
|
-
// `state.committed` — and the divergence guard just above that also
|
|
378
|
-
// pauses before any non-`close` phase once the issue carries
|
|
379
|
-
// `needs-human`. So a single continuous `runIssue()` walk cannot reach
|
|
380
|
-
// this phase's `entryCondition` today. In practice `remediate.md` is
|
|
381
|
-
// (correctly, per `work-on.md` Phase 0A.1) invoked as its own separate
|
|
382
|
-
// top-level entry point (`/work-on <pr> --remediate`), not as a
|
|
383
|
-
// continuation of the original run — this phase entry documents and
|
|
384
|
-
// tests the target state shape for a future run that reconstructs
|
|
385
|
-
// `committed`/`terminalReason` from live GitHub state (e.g. a dedicated
|
|
386
|
-
// remediation-run entry point) rather than from a fresh local run-log.
|
|
387
|
-
// Making that live wiring real is out of this issue's scope — it needs
|
|
388
|
-
// changes to `review`'s `"blocked"` contract and to `bin/tests/engine.test.mjs`,
|
|
389
|
-
// both outside this issue's declared file set.
|
|
396
|
+
// A blocked review is committed with terminalReason "needs-human" before
|
|
397
|
+
// the engine selects this phase, preserving the review verdict while
|
|
398
|
+
// handing the PR to remediation in the same run.
|
|
390
399
|
id: "remediate",
|
|
391
400
|
command: "work-on/remediate",
|
|
392
401
|
entryCondition: (s) => s.committed.includes("review") && s.terminalReason === "needs-human",
|
package/bin/engine/resolve.mjs
CHANGED
|
@@ -94,7 +94,14 @@
|
|
|
94
94
|
* @returns {ClassifiedInput}
|
|
95
95
|
*/
|
|
96
96
|
export function classifyInputPattern(input) {
|
|
97
|
-
|
|
97
|
+
// Orchestration control flags authorize or tune the dispatcher; they are not
|
|
98
|
+
// part of the issue-set predicate. Strip them here so a confirmed OpenCode
|
|
99
|
+
// fast path and the full shared resolver classify the same query.
|
|
100
|
+
const trimmed = String(input ?? "")
|
|
101
|
+
.replace(/(?:^|\s)(?:--auto|--confirm|--deep-plan)(?=\s|$)/gi, " ")
|
|
102
|
+
.replace(/(?:^|\s)--max-concurrent(?:=|\s+)[1-9]\d*(?=\s|$)/gi, " ")
|
|
103
|
+
.replace(/\s+/g, " ")
|
|
104
|
+
.trim();
|
|
98
105
|
const tokens = trimmed.length > 0 ? trimmed.split(/\s+/) : [];
|
|
99
106
|
|
|
100
107
|
// literal-numbers: every token is a bare integer or a (optionally
|
|
@@ -55,6 +55,11 @@ describe("classifyInputPattern — query patterns", () => {
|
|
|
55
55
|
assert.equal(classifyInputPattern("fast-lane").kind, "query");
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
it("ignores orchestration control flags when classifying the issue predicate", () => {
|
|
59
|
+
assert.equal(classifyInputPattern("fast-lane --auto --max-concurrent 4").pattern, "fast-lane");
|
|
60
|
+
assert.equal(classifyInputPattern("1 2 --confirm").kind, "literal");
|
|
61
|
+
});
|
|
62
|
+
|
|
58
63
|
it("classifies priority:P0 / priority:P1 as a query (both label schemas, forge#2232)", () => {
|
|
59
64
|
assert.equal(classifyInputPattern("priority:P0").pattern, "priority");
|
|
60
65
|
assert.equal(classifyInputPattern("priority:P1").kind, "query");
|
package/bin/engine.mjs
CHANGED
|
@@ -331,7 +331,9 @@ export async function runIssue(opts) {
|
|
|
331
331
|
// the same reason composes with that existing contract instead of
|
|
332
332
|
// conflating "paused pending a human" with "dead". Deliberately does
|
|
333
333
|
// NOT check `workflow:invalid`/CLOSED here — those are handled above.
|
|
334
|
-
|
|
334
|
+
// A blocked review is deliberately handed to remediation in this same
|
|
335
|
+
// run. All other needs-human states remain paused for human action.
|
|
336
|
+
if (snap.labels.includes("needs-human") && phase.id !== "remediate") {
|
|
335
337
|
const detail = `issue ${issue} carries needs-human — pausing before phase ${phase.id}`;
|
|
336
338
|
return await terminate(state, "needs-human", detail);
|
|
337
339
|
}
|
|
@@ -520,21 +522,24 @@ export async function runIssue(opts) {
|
|
|
520
522
|
}
|
|
521
523
|
}
|
|
522
524
|
|
|
525
|
+
const isRemediationHandoff = outcome.status === "blocked" &&
|
|
526
|
+
phase.id === "review" && (outcome.reason || "needs-human") === "needs-human";
|
|
523
527
|
if (outcome.status === "blocked") {
|
|
524
528
|
emitProgress({ event: "phase_exit", phase: phase.id, status: "blocked", detail: outcome.detail });
|
|
525
|
-
|
|
529
|
+
if (!isRemediationHandoff)
|
|
530
|
+
return await terminate(state, outcome.reason || "needs-human", outcome.detail);
|
|
531
|
+
} else {
|
|
532
|
+
// committed
|
|
533
|
+
// forge#2321: only report a phase_exit if a matching phase_enter was
|
|
534
|
+
// actually emitted for this phase this iteration. A reconcile-satisfied
|
|
535
|
+
// phase (short-circuited above without ever entering the `else` branch)
|
|
536
|
+
// never "started" this run — emitting phase_exit for it produced a
|
|
537
|
+
// dangling exit with no preceding enter (bin/engine-cli.mjs prints
|
|
538
|
+
// "✓ phase X committed" with no prior "→ phase X started" line).
|
|
539
|
+
// Suppressing the unmatched exit is correct here rather than fabricating
|
|
540
|
+
// a synthetic phase_enter, since the phase's runner genuinely never ran.
|
|
541
|
+
if (phaseEntered) emitProgress({ event: "phase_exit", phase: phase.id, status: "committed" });
|
|
526
542
|
}
|
|
527
|
-
|
|
528
|
-
// committed
|
|
529
|
-
// forge#2321: only report a phase_exit if a matching phase_enter was
|
|
530
|
-
// actually emitted for this phase this iteration. A reconcile-satisfied
|
|
531
|
-
// phase (short-circuited above without ever entering the `else` branch)
|
|
532
|
-
// never "started" this run — emitting phase_exit for it produced a
|
|
533
|
-
// dangling exit with no preceding enter (bin/engine-cli.mjs prints
|
|
534
|
-
// "✓ phase X committed" with no prior "→ phase X started" line).
|
|
535
|
-
// Suppressing the unmatched exit is correct here rather than fabricating
|
|
536
|
-
// a synthetic phase_enter, since the phase's runner genuinely never ran.
|
|
537
|
-
if (phaseEntered) emitProgress({ event: "phase_exit", phase: phase.id, status: "committed" });
|
|
538
543
|
// forge#2377: `outcome.usage` is populated by runPhaseWithRetry() below
|
|
539
544
|
// from the injected runner()'s (== bin/runner.mjs's runCommand()) return
|
|
540
545
|
// value — null when the backend doesn't report usage (CLI backend today)
|
|
@@ -544,7 +549,10 @@ export async function runIssue(opts) {
|
|
|
544
549
|
// owned by phase.detectOutcome() (bin/engine/phases.mjs).
|
|
545
550
|
appendEvent(dir, issue, { event: "PHASE_COMMIT", phase: phase.id, outputs: outcome.outputs || {}, usage: outcome.usage ?? null });
|
|
546
551
|
state = deriveState(readLog(dir, issue));
|
|
547
|
-
|
|
552
|
+
const terminalReason = outcome.status === "blocked"
|
|
553
|
+
? (outcome.reason || "needs-human")
|
|
554
|
+
: outcome.terminalReason;
|
|
555
|
+
if (terminalReason) state.terminalReason = terminalReason;
|
|
548
556
|
await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } });
|
|
549
557
|
|
|
550
558
|
// forge#2379: the "investigate" phase reporting terminalReason "decomposed"
|
|
@@ -564,9 +572,10 @@ export async function runIssue(opts) {
|
|
|
564
572
|
// is seen), phase.id is "decompose" — not "investigate" — so this
|
|
565
573
|
// exemption does not apply and the normal terminate() path below fires,
|
|
566
574
|
// ending the run for real.
|
|
567
|
-
const isDecomposeHandoff = phase.id === "investigate" &&
|
|
568
|
-
if (
|
|
569
|
-
|
|
575
|
+
const isDecomposeHandoff = phase.id === "investigate" && terminalReason === "decomposed";
|
|
576
|
+
if (terminalReason && TERMINAL_REASONS.includes(terminalReason) &&
|
|
577
|
+
!isDecomposeHandoff && !isRemediationHandoff)
|
|
578
|
+
return await terminate(state, terminalReason, outcome.detail);
|
|
570
579
|
if (phase.isTerminalAfter && phase.isTerminalAfter(state))
|
|
571
580
|
return await terminate(state, state.terminalReason || "merged");
|
|
572
581
|
}
|
package/bin/forgedock.mjs
CHANGED
|
@@ -1417,7 +1417,7 @@ async function relinkAndHint() {
|
|
|
1417
1417
|
// version-check path), so wiring it here covers "refreshed after every
|
|
1418
1418
|
// successful update" without duplicating the call at each branch. Note:
|
|
1419
1419
|
// this is NOT reached by install's already-managed-active short-circuit
|
|
1420
|
-
// (that path
|
|
1420
|
+
// (that path is also used by managed-active install) — see the
|
|
1421
1421
|
// writeInstallReceipt() JSDoc in journey.mjs for the full picture.
|
|
1422
1422
|
await writeInstallReceipt(c, { forged });
|
|
1423
1423
|
await refreshManagedOpenCodeAdapter();
|
|
@@ -2416,6 +2416,16 @@ async function doctor(fix = false) {
|
|
|
2416
2416
|
broken++;
|
|
2417
2417
|
brokenLinks.push(rel);
|
|
2418
2418
|
symlinkOk = false;
|
|
2419
|
+
} else {
|
|
2420
|
+
try {
|
|
2421
|
+
// readlink() only validates the stored target. Opening the
|
|
2422
|
+
// link also catches platform-specific traversal failures.
|
|
2423
|
+
readFileSync(tgt);
|
|
2424
|
+
} catch {
|
|
2425
|
+
broken++;
|
|
2426
|
+
brokenLinks.push(`${rel} (unreadable — link resolves but cannot be traversed)`);
|
|
2427
|
+
symlinkOk = false;
|
|
2428
|
+
}
|
|
2419
2429
|
}
|
|
2420
2430
|
} else {
|
|
2421
2431
|
// Regular file — copy-mode install (Windows without Developer Mode).
|
|
@@ -3266,6 +3276,7 @@ function help() {
|
|
|
3266
3276
|
* FORGEDOCK_BACKEND Default backend ("cli"|"api"|"auto") when --backend is omitted.
|
|
3267
3277
|
* FORGEDOCK_MODEL Default model id when --model is omitted.
|
|
3268
3278
|
* FORGEDOCK_CLI_TIMEOUT_MS Wall-clock timeout (ms) for the cli backend invocation.
|
|
3279
|
+
* Overrides forge.yaml pipeline.cli_timeout_minutes.
|
|
3269
3280
|
* FORGEDOCK_SHELL Override the shell used by run_bash / the cli backend.
|
|
3270
3281
|
* Defaults to bash when found (Git Bash / WSL on
|
|
3271
3282
|
* Windows, /bin/bash on POSIX), falling back to the
|
|
@@ -3808,7 +3819,9 @@ switch (command) {
|
|
|
3808
3819
|
}
|
|
3809
3820
|
}
|
|
3810
3821
|
if (existsSync(join(c.cwd, "forge.yaml")) && resolveState(c.cwd) === "managed-active") {
|
|
3811
|
-
|
|
3822
|
+
// A configured directory may still have missing commands after uninstall.
|
|
3823
|
+
// Refresh managed files without re-running the configuration journey.
|
|
3824
|
+
await relinkAndHint();
|
|
3812
3825
|
} else {
|
|
3813
3826
|
exitCode = await runJourney(c);
|
|
3814
3827
|
// Record persistHome()'s outcome (set on c.persistHomeResult inside
|