forgedock 1.20.0 → 1.21.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/runner.mjs CHANGED
@@ -671,6 +671,23 @@ export function isClaudeCliAvailable(
671
671
  // gated on the cached `claude --version` probe, never on help-text scraping.
672
672
  const CLI_MIN_VERSION_MODEL_FLAG = [1, 0, 0];
673
673
  const CLI_MIN_VERSION_MAX_TURNS_FLAG = [1, 0, 0];
674
+ // forge#4065: minimal phase sessions. Conservative floor: the oldest version
675
+ // known to accept `--strict-mcp-config`, `--mcp-config` and
676
+ // `--disable-slash-commands` together. Unparseable/older => flags omitted.
677
+ const CLI_MIN_VERSION_MINIMAL_SESSION = [2, 1, 296];
678
+
679
+ // Phase commands whose spec never invokes `Skill(...)`; only these run with
680
+ // `--disable-slash-commands` (spec-coverage test enforces the invariant).
681
+ export const SKILL_FREE_PHASE_COMMANDS = Object.freeze(["work-on/investigate", "work-on/close"]);
682
+
683
+ // Engine phase commands (mirrors PHASES in bin/engine/phases.mjs; a test enforces it). Only these
684
+ // get the minimal-session group: other `forgedock run` commands (qa-sweep, analytics, geo-audit,
685
+ // autopilot) may need the operator's MCP servers and skills.
686
+ export const ENGINE_PHASE_COMMANDS = Object.freeze([
687
+ "work-on/investigate", "work-on/decompose", "work-on/build", "work-on/review", "work-on/remediate", "work-on/close",
688
+ ]);
689
+
690
+ const MINIMAL_SESSION_MCP_CONFIG = '{"mcpServers":{}}';
674
691
 
675
692
  /**
676
693
  * Extract a `major.minor.patch` version from probe output (the text after the
@@ -702,7 +719,7 @@ function versionAtLeast(version, min) {
702
719
  *
703
720
  * @param {string} [cwd]
704
721
  * @param {object} [opts] - Forwarded to `isClaudeCliAvailable()` (test seams).
705
- * @returns {{model: boolean, maxTurns: boolean}}
722
+ * @returns {{model: boolean, maxTurns: boolean, strictMcp: boolean, disableSlash: boolean}}
706
723
  */
707
724
  export function getClaudeCliCapabilities(cwd = process.cwd(), opts = {}) {
708
725
  isClaudeCliAvailable(cwd, opts);
@@ -710,9 +727,53 @@ export function getClaudeCliCapabilities(cwd = process.cwd(), opts = {}) {
710
727
  return {
711
728
  model: versionAtLeast(version, CLI_MIN_VERSION_MODEL_FLAG),
712
729
  maxTurns: versionAtLeast(version, CLI_MIN_VERSION_MAX_TURNS_FLAG),
730
+ strictMcp: versionAtLeast(version, CLI_MIN_VERSION_MINIMAL_SESSION),
731
+ disableSlash: versionAtLeast(version, CLI_MIN_VERSION_MINIMAL_SESSION),
713
732
  };
714
733
  }
715
734
 
735
+ /**
736
+ * Build the minimal-session argv group (forge#4065): no operator MCP servers
737
+ * and, for Skill-free phases only, no slash-command/skill listing. Operator
738
+ * settings are deliberately NOT skipped (the CLI setting-sources flag is not passed) so
739
+ * ForgeDock's user-scope PreToolUse/SubagentStop hooks keep running.
740
+ * Non-engine commands, `FORGEDOCK_FULL_SESSION=1` (exactly) or an unsupported CLI yield `[]`.
741
+ *
742
+ * @param {string} commandName
743
+ * @param {{strictMcp: boolean, disableSlash: boolean}} caps
744
+ * @param {NodeJS.ProcessEnv} [env]
745
+ * @returns {string[]}
746
+ */
747
+ export function buildMinimalSessionFlags(commandName, caps, env = process.env) {
748
+ if (env?.FORGEDOCK_FULL_SESSION === "1" || !caps) return [];
749
+ const name = String(commandName ?? "").trim().replace(/^\/+/, "").replace(/\.md$/i, "");
750
+ if (!ENGINE_PHASE_COMMANDS.includes(name)) return [];
751
+ const flags = [];
752
+ if (caps.strictMcp) flags.push("--strict-mcp-config", "--mcp-config", MINIMAL_SESSION_MCP_CONFIG);
753
+ if (caps.disableSlash && SKILL_FREE_PHASE_COMMANDS.includes(name)) flags.push("--disable-slash-commands");
754
+ return flags;
755
+ }
756
+
757
+ /**
758
+ * Lean-path (#4081) minimal session flags: reuses the #4065 capability probe. Every phase is Skill-free, so slash
759
+ * commands are off too; user-scope settings and hooks still load. Unsupported CLI or FORGEDOCK_FULL_SESSION=1 yields [].
760
+ */
761
+ export function buildLeanSessionFlags(caps, env = process.env) {
762
+ // Same group as the Skill-free engine phases (no MCP, no slash commands). Operator settings stay loaded so the
763
+ // user-scope safety hooks keep running, exactly like #4077.
764
+ return buildMinimalSessionFlags("work-on/investigate", caps, env);
765
+ }
766
+
767
+ /**
768
+ * Read-only reviewer tools (#4081): only Read, Grep and Glob. Uses `--tools` where the capability probe says the
769
+ * CLI is recent enough, else denies the mutating tools with `--disallowedTools`.
770
+ */
771
+ export function buildReadOnlyToolFlags(caps) {
772
+ return caps?.strictMcp
773
+ ? ["--tools", "Read,Grep,Glob"]
774
+ : ["--disallowedTools", "Bash", "Edit", "Write", "MultiEdit", "NotebookEdit"];
775
+ }
776
+
716
777
  /**
717
778
  * Return the exact absolute `claude` executable path that
718
779
  * `isClaudeCliAvailable()` validated for `cwd` (issue #2741), ensuring the
@@ -1443,6 +1504,9 @@ export function runCliBackend({
1443
1504
  model = null,
1444
1505
  maxTurns = null,
1445
1506
  leaseHolder = null,
1507
+ sessionFlags = [],
1508
+ // #4081: extra argv (e.g. ["--resume", id]) appended verbatim, never dropped by FORGEDOCK_FULL_SESSION.
1509
+ extraArgs = [],
1446
1510
  }) {
1447
1511
  // forge#3589: opt-in flags, validated again here as defence in depth. Callers
1448
1512
  // (runCommand) are responsible for the version gate. Unset -> argv unchanged.
@@ -1451,6 +1515,12 @@ export function runCliBackend({
1451
1515
  const optionalFlags = [
1452
1516
  ...(modelFlag ? ["--model", modelFlag] : []),
1453
1517
  ...(maxTurnsFlag ? ["--max-turns", String(maxTurnsFlag)] : []),
1518
+ // forge#4065: minimal-session group, built by buildMinimalSessionFlags().
1519
+ // Re-checked here so FORGEDOCK_FULL_SESSION=1 always wins.
1520
+ ...(process.env.FORGEDOCK_FULL_SESSION === "1" || !Array.isArray(sessionFlags)
1521
+ ? []
1522
+ : sessionFlags.map(String)),
1523
+ ...(Array.isArray(extraArgs) ? extraArgs.map(String) : []),
1454
1524
  ];
1455
1525
  const rawTimeout = parseInt(process.env.FORGEDOCK_CLI_TIMEOUT_MS, 10);
1456
1526
  const configuredTimeout = resolveConfiguredCliTimeout(cwd);
@@ -1738,6 +1808,7 @@ export function runCliBackend({
1738
1808
  // bin/batch-runner.mjs's `tokenCost()` (`input + output`).
1739
1809
  const stdoutTrimmed = stdout.trim();
1740
1810
  let parsedResult = null;
1811
+ let sessionId = null;
1741
1812
  let usage = null;
1742
1813
  let turns = null;
1743
1814
  // forge#3696: envelope cost/duration per attempt (null when absent or non-finite).
@@ -1750,6 +1821,7 @@ export function runCliBackend({
1750
1821
  if (Number.isFinite(parsed.total_cost_usd)) costUsd = parsed.total_cost_usd;
1751
1822
  if (Number.isFinite(parsed.duration_ms)) durationMs = parsed.duration_ms;
1752
1823
  parsedResult = typeof parsed.result === "string" ? parsed.result : null;
1824
+ if (typeof parsed.session_id === "string" && parsed.session_id) sessionId = parsed.session_id;
1753
1825
  if (parsed.usage && typeof parsed.usage === "object") {
1754
1826
  usage = {
1755
1827
  input_tokens: toFiniteUsageNumber(parsed.usage.input_tokens),
@@ -1835,6 +1907,7 @@ export function runCliBackend({
1835
1907
  turns,
1836
1908
  costUsd,
1837
1909
  durationMs,
1910
+ sessionId,
1838
1911
  backend: "cli",
1839
1912
  // forge#3521: the phase's final reply (the CLI envelope's `.result`), so
1840
1913
  // callers can read its structured `*_RESULT` block. Additive; "" when absent.
@@ -2715,10 +2788,13 @@ export async function runCommand(opts = {}) {
2715
2788
  : null;
2716
2789
  let cliModel = commandSettings.model ?? runLevelModel;
2717
2790
  let cliMaxTurns = commandSettings.maxTurns;
2718
- if (resolvedBin && (cliModel || cliMaxTurns)) {
2791
+ // forge#4065: minimal phase session flags, computed per call (never cached).
2792
+ let sessionFlags = [];
2793
+ if (resolvedBin) {
2719
2794
  const caps = getClaudeCliCapabilities(cwd);
2720
2795
  if (cliModel && !caps.model) cliModel = null;
2721
2796
  if (cliMaxTurns && !caps.maxTurns) cliMaxTurns = null;
2797
+ sessionFlags = buildMinimalSessionFlags(commandName, caps);
2722
2798
  }
2723
2799
  if (!resolvedBin && backend === "auto") {
2724
2800
  resolvedBackend = "api";
@@ -2741,6 +2817,7 @@ export async function runCommand(opts = {}) {
2741
2817
  model: cliModel,
2742
2818
  maxTurns: cliMaxTurns,
2743
2819
  leaseHolder,
2820
+ sessionFlags,
2744
2821
  },
2745
2822
  });
2746
2823
  }
@@ -842,11 +842,18 @@ DEGRADED_CHECKS="${DEGRADED_CHECKS:-}"
842
842
  GATE_STATUS=$([ "$GATE_PASSED" != "true" ] && echo FAIL || { [ -n "$DEGRADED_CHECKS" ] && echo DEGRADED || echo PASS; })
843
843
  # Bind the PASS to what was actually gated: the staged tree is exactly the tree the V5 commit will have.
844
844
  # scripts/verify-phase-trail.sh --head-tree (work-on/review.md R1.5) rejects a PASS recorded for a different tree. <!-- Added: forge#3149 -->
845
- git -C {WORKTREE_PATH} add -u # same staging as the commit below; valid only because V2-V4 are done
846
- GATE_TREE=$(git -C {WORKTREE_PATH} write-tree)
847
- # Marker-only re-gate (invoked by work-on/review.md after a base sync or a stale re-review): HEAD is already committed and
848
- # the index is clean, so the gated tree is HEAD's tree. Use it instead of write-tree, and post the marker only (no V5 Step 2 commit).
849
- [ "${REGATE_MERGED_HEAD:-}" = "1" ] && GATE_TREE=$(git -C {WORKTREE_PATH} rev-parse 'HEAD^{tree}')
845
+ if [ "${REGATE_MERGED_HEAD:-}" = "1" ]; then
846
+ # Marker-only re-gate (invoked by work-on/review.md after a base sync or a stale re-review): HEAD is already committed, so the
847
+ # gated tree must be HEAD's tree. Never stage here: require a clean worktree so no uncommitted fix sits outside the marked tree.
848
+ if [ -n "$(git -C {WORKTREE_PATH} status --porcelain --untracked-files=no)" ]; then
849
+ echo "QUALITY_GATE: marker-only re-gate needs a clean worktree (uncommitted tracked changes) - commit the fix, re-run the scoped V1 gate on the committed head, then re-run this step; not posting a marker" >&2
850
+ exit 1
851
+ fi
852
+ GATE_TREE=$(git -C {WORKTREE_PATH} rev-parse 'HEAD^{tree}')
853
+ else
854
+ git -C {WORKTREE_PATH} add -u # same staging as the commit below; valid only because V2-V4 are done
855
+ GATE_TREE=$(git -C {WORKTREE_PATH} write-tree)
856
+ fi
850
857
  [ -n "$GATE_TREE" ] || { echo "QUALITY_GATE: tree unresolved - not posting a marker" >&2; exit 1; }
851
858
  QG_BODY="<!-- FORGE:QUALITY_GATE -->
852
859
  ## Quality Gate Result
@@ -860,7 +867,7 @@ QG_BODY="<!-- FORGE:QUALITY_GATE -->
860
867
  gh issue comment {NUMBER} {GH_FLAG} --body "$QG_BODY" # allowlist:check-command-side-effects
861
868
  ```
862
869
 
863
- **Marker-only re-gate mode** <!-- Added: forge#3983 -->: when `work-on/review.md` re-gates a merged head (Pre-Handoff Base Sync or stale re-review), it runs the scoped V1 gate on the committed head first, then sets `REGATE_MERGED_HEAD=1` and runs only this Step 1 with the real `GATE_RESULT` (FAIL stays FAIL). Steps 2-4 are skipped: there is nothing staged and no commit to make.
870
+ **Marker-only re-gate mode** <!-- Added: forge#3983 -->: when `work-on/review.md` re-gates a merged head (Pre-Handoff Base Sync or stale re-review), it runs the scoped V1 gate on the committed head first, then sets `REGATE_MERGED_HEAD=1` and runs only this Step 1 with the real `GATE_RESULT` (FAIL stays FAIL). Steps 2-4 are skipped: there is nothing staged and no commit to make. **Precondition**: a clean worktree (no uncommitted tracked changes, untracked files ignored); a dirty tracked file or staged change exits 1 with no marker, so a fix made after a FAIL must be committed and the scoped V1 gate re-run on the committed head before this step is re-run, keeping the gated tree equal to the marked `HEAD^{tree}`.
864
871
 
865
872
  ### V5 Step 2: Commit
866
873
 
@@ -205,7 +205,7 @@ On conflict, resolve every file, keep both sides' intent, `git add`, `git commit
205
205
  **Re-gate the merged head** <!-- Added: forge#3983 -->: the `FORGE:QUALITY_GATE` PASS is bound to `HEAD^{tree}` (R1.5 `--head-tree`), so a merge that changes the tree invalidates it. After the merge and any conflict resolution, compare `PRE_SYNC_TREE` with the current `HEAD^{tree}` (fail closed if either cannot be resolved):
206
206
 
207
207
  - Unchanged: do nothing; never re-gate.
208
- - Changed: run the scoped quality gate on the merged head here (`work-on/build/validate` V1 loop over the PR's changed files), then post the marker via validate.md V5 Step 1 "Marker-only re-gate mode" (`**Tree**` = merged `HEAD^{tree}`, `**Commands run**` = what ran). Post the real result: on FAIL, fix and re-run within the validate bounds, else add `needs-human` and return `REVIEW_RESULT: status: BLOCKED`, blocker "post-sync quality gate failed"; never post PASS without a run.
208
+ - Changed: run the scoped quality gate on the merged head here (`work-on/build/validate` V1 loop over the PR's changed files), then post the marker via validate.md V5 Step 1 "Marker-only re-gate mode" (`**Tree**` = merged `HEAD^{tree}`, `**Commands run**` = what ran). Post the real result: on FAIL, fix, commit the fix (marker-only mode requires a clean worktree so the re-gated tree is the committed `HEAD^{tree}`), and re-run within the validate bounds, else add `needs-human` and return `REVIEW_RESULT: status: BLOCKED`, blocker "post-sync quality gate failed"; never post PASS without a run.
209
209
 
210
210
  ## Phase R1: Pre-Push Ancestry Guard
211
211
 
package/docs/CONFIG.md CHANGED
@@ -481,6 +481,7 @@ engine:
481
481
  | `<key>_model` | string | No | Model alias (`sonnet`, `opus`, `haiku`) or full model id passed as `--model`. Invalid values are ignored. |
482
482
  | `<key>_max_turns` | integer | No | Positive integer passed as `--max-turns`. Invalid values are ignored. |
483
483
 
484
+ | `path` | string | No | `full` (default), `lean` or `auto`. The lean path (#4081, `bin/lean/`) runs validate, architect and implement in one warm builder session, deterministic gates, one independent reviewer and a no-model merge; `auto` uses it unless the risk tier is high and the diff is large. The `--path` flag overrides. `lean_reviewer_model` sets the reviewer model (default `claude-sonnet-5-5`); the builder uses `--model` or the CLI default. |
484
485
  | `ci_pending_max_minutes` | integer | No | Minutes the engine waits for CI when the review phase ends BLOCKED with "checks still pending", or the remediate phase holds at its M7 re-gate on "CI pending" (the merge gate's wait window ended while CI was still running). Default: 90. Invalid values are ignored. This key is flat like the others but is not part of the `<key>_model` / `<key>_max_turns` pattern. |
485
486
 
486
487
  **CI still pending**: the engine polls `scripts/wait-ci-green.sh` (no model calls) one read per poll on a 60 s, 2 min, 4 min, 5 min (cap) backoff with +/-20% jitter, while the lease keeps renewing, then re-dispatches the phase that was waiting, review or remediate (idempotent at an unchanged head: review re-runs the merge gate and merges; remediate resumes at its M7 re-gate). Before each poll it reads `gh api rate_limit` (not itself rate-limited) and, below 10% of the REST budget, sleeps until the window resets instead of polling; a rate-limited or transient (5xx, network) poll is retried and never re-dispatches the phase. Red CI takes the normal ci-gate path. When the budget is exhausted the run ends `needs-human` with outcome code `review.ci_pending_timeout`; `--retry` resumes. Each wait emits a `ci_pending_wait` telemetry event (`docs/TELEMETRY.md`) with the minutes waited, polls made and rate-limit sleeps.
@@ -493,6 +494,8 @@ engine:
493
494
 
494
495
  **Per-run scratch dir** (`tmp_root`): every engine child runs with `TMPDIR` and `CLAUDE_CODE_TMPDIR` set to `<tmp_root>/<repo-key>/<issue>` so venvs and Claude session data stay off a size-limited `/tmp`. `engine.tmp_root` is an absolute or `~`-prefixed path; default `~/.forge/tmp`. The `<repo-key>` is the checkout's directory name plus 8 hex characters of a hash of its real path, so the same issue number in two repos never shares a dir; the dir is created `0700` and an existing symlink or dir owned by another user is not used (the child keeps the inherited tmp). Old `<tmp_root>/<issue>` dirs from earlier versions are not migrated. When `tmp_root` is unset and the operator already exported `TMPDIR` or `CLAUDE_CODE_TMPDIR`, that choice is respected and never cleaned. The dir is removed on a terminal outcome and kept on `engine-error` so `--retry` resumes. A disk-full/quota failure (EDQUOT, ENOSPC, errno -28/-122) ends a retryable `engine-error` (outcome code `infra.disk_quota`), never `needs-human`.
495
496
 
497
+ **Minimal phase sessions** (forge#4065): on a supported CLI (`claude --version` >= 2.1.296, probed once and cached like the flags above) every engine phase (`work-on/*`) runs with `--strict-mcp-config --mcp-config '{"mcpServers":{}}'`, so operator MCP servers and connectors are not loaded. Phases whose spec never calls `Skill(...)` (`work-on/investigate`, `work-on/close`) also get `--disable-slash-commands`; skill-using phases keep skills. Operator settings are intentionally not skipped (no `--setting-sources`), because ForgeDock's user-scope PreToolUse/SubagentStop hooks must keep running. Set `FORGEDOCK_FULL_SESSION=1` to restore the previous full session (no flags). Other `forgedock run` commands (qa-sweep, analytics, geo-audit, autopilot) keep the full session because they may need MCP servers. An old or unparseable CLI version also omits the flags.
498
+
496
499
  **Commands that use this section**: CLI backend (`runCommand` in `bin/runner.mjs`)
497
500
 
498
501
  ---
@@ -2,6 +2,12 @@
2
2
 
3
3
  Operator-facing notes for behavior changes that need action or awareness. Newest first.
4
4
 
5
+ ## Lean path engine (#4081)
6
+
7
+ `forgedock run-issue N --lane L --path lean|full|auto` (or `engine.path` in forge.yaml; default `full`) runs validate, architect and implement in one warm, minimal builder session, then deterministic gates (a test-file change unless docs-only, ci-local), one independent Sonnet reviewer (plus a security reviewer for auth, crypto, secret, permission, migration, payment or workflow paths), then PR, remote CI wait, merge and close with no model. Gate failures and blocking findings (CRITICAL/HIGH, or CONFIRMED MEDIUM introduced) resume the same builder session with `--resume`, at most 2 rounds each, then `needs-human`; a blocking round is followed by one delta re-review. Non-blocking findings are listed in `FORGE:REVIEW` and never filed. Each stage posts `FORGE:VALIDATE`, `FORGE:PLAN`, `FORGE:IMPLEMENT`, `FORGE:REVIEW`, `FORGE:CLOSE`; telemetry is one `phase_attempt` per stage plus `run_terminal`. `auto` goes lean unless the risk tier is high and the diff is large, in which case it falls back to `full`. Slow CI is polled with backoff and ends `ci-pending` (resumable), never `needs-human`. See docs/CONFIG.md (`engine.path`).
8
+
9
+ Action: none; the default path is unchanged.
10
+
5
11
  ## v1.19.1: build-time self-review, ci-local under concurrency, review roster gates
6
12
 
7
13
  - **Build-time self-review (B5.6, #3971).** After implement, the risk-tier review roster's domain agents review the staged diff, and the builder fixes CONFIRMED/LIKELY findings at MEDIUM or above in one repair round before validate. Small or docs-only diffs skip. Each build that runs it spends extra reviewer tokens, which the official review should save back through fewer findings and remediation rounds. Off switch: `FORGE_SELFREVIEW=off`. Wait budget: `SELFREVIEW_WAIT_SECS` (default 600, max 1800). Telemetry: `build_selfreview`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
4
4
  "description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"
@@ -48,7 +48,7 @@
48
48
  "README.md"
49
49
  ],
50
50
  "scripts": {
51
- "test": "bash scripts/with-run-tmpdir.sh node --test \"bin/tests/**/*.test.mjs\" \"bin/engine/**/*.test.mjs\" \"bin/stats/**/*.test.mjs\" \"packages/protocol/test/**/*.test.mjs\"",
51
+ "test": "bash scripts/with-run-tmpdir.sh node --test \"bin/tests/**/*.test.mjs\" \"bin/engine/**/*.test.mjs\" \"bin/stats/**/*.test.mjs\" \"bin/lean/**/*.test.mjs\" \"packages/protocol/test/**/*.test.mjs\"",
52
52
  "test:sh": "bash scripts/run-shell-tests.sh",
53
53
  "docs:dev": "vitepress dev docs/site",
54
54
  "docs:build": "vitepress build docs/site",