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.
- package/bin/engine/phases.mjs +19 -5
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine-cli.mjs +81 -1
- package/bin/forgedock.mjs +15 -3
- package/bin/lean/effects.mjs +56 -0
- package/bin/lean/lean.mjs +284 -0
- package/bin/lean/lean.test.mjs +485 -0
- package/bin/lean/orchestrate.mjs +69 -0
- package/bin/lean/prompts.mjs +55 -0
- package/bin/lean/render.mjs +66 -0
- package/bin/runner.mjs +79 -2
- package/commands/work-on/build/validate.md +13 -6
- package/commands/work-on/review.md +1 -1
- package/docs/CONFIG.md +4 -0
- package/docs/RELEASE-NOTES.md +15 -0
- package/package.json +2 -2
- package/scripts/ci-local.test.sh +2 -2
- package/scripts/review-roster.sh +14 -1
- package/scripts/review-roster.test.sh +13 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
#
|
|
848
|
-
|
|
849
|
-
|
|
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
|
---
|
package/docs/RELEASE-NOTES.md
CHANGED
|
@@ -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.
|
|
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",
|
package/scripts/ci-local.test.sh
CHANGED
|
@@ -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
|
|
514
|
-
eq "ForgeDock: the macOS portability job is
|
|
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
|
package/scripts/review-roster.sh
CHANGED
|
@@ -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
|
-
|
|
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
|