forgedock 1.20.0 → 1.22.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.
@@ -0,0 +1,66 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /** Fixed comment templates for the core work-on. Pure functions, no model calls. Missing fields read "not reported". */
4
+
5
+ export const NR = "not reported";
6
+
7
+ /** Single-line, table-safe text: collapse newlines, escape pipes. */
8
+ export const cell = (v) => {
9
+ const s = v == null ? "" : typeof v === "string" ? v : JSON.stringify(v);
10
+ const t = s.replace(/\r?\n+/g, " ").replace(/\|/g, "\\|").trim();
11
+ return t || NR;
12
+ };
13
+ /** Inline text: newlines collapsed (keeps list items and one-liners intact). */
14
+ const line = (v) => cell(v).replace(/\\\|/g, "|");
15
+ const arr = (v) => (Array.isArray(v) ? v : v == null || v === "" ? [] : [v]);
16
+ const bullets = (v) => {
17
+ const items = arr(v).map((x) => line(typeof x === "object" && x ? Object.values(x).join(" - ") : x)).filter((x) => x !== NR);
18
+ return items.length ? items.map((x) => `- ${x}`).join("\n") : NR;
19
+ };
20
+
21
+ const norm = (r) => r ?? {};
22
+ const objs = (v, keys) => arr(v).map((x) => (x && typeof x === "object" ? x : { [keys[0]]: x }));
23
+
24
+ export function changesTable(r, gitFiles = []) {
25
+ let rows = objs(norm(r).changes, ["file"]);
26
+ if (!rows.length) rows = gitFiles.map((file) => ({ file }));
27
+ if (!rows.length) return NR;
28
+ return ["| file | change | why |", "| --- | --- | --- |", ...rows.map((x) => `| ${cell(x.file)} | ${cell(x.change)} | ${cell(x.why)} |`)].join("\n");
29
+ }
30
+
31
+ export function verification(r) {
32
+ const rows = objs(norm(r).tests, ["command"]);
33
+ if (!rows.length) return NR;
34
+ return rows.map((t) => `- \`${line(t.command).replace(/`/g, "'")}\`: ${line(t.result)}; fails without fix: ${t.fails_without_fix === true ? "yes" : t.fails_without_fix === false ? "no" : "unknown"}`).join("\n");
35
+ }
36
+
37
+ export function renderValidate(r, verdict) {
38
+ const x = norm(r);
39
+ return `## Validation: ${verdict}\n**Verdict:** ${verdict}\n\n**Evidence**\n${bullets(x.evidence)}\n\n**Root cause:** ${line(x.root_cause)}\n\n**Summary:** ${line(String(x.summary ?? "").split("\n")[0].slice(0, 300))}`;
40
+ }
41
+
42
+ export function renderPlan(r, gitFiles = []) {
43
+ const x = norm(r);
44
+ const files = objs(x.changes, ["file"]).map((c) => c.file).filter(Boolean);
45
+ const testPlan = objs(x.tests, ["command"]).map((t) => t.command).filter(Boolean);
46
+ return `## Plan\n**Approach:** ${line(x.approach)}\n\n**Files**\n${bullets(files.length ? files : gitFiles)}\n\n**Sibling sites checked**\n${bullets(Array.isArray(x.sibling_sites_checked) ? x.sibling_sites_checked.map((s) => (s && typeof s === "object" ? Object.values(s).join(": ") : s)) : x.sibling_sites_checked)}\n\n**Test plan**\n${bullets(testPlan)}`;
47
+ }
48
+
49
+ export function renderReview(rv) {
50
+ const yn = (b) => (b ? "yes" : "no");
51
+ return `## Review: ${rv.verdict}\n**Fixes the issue:** ${yn(rv.fixes_issue)}${rv.fixes_issue ? "" : ` - ${line(rv.reason)}`}\n**Regression:** ${yn(rv.regression)}${rv.regression ? ` - ${line(rv.reason)}` : ""}\n**Reason:** ${line(rv.reason)}\n**Checked:** ${line(rv.checked)}`;
52
+ }
53
+
54
+ export function renderSummary(r, gitFiles = []) {
55
+ return `**Root cause:** ${line(norm(r).root_cause)}\n\n**Changes**\n${changesTable(r, gitFiles)}\n\n**Verification**\n${verification(r)}`;
56
+ }
57
+
58
+ export function renderRegister({ result, gitFiles = [], review, links = {}, prUrl, usage, policy }) {
59
+ const x = norm(result);
60
+ const link = (k, v) => (v ? `- ${k}: ${v}\n` : "");
61
+ return `## Work-on: fixed\n**Summary:** ${line(x.summary)}\n\n${renderSummary(result, gitFiles)}\n\n**Risks:** ${line(Array.isArray(x.risks) ? x.risks.join("; ") : x.risks)}\n**Follow-ups:** ${line(Array.isArray(x.follow_ups) ? x.follow_ups.join("; ") : x.follow_ups)}\n\n**Review:** ${review.verdict} (fixes issue: ${review.fixes_issue ? "yes" : "no"}, regression: ${review.regression ? "yes" : "no"}) ${line(review.reason)}\n\n**Links**\n${link("Validation", links.validate)}${link("Plan", links.plan)}${link("Review", links.review)}- PR: ${prUrl}\n\n**Merge policy:** ${line(policy)}\n\n_${usage}_`;
62
+ }
63
+
64
+ export function renderPrBody({ issue, result, gitFiles = [] }) {
65
+ return `Closes #${issue}\n\n${renderSummary(result, gitFiles)}`;
66
+ }
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,8 @@ 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 | `core` (default), `full`, `lean` or `auto`. `core` is the lean work-on (`bin/lean/`): one plain builder session, one read-only two-question reviewer, GitHub as the register (see RELEASE-NOTES v1.22). `lean` and `auto` are aliases of `core`; `full` runs the legacy multi-phase pipeline and is required for `--retry` and `--wait-lease`. The `--path` flag overrides. `lean_reviewer_model` sets the reviewer model (default `claude-sonnet-5-5`); the builder uses `--model` or the CLI default. `forgedock orchestrate <issues...> [--parallel K] [--lane L]` runs the core per issue (K default 3, lane default `staging`) and honours `**Depends on**` ordering among the given issues. |
485
+ | `merge_wait` | string | No | Core work-on merge gate. `required` (default): wait only for the status checks the base branch requires under branch protection (`gh api repos/{o}/{r}/branches/{base}/protection/required_status_checks`, contexts and checks); with no protection or no required checks (404 or empty) merge right after review PASS. A failing required check is `needs-human`; a pending one is polled with backoff and never `needs-human`. `all`: wait for every check (`scripts/wait-ci-green.sh`). `none`: merge right after review. Always `--match-head-commit` at the reviewed head. The policy used is recorded in the `FORGE:WORK-ON` comment. |
484
486
  | `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
487
 
486
488
  **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 +495,8 @@ engine:
493
495
 
494
496
  **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
497
 
498
+ **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.
499
+
496
500
  **Commands that use this section**: CLI backend (`runCommand` in `bin/runner.mjs`)
497
501
 
498
502
  ---
@@ -2,6 +2,21 @@
2
2
 
3
3
  Operator-facing notes for behavior changes that need action or awareness. Newest first.
4
4
 
5
+ ## v1.22: core work-on and orchestrate (GitHub as register)
6
+
7
+ A blind A/B showed plain Claude Code fixes issues at equal quality for about 1/10 the cost of the multi-phase pipeline, so ForgeDock is reduced to a lean core.
8
+
9
+ - **`forgedock run-issue N --lane L` now runs the core work-on by default.** One plain builder session (default model, a short developer prompt) does the work and writes `.forge-lean/result.json` (`status` fixed|already-fixed|invalid|blocked, `summary`, `evidence`, `tests`). One read-only Sonnet reviewer (Read/Grep/Glob, diff and issue in the prompt) answers two questions: does the change fix the issue, and does it introduce a regression. It fails the change only when the issue is not fixed or a regression has a concrete `file:line` failure. A FAIL resumes the builder session (`--resume`) with the reason for one re-review; a second FAIL is `needs-human`. Then push, PR (`Closes #N`), ONE register comment on the issue (`<!-- FORGE:WORK-ON -->`: summary, files, tests, review verdict, PR link, cost, tokens, wall time), CI wait (`scripts/wait-ci-green.sh`, backoff, slow CI is never `needs-human`), merge with `--match-head-commit` at the reviewed head, `workflow:merged`, close. Invalid or already-fixed with file:line evidence closes as `workflow:invalid`; without evidence it is `needs-human`.
10
+ - **Validation comment:** right after the builder ends, a `<!-- FORGE:VALIDATE -->` comment (verdict, file:line evidence, one-line summary; no extra model call) is posted before review and before `workflow:in-review`; for invalid or already-fixed it is the evidence comment. The register comment links it.
11
+ - **Rich comments, no extra model calls:** the builder writes structured `result.json` fields (root cause, approach, sibling sites, changes, tests with fails-without-fix, risks, follow-ups) and the engine renders fixed templates: `FORGE:VALIDATE`, `FORGE:PLAN` (both before review), `FORGE:REVIEW`, then `FORGE:WORK-ON` (root cause, changes table, verification, risks, review, links, merge policy, cost). The PR body reuses the summary. Missing fields read "not reported".
12
+ - **`engine.merge_wait`** (`required` default, `all`, `none`): by default the core waits only for the base branch's required checks (none required means merge right after review). See docs/CONFIG.md.
13
+ - **Unchanged:** the workflow labels, the issue lease, the Depends-on open-dependency refusal, rerun/resume from an open PR or remote branch, `phase_attempt` (builder as `implement`, reviewer as `review`) and `run_terminal` telemetry, the run log, and `forgedock stats`.
14
+ - **Removed from the default path:** investigation, architect and validate phases, severity taxonomies, the security second reviewer, risk tiers, ci-local and test-required gates, and round counters beyond the single review retry.
15
+ - **`forgedock orchestrate <issues...> [--parallel K] [--lane L]`** runs the core work-on per issue with K concurrency (default 3, lane default `staging`), in-process. An issue waits for any `**Depends on**: #X` among the given issues to merge, and is skipped if that dependency ends anything but merged/invalid (for example `needs-human`). It prints a summary table (issue, outcome, PR, cost, minutes). No investigation, synthesis or agent spawning.
16
+ - **`--path full`** keeps the legacy multi-phase pipeline, untouched (and is required for `--retry` and `--wait-lease`). `--path lean` and `--path auto` are aliases of the core. `engine.path` in forge.yaml sets the default (see docs/CONFIG.md).
17
+
18
+ Action: none for the default flow. Pin `engine.path: full` in forge.yaml to keep the old pipeline as the default.
19
+
5
20
  ## v1.19.1: build-time self-review, ci-local under concurrency, review roster gates
6
21
 
7
22
  - **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.22.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",
@@ -510,8 +510,8 @@ if [ -z "$P" ]; then bad "plan on ForgeDock's own workflows produced no output";
510
510
  eq "ForgeDock: a spec change selects the ci unit-tests job" runnable "$(state "$P" gha:ci:unit-tests)"
511
511
  eq "ForgeDock: a spec change selects the ci shell-tests job" runnable "$(state "$P" gha:ci:shell-tests)"
512
512
  eq "ForgeDock: a spec change selects the shellcheck job" runnable "$(state "$P" gha:shell-compat:shellcheck)"
513
- eq "ForgeDock: the Windows job is deferred" deferred "$(state "$P" gha:shell-compat:windows-fixture-smoke)"
514
- eq "ForgeDock: the macOS portability job is deferred" deferred "$(state "$P" gha:shell-compat:macos-portability)"
513
+ eq "ForgeDock: the Windows job is not run on PRs" not-applicable "$(state "$P" gha:shell-compat:windows-fixture-smoke)"
514
+ eq "ForgeDock: the macOS portability job is not run on PRs" not-applicable "$(state "$P" gha:shell-compat:macos-portability)"
515
515
  eq "ForgeDock: portability checks run" runnable "$(state "$P" portability:builtin)"
516
516
  eq "ForgeDock: the unit-test step is narrowed by the reference graph" scoped "$(printf '%s' "$P" | jq -r '[.jobs[] | select(.id == "gha:ci:unit-tests") | .steps[] | select(.scope.selector == "refgraph-nodetest") | .scope.mode][0]')"
517
517
  fi
@@ -183,7 +183,20 @@ if [ "$CHURN" = "true" ] || [ "$DANGER" = "true" ]; then
183
183
  for d in $(top_domains 1); do add_agent "$d"; trig churn-or-danger-zone; done
184
184
  fi
185
185
  hit() { printf '%s\n' "$FILES" | grep -qiE "$1"; }
186
- hit '(^|[/_.-])(auth([^o]|o[^r]|$)|authori[sz]|oauth|session|jwt|login|logout|passw(or)?d|permission|rbac|acl([/_.-]|$)|tokens?([/_.-]|$)|api[-_]?keys?|credential|trust[-_]?filter|trusted[-_a-z]*)|(^|/)deps\.py$' && { add_agent Auth; trig auth-path; }
186
+ # Auth path gate (forge#4071). Docs and prose are never auth by path (the content gate still applies). Strong names match
187
+ # any path segment; weak names (session, login, permission, token) only as a whole word in a code file outside design-system
188
+ # and asset dirs, so design-tokens/, SessionTimeline.tsx, LoginButton.tsx and cursor-token files do not spawn Auth. A CamelCase
189
+ # weak name, leading or after a CamelCase prefix (LoginController.java, RefreshTokenService.java), still counts outside UI
190
+ # component files and dirs.
191
+ AUTH_FILES=$(printf '%s\n' "$FILES" | grep -viE '(^|/)docs?/|\.(md|mdx|markdown|rst|adoc|txt)$' || true)
192
+ AUTH_CODE=$(printf '%s\n' "$AUTH_FILES" | grep -iE '\.(py|pyi|ts|tsx|js|jsx|mjs|cjs|go|rb|java|kt|kts|scala|rs|php|cs|ex|exs|swift|c|cc|cpp|h|hpp|sh|bash|lua|dart|groovy|pl|vue|svelte)$' \
193
+ | grep -viE '(^|/)(design[-_]?(system|tokens?)|assets?|static|images?|icons?|fonts?|styles?|themes?)/' || true)
194
+ if printf '%s\n' "$AUTH_FILES" | grep -qiE '(^|[/_.-])(auth([^o]|o[^r]|$)|authori[sz]|oauth|jwt|passw(or)?d|rbac|acl([/_.-]|$)|api[-_]?keys?|credential|trust[-_]?filter|trusted[-_a-z]*)|(^|/)deps\.py$' \
195
+ || printf '%s\n' "$AUTH_CODE" | grep -qiE '(^|[/_])(sessions?|login|logout|permissions?|tokens?)([/_.-]|$)' \
196
+ || printf '%s\n' "$AUTH_CODE" | grep -viE '\.(tsx|jsx|vue|svelte)$|(^|/)(components?|pages|views|ui|widgets)/' \
197
+ | grep -qE '((^|[/_])([Ss]essions?|[Ll]ogin|[Ll]ogout|[Pp]ermissions?|[Tt]okens?)|[a-z0-9](Sessions?|Login|Logout|Permissions?|Tokens?))([A-Z]|[/_.-]|$)'; then
198
+ add_agent Auth; trig auth-path
199
+ fi
187
200
  hit '^\.github/|(^|/)Dockerfile|docker-compose|(^|/)(docker-)?compose[^/]*\.ya?ml$|(^|/)deploy[^/]*\.sh$|(^|/)docker-entrypoint[^/]*$|(^|/)(deploy|infra|traefik|nginx|k8s|helm|terraform)/|\.tf$' && { add_agent Infra; trig infra-path; }
188
201
  hit 'migration|\.sql$' && { add_agent Database; trig migration-path; }
189
202
  hit '^sdk/|openapi.*\.json$|openapi-versions/' && { add_agent API; trig sdk-path; }
@@ -130,6 +130,19 @@ run "docs/author.md" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
130
130
  expect_not "no auth path docs/author.md" auth
131
131
  run "svc/tokenizer.py" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
132
132
  expect_not "no-auth-path-svc/tokenizer.py" auth
133
+ # forge#4071: weak names (session, token, permission, login) select Auth only as whole words in code files; docs never
134
+ for f in design-tokens/colors.json src/SessionTimeline.tsx docs/permissions.md src/components/LoginButton.tsx src/editor/cursor-token.ts docs/auth.md \
135
+ src/components/LoginForm.ts svc/tokenizer.py src/JsonTokenizer.java src/components/UserSessionBadge.ts; do
136
+ run "$f" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
137
+ expect_not "no-auth-path-$f" auth
138
+ done
139
+ for f in src/auth/session.py api/tokens.py middleware/permissions.ts lib/jwt.go src/session/store.ts svc/login_handler.go \
140
+ src/main/java/com/x/LoginController.java src/main/java/com/x/SessionManager.java src/TokenService.kt src/PermissionChecker.cs middleware/PermissionGuard.ts \
141
+ src/main/java/com/x/RefreshTokenService.java src/AccessTokenValidator.kt src/main/java/com/x/UserSessionManager.java \
142
+ src/CsrfTokenFilter.java src/AdminPermissionChecker.cs src/OtpLoginHandler.ts src/main/java/com/x/RefreshToken.java; do
143
+ run "$f" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
144
+ expect_has "auth-path-$f" auth
145
+ done
133
146
  run "commands/review-pr.md" --scores "AUTH=3 INFRA=0 DATABASE=0 BILLING=0 CONCURRENCY=0" --changed-lines 20 --changed-files 1
134
147
  expect_not "auth-from-prose-score-alone" auth
135
148
  run "commands/review-pr.md" --scores "AUTH=3 INFRA=3 DATABASE=0 BILLING=0 CONCURRENCY=0" --changed-lines 120