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.
Files changed (47) hide show
  1. package/bin/engine/admission.mjs +242 -7
  2. package/bin/engine/admission.test.mjs +183 -2
  3. package/bin/engine/phases.mjs +31 -22
  4. package/bin/engine/resolve.mjs +8 -1
  5. package/bin/engine/resolve.test.mjs +5 -0
  6. package/bin/engine.mjs +26 -17
  7. package/bin/forgedock.mjs +15 -2
  8. package/bin/hooks/pre-tool-use.mjs +573 -2
  9. package/bin/journey.mjs +11 -7
  10. package/bin/labels.json +5 -0
  11. package/bin/opencode-adapter.mjs +47 -13
  12. package/bin/orchestrate-preflight.mjs +434 -0
  13. package/bin/runner.mjs +30 -4
  14. package/commands/audit-agents.md +75 -26
  15. package/commands/autopilot.md +4 -4
  16. package/commands/cleanup.md +10 -5
  17. package/commands/issue.md +95 -35
  18. package/commands/orchestrate/phase-1-resolve.md +70 -32
  19. package/commands/orchestrate/phase-3-dependency.md +141 -13
  20. package/commands/orchestrate/phase-4-execution.md +660 -70
  21. package/commands/orchestrate/phase-5-cleanup.md +5 -11
  22. package/commands/orchestrate/phase-6-report.md +27 -1
  23. package/commands/orchestrate/safety.md +1 -1
  24. package/commands/orchestrate.md +39 -1
  25. package/commands/quality-gate.md +30 -10
  26. package/commands/review-pr-agents/protocols.md +13 -0
  27. package/commands/review-pr-agents.md +1 -1
  28. package/commands/review-pr-staging.md +100 -18
  29. package/commands/review-pr.md +78 -24
  30. package/commands/work-on/build/validate.md +25 -4
  31. package/commands/work-on/close.md +40 -10
  32. package/commands/work-on/decompose.md +33 -1
  33. package/commands/work-on/investigate.md +33 -0
  34. package/commands/work-on/remediate.md +40 -5
  35. package/commands/work-on/review.md +27 -0
  36. package/commands/work-on.md +48 -5
  37. package/docs/CONFIG.md +45 -10
  38. package/docs/OPENCODE.md +21 -6
  39. package/docs/spec/review-protocol.md +7 -0
  40. package/package.json +1 -1
  41. package/scripts/check-admission-parity.mjs +1 -0
  42. package/scripts/check-command-side-effects.sh +14 -13
  43. package/scripts/check-shell-portability.sh +36 -0
  44. package/scripts/check-spec-markers.sh +21 -11
  45. package/scripts/checkbox-sections.test.sh +67 -0
  46. package/scripts/extract-affected-files.sh +85 -5
  47. package/scripts/extract-affected-files.test.sh +154 -2
@@ -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
- const { blob } = await issueMarkers(state.issue, io);
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
- const { blob } = await issueMarkers(state.issue, io);
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
- // KNOWN LIMITATION (documented, not fixed here — see forge#2379
374
- // investigation "What We Found"): `review`'s `"blocked"` outcome (the
375
- // needs-human escalation) causes `bin/engine.mjs`'s `runIssue()` to
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",
@@ -94,7 +94,14 @@
94
94
  * @returns {ClassifiedInput}
95
95
  */
96
96
  export function classifyInputPattern(input) {
97
- const trimmed = (input ?? "").trim();
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
- if (snap.labels.includes("needs-human")) {
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
- return await terminate(state, outcome.reason || "needs-human", outcome.detail);
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
- if (outcome.terminalReason) state.terminalReason = outcome.terminalReason;
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" && outcome.terminalReason === "decomposed";
568
- if (outcome.terminalReason && TERMINAL_REASONS.includes(outcome.terminalReason) && !isDecomposeHandoff)
569
- return await terminate(state, outcome.terminalReason);
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 calls statusScreen(), never relinkAndHint()) — see the
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
- await statusScreen(c);
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