forgedock 1.2.0 → 1.4.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/README.md +8 -2
- package/bin/batch-runner.mjs +31 -2
- package/bin/engine/admission.mjs +294 -0
- package/bin/engine/admission.test.mjs +265 -0
- package/bin/engine/invariants.mjs +17 -4
- package/bin/engine/phases.mjs +358 -42
- package/bin/engine/resolve.mjs +256 -0
- package/bin/engine/resolve.test.mjs +149 -0
- package/bin/engine/state.mjs +31 -7
- package/bin/engine-cli.mjs +138 -2
- package/bin/engine.mjs +414 -21
- package/bin/forgedock.mjs +552 -44
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +189 -19
- package/bin/journey.mjs +109 -11
- package/bin/labels.json +5 -0
- package/bin/runner.mjs +321 -10
- package/bin/watch-utils.mjs +255 -0
- package/commands/cleanup.md +7 -3
- package/commands/forge-stats.md +5 -4
- package/commands/issue.md +5 -1
- package/commands/orchestrate/config.md +41 -1
- package/commands/orchestrate/phase-1-resolve.md +234 -8
- package/commands/orchestrate/phase-3-dependency.md +27 -18
- package/commands/orchestrate/phase-4-execution.md +223 -22
- package/commands/qa-sweep.md +17 -0
- package/commands/quality-gate.md +6 -6
- package/commands/replay.md +16 -2
- package/commands/review-pr-agents/spec-cli.md +179 -0
- package/commands/review-pr.md +65 -0
- package/commands/upgrade-deps.md +523 -0
- package/commands/work-on/build/validate.md +61 -1
- package/commands/work-on/build.md +9 -1
- package/commands/work-on/decompose.md +10 -1
- package/commands/work-on/investigate.md +22 -4
- package/commands/work-on/remediate.md +2 -0
- package/commands/work-on/review.md +40 -1
- package/commands/work-on.md +19 -4
- package/docs/AIDER.md +264 -0
- package/docs/AUTOPILOT-CRON.md +130 -0
- package/docs/CONFIG.md +156 -0
- package/docs/CURSOR.md +135 -0
- package/docs/OPENCODE.md +312 -0
- package/docs/demo.gif +0 -0
- package/docs/eval/README.md +4 -4
- package/docs/launch/show-hn.md +3 -3
- package/docs/site/architecture.md +180 -0
- package/docs/site/command-reference.md +17 -0
- package/docs/site/configuration.md +243 -0
- package/docs/site/for-companies.md +4 -4
- package/docs/site/forge-annotation-protocol.md +1 -1
- package/docs/site/github-is-the-memory.md +2 -2
- package/docs/site/how-it-works.md +1 -1
- package/docs/site/public/llms-full.txt +380 -0
- package/docs/site/public/llms.txt +35 -0
- package/docs/spec/eval-run-result.md +8 -4
- package/docs/spec/label-state-machine.md +9 -0
- package/docs/spec-graph-schema.md +3 -3
- package/package.json +3 -3
- package/packages/protocol/README.md +75 -0
- package/packages/protocol/src/card.js +114 -0
- package/packages/protocol/src/cli.js +4 -77
- package/packages/protocol/src/emit.js +5 -11
- package/packages/protocol/src/html-comment-escape.js +68 -0
- package/packages/protocol/src/index.js +3 -1
- package/packages/protocol/src/parse.js +19 -11
- package/packages/protocol/src/phases.js +105 -0
- package/packages/protocol/src/types.js +18 -1
- package/packages/protocol/src/validate.js +11 -0
- package/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +376 -0
- package/scripts/check-phase-registry-drift.mjs +96 -0
- package/scripts/check-spec-markers.sh +274 -0
- package/scripts/classify-lane.sh +29 -7
- package/scripts/forge-run.sh +353 -0
- package/templates/cross-repo-trigger.yml +282 -0
- package/templates/cursor/forge.mdc +148 -0
- package/templates/github-actions/forgedock-autopilot.yml +113 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue-set resolution predicate model — standing-query re-resolution (forge#2236).
|
|
3
|
+
*
|
|
4
|
+
* `/orchestrate`'s Phase 1 (`commands/orchestrate/phase-1-resolve.md`) turns
|
|
5
|
+
* `$ARGUMENTS` into a concrete list of issue numbers exactly once, at T0, and
|
|
6
|
+
* that list is frozen for the rest of the run (`commands/orchestrate/phase-4-execution.md`
|
|
7
|
+
* states this plainly: "Phase 1 only runs once, at the start."). Of the 8
|
|
8
|
+
* input patterns Phase 1 accepts, only one (`#1 #2 #3` — an explicit literal
|
|
9
|
+
* set) is actually a one-time snapshot by nature. The other 7
|
|
10
|
+
* (`milestone <slug>`, `next <N>`, `next <N> all-repos`, `fast-lane`,
|
|
11
|
+
* `priority:P0`/`priority:P1`/..., `mcp:fast`/`n8n:next 3`, a bare `<slug>`)
|
|
12
|
+
* are standing queries/predicates — "work all P0s", "work this milestone" —
|
|
13
|
+
* and a new issue matching that predicate mid-run is currently invisible to
|
|
14
|
+
* the DAG until the operator re-invokes `/orchestrate` by hand.
|
|
15
|
+
*
|
|
16
|
+
* This module is the typed reference implementation for two decisions Phase 1
|
|
17
|
+
* and Phase 4 need to make about that gap:
|
|
18
|
+
*
|
|
19
|
+
* 1. `classifyInputPattern` — is this input a `literal` set (never
|
|
20
|
+
* re-resolve — the list of numbers IS the intent) or a `query`
|
|
21
|
+
* (re-resolve periodically — the predicate is the intent)?
|
|
22
|
+
* 2. `shouldReResolve` — given that classification and the run's
|
|
23
|
+
* `orchestration.reresolve` config, should Phase 4's Step 4B loop
|
|
24
|
+
* actually re-run the query this round?
|
|
25
|
+
* 3. `foldNewMatches` — of the issue numbers a re-resolution just
|
|
26
|
+
* returned, which ones are genuinely new (not already in this run's
|
|
27
|
+
* processed-issue registry) and therefore candidates to fold into the
|
|
28
|
+
* live DAG?
|
|
29
|
+
*
|
|
30
|
+
* As with `bin/engine/admission.mjs` (forge#2234), this module does not
|
|
31
|
+
* itself shell out to `gh`/`yq` — the orchestrator is LLM-executed prose,
|
|
32
|
+
* not a `bin/engine/` call site, so `commands/orchestrate/phase-1-resolve.md`
|
|
33
|
+
* and `commands/orchestrate/phase-4-execution.md` mirror these rules by hand
|
|
34
|
+
* in bash. Any change to the classification table or defaults here MUST be
|
|
35
|
+
* mirrored there too (see forge#1837 — mirrored-logic drift between grep ERE
|
|
36
|
+
* and a typed reference is a recurring review-finding class in this pair of
|
|
37
|
+
* files).
|
|
38
|
+
*
|
|
39
|
+
* Hard invariant (by design, not configurable): `foldNewMatches` only ever
|
|
40
|
+
* narrows a re-resolved set down to "not yet processed" — it does not itself
|
|
41
|
+
* decide admission. Every folded-in issue (`newMatches`) MUST still be
|
|
42
|
+
* dispatched through the exact same path a T0-resolved issue takes —
|
|
43
|
+
* standard DAG dependency analysis (`phase-3-dependency.md`) followed by
|
|
44
|
+
* Step 4A/4B's `dispatch_headroom`-gated dispatch (see
|
|
45
|
+
* `phase-4-execution.md` Step 4B.6) — before an agent is dispatched on it.
|
|
46
|
+
* This is deliberately **not** Step 4C's `admission.mjs`'s
|
|
47
|
+
* `evaluateCascadeFinding` chain: that gate's rules (comment/typo keyword
|
|
48
|
+
* heuristic, P3+same-file defer, generation cap) are shaped for
|
|
49
|
+
* cascade-spawned review-findings — a different issue-origin stream — and
|
|
50
|
+
* would incorrectly restrict legitimate re-resolved standing-query issues
|
|
51
|
+
* if applied here. A mid-run check that detects new work but never
|
|
52
|
+
* actually wires into dispatch is exactly the dead-code failure class
|
|
53
|
+
* documented in forge#1832 (`SURFACE_BATCHED_FINDINGS`) — this module must
|
|
54
|
+
* not become a second instance of it.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @typedef {"literal"|"query"} InputKind
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {Object} ClassifiedInput
|
|
63
|
+
* @property {InputKind} kind
|
|
64
|
+
* @property {string} pattern - Canonical name of the matched pattern, e.g.
|
|
65
|
+
* "literal-numbers", "milestone", "next-n", "next-n-all-repos",
|
|
66
|
+
* "fast-lane", "priority", "repo-scoped", "bare-slug".
|
|
67
|
+
* @property {string[]} args - The raw argument tokens (excluding the pattern
|
|
68
|
+
* keyword itself) that produced this classification, preserved so a
|
|
69
|
+
* re-resolution round can replay the exact same query. Exceptions:
|
|
70
|
+
* `priority` and `repo-scoped` keep the FULL token list instead of
|
|
71
|
+
* slicing off a leading keyword token — for those two patterns the
|
|
72
|
+
* pattern keyword is fused into the same token as its argument
|
|
73
|
+
* (`priority:P0`, `mcp:fast-lane`, `n8n:next`), so there is no separable
|
|
74
|
+
* leading keyword token to drop the way there is for `milestone <slug>`
|
|
75
|
+
* or `next <N>`. Slicing here would destroy the only copy of the data a
|
|
76
|
+
* re-resolution round needs to replay the query.
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Classify a raw `/orchestrate` input string per the Phase 1 input-pattern
|
|
81
|
+
* table (`commands/orchestrate/phase-1-resolve.md` "Input Patterns").
|
|
82
|
+
*
|
|
83
|
+
* Only an explicit list of bare/`#`-prefixed, optionally repo-prefixed issue
|
|
84
|
+
* numbers (`#1 #2 #3`, `1 2 3`, `#123 mcp:5 n8n:12`) classifies as `literal`.
|
|
85
|
+
* Every other recognized pattern is a `query` — the predicate, not the
|
|
86
|
+
* resolved list, is the caller's actual intent, per the issue's finding that
|
|
87
|
+
* a query re-run mid-batch is what a standing predicate like `priority:P0`
|
|
88
|
+
* or `fast-lane` implies. An input that matches no known pattern still
|
|
89
|
+
* classifies as `query` with `pattern: "unknown"` — unrecognized input is
|
|
90
|
+
* handed to Phase 1's existing milestone/label fallback resolution, which is
|
|
91
|
+
* itself a query (bare `<slug>` row), not a literal set.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} input - Raw `$ARGUMENTS` string (already trimmed).
|
|
94
|
+
* @returns {ClassifiedInput}
|
|
95
|
+
*/
|
|
96
|
+
export function classifyInputPattern(input) {
|
|
97
|
+
const trimmed = (input ?? "").trim();
|
|
98
|
+
const tokens = trimmed.length > 0 ? trimmed.split(/\s+/) : [];
|
|
99
|
+
|
|
100
|
+
// literal-numbers: every token is a bare integer or a (optionally
|
|
101
|
+
// repo-prefixed) `#`-prefixed integer, e.g. "1 2 3", "#1 #2 #3",
|
|
102
|
+
// "#123 mcp:5 n8n:12". At least one token is required — empty input is
|
|
103
|
+
// not a literal set, it falls through to "unknown".
|
|
104
|
+
const literalNumberToken = /^([a-zA-Z0-9_-]+:)?#?\d+$/;
|
|
105
|
+
if (tokens.length > 0 && tokens.every((t) => literalNumberToken.test(t))) {
|
|
106
|
+
return { kind: "literal", pattern: "literal-numbers", args: tokens };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const lower = trimmed.toLowerCase();
|
|
110
|
+
|
|
111
|
+
if (/^milestone\s+/.test(lower)) {
|
|
112
|
+
return { kind: "query", pattern: "milestone", args: tokens.slice(1) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (/^next\s+\d+\s+all-repos$/.test(lower)) {
|
|
116
|
+
return { kind: "query", pattern: "next-n-all-repos", args: tokens.slice(1) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (/^next\s+\d+$/.test(lower)) {
|
|
120
|
+
return { kind: "query", pattern: "next-n", args: tokens.slice(1) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (lower === "fast-lane" || lower === "fast") {
|
|
124
|
+
return { kind: "query", pattern: "fast-lane", args: [] };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (/^priority:p\d+$/.test(lower) || /^[a-zA-Z0-9_-]+:priority:p\d+$/.test(lower)) {
|
|
128
|
+
return { kind: "query", pattern: "priority", args: tokens };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// repo-scoped queries: "mcp:fast", "n8n:next 3", "mcp:cascade", etc. — a
|
|
132
|
+
// repo prefix followed by a colon and a recognized query keyword. Checked
|
|
133
|
+
// after the more specific patterns above so e.g. "mcp:5" (a repo-prefixed
|
|
134
|
+
// literal number) is not mis-classified here — literalNumberToken already
|
|
135
|
+
// claimed that shape.
|
|
136
|
+
if (/^[a-zA-Z0-9_-]+:(fast-lane|fast|next|cascade|review-findings|findings)\b/.test(lower)) {
|
|
137
|
+
return { kind: "query", pattern: "repo-scoped", args: tokens };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (/^(cascade|review-findings|findings)\b/.test(lower)) {
|
|
141
|
+
return { kind: "query", pattern: "cascade", args: tokens.slice(1) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (tokens.length === 1 && tokens[0].length > 0) {
|
|
145
|
+
// bare `<slug>` — try milestone first, then label search (Phase 1's own
|
|
146
|
+
// fallback order). Still a query: the slug's membership can change.
|
|
147
|
+
return { kind: "query", pattern: "bare-slug", args: tokens };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { kind: "query", pattern: "unknown", args: tokens };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @typedef {Object} ReResolveConfig
|
|
155
|
+
* @property {boolean|string} [enabled] - Explicit off switch. `false` or the
|
|
156
|
+
* string `"off"` (case-insensitive) disables re-resolution regardless of
|
|
157
|
+
* `kind`. Absent/`undefined`/`true`/`"on"` leaves the default (on for
|
|
158
|
+
* query-kind input) in effect. This mirrors the validate-warn-fall-back
|
|
159
|
+
* idiom's spirit but re-resolution has only two valid states, so an
|
|
160
|
+
* unrecognized value is treated as `true` (default-on) rather than an
|
|
161
|
+
* error — the safer default in this context is "keep the existing
|
|
162
|
+
* behavior of periodic re-check", not "silently stop tracking a standing
|
|
163
|
+
* predicate".
|
|
164
|
+
* @property {number} [maxRounds] - Bound on how many times a single run may
|
|
165
|
+
* re-resolve the same predicate. Required to satisfy the issue's
|
|
166
|
+
* "termination is bounded" acceptance criterion — a standing predicate
|
|
167
|
+
* that keeps matching must not re-resolve forever. `undefined`/absent
|
|
168
|
+
* means "no additional bound beyond the caller's own token-budget /
|
|
169
|
+
* generation caps" (those are enforced downstream, by `admission.mjs`,
|
|
170
|
+
* not here).
|
|
171
|
+
*/
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Decide whether Phase 4's Step 4B loop should re-run the originating query
|
|
175
|
+
* this round.
|
|
176
|
+
*
|
|
177
|
+
* @param {ClassifiedInput} classified - Result of `classifyInputPattern`.
|
|
178
|
+
* @param {ReResolveConfig} [config]
|
|
179
|
+
* @param {number} [roundsSoFar] - How many re-resolution rounds have already
|
|
180
|
+
* run this batch (0 on the first call).
|
|
181
|
+
* @returns {{ reResolve: boolean, reason: string }}
|
|
182
|
+
*/
|
|
183
|
+
export function shouldReResolve(classified, config = {}, roundsSoFar = 0) {
|
|
184
|
+
if (classified.kind === "literal") {
|
|
185
|
+
return {
|
|
186
|
+
reResolve: false,
|
|
187
|
+
reason: "literal issue-number set — the list IS the intent, never re-resolve",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const enabledRaw = config.enabled;
|
|
192
|
+
// The `"off"` string branch IS reachable via the real `yq`-based bash
|
|
193
|
+
// mirror in `phase-4-execution.md` Step 4B.6 — not just from direct JS
|
|
194
|
+
// callers (e.g. this file's own unit tests). `yq`'s default core schema
|
|
195
|
+
// only coerces the literal scalars `true`/`false` to booleans; an
|
|
196
|
+
// unquoted `enabled: off` in forge.yaml is parsed as the plain string
|
|
197
|
+
// `"off"` (verified: `yq '.a.enabled' <<< 'a: {enabled: off}'` prints
|
|
198
|
+
// `off`, `... | type` prints `!!str`), which then survives
|
|
199
|
+
// `process.argv[3] === "false" ? false : process.argv[3]` unchanged and
|
|
200
|
+
// reaches this branch as the string `"off"`. Only `enabled: false`
|
|
201
|
+
// (unquoted) is coerced by `yq` to boolean `false` before this code
|
|
202
|
+
// runs. Do not remove this branch as unreachable dead code.
|
|
203
|
+
const disabled =
|
|
204
|
+
enabledRaw === false || (typeof enabledRaw === "string" && enabledRaw.trim().toLowerCase() === "off");
|
|
205
|
+
if (disabled) {
|
|
206
|
+
return { reResolve: false, reason: "orchestration.reresolve.enabled is off" };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (typeof config.maxRounds === "number" && Number.isFinite(config.maxRounds) && roundsSoFar >= config.maxRounds) {
|
|
210
|
+
return {
|
|
211
|
+
reResolve: false,
|
|
212
|
+
reason: `orchestration.reresolve.max_rounds (${config.maxRounds}) reached — bounded termination`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
reResolve: true,
|
|
218
|
+
reason: `query pattern "${classified.pattern}" is a standing predicate — re-resolving`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Narrow a freshly re-resolved issue-number list down to genuinely new
|
|
224
|
+
* issues — ones not already present in the run's processed-issue registry
|
|
225
|
+
* (T0-resolved issues plus any already admitted in a prior re-resolution
|
|
226
|
+
* round). This function does NOT decide admission; every returned number
|
|
227
|
+
* (`newMatches`) must still be dispatched through the same path a
|
|
228
|
+
* T0-resolved issue takes — standard DAG dependency analysis followed by
|
|
229
|
+
* Step 4A/4B's dispatch (`phase-4-execution.md` Step 4B.6) — NOT through
|
|
230
|
+
* Step 4C's `admission.mjs`'s `evaluateCascadeFinding` chain, which is
|
|
231
|
+
* reserved for the cascade-spawned review-finding stream — see module
|
|
232
|
+
* docstring's "Hard invariant".
|
|
233
|
+
*
|
|
234
|
+
* @param {number[]} reResolvedNumbers - Issue numbers the query returned this round.
|
|
235
|
+
* @param {Iterable<number>} processedRegistry - Issue numbers already
|
|
236
|
+
* resolved at T0 or admitted in an earlier re-resolution round this run.
|
|
237
|
+
* @returns {{ newMatches: number[], alreadyProcessed: number[] }}
|
|
238
|
+
*/
|
|
239
|
+
export function foldNewMatches(reResolvedNumbers, processedRegistry) {
|
|
240
|
+
const processed = new Set(processedRegistry);
|
|
241
|
+
const newMatches = [];
|
|
242
|
+
const alreadyProcessed = [];
|
|
243
|
+
const seenThisCall = new Set();
|
|
244
|
+
|
|
245
|
+
for (const n of reResolvedNumbers ?? []) {
|
|
246
|
+
if (seenThisCall.has(n)) continue; // de-dupe within the same re-resolution result
|
|
247
|
+
seenThisCall.add(n);
|
|
248
|
+
if (processed.has(n)) {
|
|
249
|
+
alreadyProcessed.push(n);
|
|
250
|
+
} else {
|
|
251
|
+
newMatches.push(n);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { newMatches, alreadyProcessed };
|
|
256
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { classifyInputPattern, shouldReResolve, foldNewMatches } from "./resolve.mjs";
|
|
4
|
+
|
|
5
|
+
describe("classifyInputPattern — literal sets", () => {
|
|
6
|
+
it("classifies bare issue numbers as literal", () => {
|
|
7
|
+
assert.deepEqual(classifyInputPattern("1 2 3"), {
|
|
8
|
+
kind: "literal",
|
|
9
|
+
pattern: "literal-numbers",
|
|
10
|
+
args: ["1", "2", "3"],
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("classifies #-prefixed issue numbers as literal", () => {
|
|
15
|
+
const r = classifyInputPattern("#1 #2 #3");
|
|
16
|
+
assert.equal(r.kind, "literal");
|
|
17
|
+
assert.equal(r.pattern, "literal-numbers");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("classifies repo-prefixed literal numbers as literal", () => {
|
|
21
|
+
const r = classifyInputPattern("#123 mcp:5 n8n:12");
|
|
22
|
+
assert.equal(r.kind, "literal");
|
|
23
|
+
assert.equal(r.pattern, "literal-numbers");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("classifies a single bare number as literal", () => {
|
|
27
|
+
const r = classifyInputPattern("42");
|
|
28
|
+
assert.equal(r.kind, "literal");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("classifyInputPattern — query patterns", () => {
|
|
33
|
+
it("classifies milestone <slug> as a query", () => {
|
|
34
|
+
const r = classifyInputPattern("milestone modular-pipeline-architecture");
|
|
35
|
+
assert.equal(r.kind, "query");
|
|
36
|
+
assert.equal(r.pattern, "milestone");
|
|
37
|
+
assert.deepEqual(r.args, ["modular-pipeline-architecture"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("classifies next <N> as a query", () => {
|
|
41
|
+
const r = classifyInputPattern("next 5");
|
|
42
|
+
assert.equal(r.kind, "query");
|
|
43
|
+
assert.equal(r.pattern, "next-n");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("classifies next <N> all-repos as a query", () => {
|
|
47
|
+
const r = classifyInputPattern("next 5 all-repos");
|
|
48
|
+
assert.equal(r.kind, "query");
|
|
49
|
+
assert.equal(r.pattern, "next-n-all-repos");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("classifies fast-lane / fast as a query", () => {
|
|
53
|
+
assert.equal(classifyInputPattern("fast-lane").pattern, "fast-lane");
|
|
54
|
+
assert.equal(classifyInputPattern("fast").pattern, "fast-lane");
|
|
55
|
+
assert.equal(classifyInputPattern("fast-lane").kind, "query");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("classifies priority:P0 / priority:P1 as a query (both label schemas, forge#2232)", () => {
|
|
59
|
+
assert.equal(classifyInputPattern("priority:P0").pattern, "priority");
|
|
60
|
+
assert.equal(classifyInputPattern("priority:P1").kind, "query");
|
|
61
|
+
assert.equal(classifyInputPattern("mcp:priority:P2").pattern, "priority");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("classifies repo-scoped queries as a query", () => {
|
|
65
|
+
assert.equal(classifyInputPattern("mcp:fast").pattern, "repo-scoped");
|
|
66
|
+
assert.equal(classifyInputPattern("n8n:next 3").pattern, "repo-scoped");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("classifies cascade/review-findings/findings as a query", () => {
|
|
70
|
+
assert.equal(classifyInputPattern("cascade").pattern, "cascade");
|
|
71
|
+
assert.equal(classifyInputPattern("review-findings").pattern, "cascade");
|
|
72
|
+
assert.equal(classifyInputPattern("findings --include-deferred").pattern, "cascade");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("classifies a bare slug as a query", () => {
|
|
76
|
+
const r = classifyInputPattern("some-milestone-slug");
|
|
77
|
+
assert.equal(r.kind, "query");
|
|
78
|
+
assert.equal(r.pattern, "bare-slug");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("classifies empty/unrecognized input as an unknown query, never literal", () => {
|
|
82
|
+
assert.equal(classifyInputPattern("").kind, "query");
|
|
83
|
+
assert.equal(classifyInputPattern("").pattern, "unknown");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("shouldReResolve", () => {
|
|
88
|
+
it("never re-resolves a literal set, regardless of config", () => {
|
|
89
|
+
const classified = { kind: "literal", pattern: "literal-numbers", args: ["1"] };
|
|
90
|
+
const r = shouldReResolve(classified, { enabled: true, maxRounds: 100 }, 0);
|
|
91
|
+
assert.equal(r.reResolve, false);
|
|
92
|
+
assert.match(r.reason, /never re-resolve/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("re-resolves a query pattern by default (no config)", () => {
|
|
96
|
+
const classified = { kind: "query", pattern: "priority", args: [] };
|
|
97
|
+
const r = shouldReResolve(classified);
|
|
98
|
+
assert.equal(r.reResolve, true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("respects an explicit off switch (boolean false)", () => {
|
|
102
|
+
const classified = { kind: "query", pattern: "fast-lane", args: [] };
|
|
103
|
+
const r = shouldReResolve(classified, { enabled: false });
|
|
104
|
+
assert.equal(r.reResolve, false);
|
|
105
|
+
assert.match(r.reason, /off/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("respects an explicit off switch (string 'off', case-insensitive)", () => {
|
|
109
|
+
const classified = { kind: "query", pattern: "fast-lane", args: [] };
|
|
110
|
+
assert.equal(shouldReResolve(classified, { enabled: "off" }).reResolve, false);
|
|
111
|
+
assert.equal(shouldReResolve(classified, { enabled: "OFF" }).reResolve, false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("bounds termination via maxRounds", () => {
|
|
115
|
+
const classified = { kind: "query", pattern: "milestone", args: [] };
|
|
116
|
+
assert.equal(shouldReResolve(classified, { maxRounds: 3 }, 2).reResolve, true);
|
|
117
|
+
const atLimit = shouldReResolve(classified, { maxRounds: 3 }, 3);
|
|
118
|
+
assert.equal(atLimit.reResolve, false);
|
|
119
|
+
assert.match(atLimit.reason, /max_rounds/);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("foldNewMatches", () => {
|
|
124
|
+
it("returns issues not already in the processed registry", () => {
|
|
125
|
+
const r = foldNewMatches([10, 11, 12], [10, 11]);
|
|
126
|
+
assert.deepEqual(r.newMatches, [12]);
|
|
127
|
+
assert.deepEqual(r.alreadyProcessed, [10, 11]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("is idempotent — re-running with an updated registry returns nothing new", () => {
|
|
131
|
+
const first = foldNewMatches([10, 11, 12], [10, 11]);
|
|
132
|
+
assert.deepEqual(first.newMatches, [12]);
|
|
133
|
+
const registryAfterFold = new Set([10, 11, ...first.newMatches]);
|
|
134
|
+
const second = foldNewMatches([10, 11, 12], registryAfterFold);
|
|
135
|
+
assert.deepEqual(second.newMatches, []);
|
|
136
|
+
assert.deepEqual(second.alreadyProcessed, [10, 11, 12]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("de-dupes duplicate numbers within the same re-resolution result", () => {
|
|
140
|
+
const r = foldNewMatches([5, 5, 6], []);
|
|
141
|
+
assert.deepEqual(r.newMatches, [5, 6]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("handles an empty re-resolution result", () => {
|
|
145
|
+
const r = foldNewMatches([], [1, 2, 3]);
|
|
146
|
+
assert.deepEqual(r.newMatches, []);
|
|
147
|
+
assert.deepEqual(r.alreadyProcessed, []);
|
|
148
|
+
});
|
|
149
|
+
});
|
package/bin/engine/state.mjs
CHANGED
|
@@ -2,17 +2,33 @@
|
|
|
2
2
|
* Codec for the single machine-readable FORGE:STATE HTML-comment block on an
|
|
3
3
|
* issue body. Carries the COMPACT INDEX only (no rich per-phase outputs).
|
|
4
4
|
*/
|
|
5
|
+
import {
|
|
6
|
+
escapeHtmlCommentDelimiters,
|
|
7
|
+
unescapeHtmlCommentDelimiters,
|
|
8
|
+
} from "../../packages/protocol/src/html-comment-escape.js";
|
|
9
|
+
|
|
5
10
|
const OPEN = "<!-- FORGE:STATE";
|
|
6
11
|
const CLOSE = "-->";
|
|
7
12
|
const BLOCK_RE = /<!-- FORGE:STATE\s*([\s\S]*?)-->/;
|
|
8
13
|
|
|
9
14
|
/** @param {import("./phases.mjs").RunState} index */
|
|
10
15
|
export function serializeState(index) {
|
|
11
|
-
// Escape
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
+
// Escape all three HTML comment delimiter forms so a payload value can never
|
|
17
|
+
// open or close an HTML comment in GitHub's renderer:
|
|
18
|
+
// - "<!--" the comment opener — an unescaped opener would start a new
|
|
19
|
+
// (nested-looking) comment, visually swallowing subsequent content
|
|
20
|
+
// - "-->" and "--!>" the two comment-close forms browsers/HTML parsers
|
|
21
|
+
// honour. JSON.stringify does not escape any of these, so a payload
|
|
22
|
+
// value containing them could otherwise open/close the
|
|
23
|
+
// <!-- FORGE:STATE ... --> block early (CodeQL js/bad-tag-filter).
|
|
24
|
+
//
|
|
25
|
+
// Delegated to the shared escapeHtmlCommentDelimiters() helper (forge#2225),
|
|
26
|
+
// which is also used by packages/protocol/src/emit.js's sanitizeFieldValue —
|
|
27
|
+
// this scheme previously had two hand-duplicated copies kept in sync only by
|
|
28
|
+
// cross-referencing code comments, and had already drifted once (forge#1638's
|
|
29
|
+
// fix landed here only after forge#2119). A single shared implementation
|
|
30
|
+
// removes that drift vector entirely.
|
|
31
|
+
const payload = escapeHtmlCommentDelimiters(JSON.stringify(index));
|
|
16
32
|
return `${OPEN}\n${payload}\n${CLOSE}`;
|
|
17
33
|
}
|
|
18
34
|
|
|
@@ -20,8 +36,16 @@ export function serializeState(index) {
|
|
|
20
36
|
export function parseState(issueBody) {
|
|
21
37
|
const m = BLOCK_RE.exec(issueBody || "");
|
|
22
38
|
if (!m) return null;
|
|
23
|
-
// Inverse of serializeState
|
|
24
|
-
|
|
39
|
+
// Inverse of serializeState — delegated to the shared
|
|
40
|
+
// unescapeHtmlCommentDelimiters() helper (forge#2225), same ordering
|
|
41
|
+
// rationale as packages/protocol/src/parse.js's unescapeFieldValue: closer
|
|
42
|
+
// forms restored first (most-specific "--!>" before "-->"), opener
|
|
43
|
+
// restored next, and "&" -> "&" unescaped LAST so it never touches the
|
|
44
|
+
// "&" that is part of a just-restored "<!--"/"-->"/"--!>" sequence
|
|
45
|
+
// (forge#2137).
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(unescapeHtmlCommentDelimiters(m[1].trim()));
|
|
48
|
+
} catch { return null; }
|
|
25
49
|
}
|
|
26
50
|
|
|
27
51
|
/** Replace the FORGE:STATE block in place, or append one if absent. */
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { promisify } from "node:util";
|
|
|
9
9
|
import { readdirSync, statSync } from "node:fs";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
-
import { runIssue } from "./engine.mjs";
|
|
12
|
+
import { runIssue, DEFAULT_MAX_ATTEMPTS } from "./engine.mjs";
|
|
13
13
|
import { makeProjector } from "./engine/projector.mjs";
|
|
14
14
|
import { readLog, deriveState } from "./engine/runlog.mjs";
|
|
15
15
|
|
|
@@ -55,6 +55,103 @@ export function makeIo() {
|
|
|
55
55
|
|
|
56
56
|
export function runDir() { return join(homedir(), ".forge", "runs"); }
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Sums the four token-usage fields across every `PHASE_COMMIT`/`PHASE_FAILED`
|
|
60
|
+
* run-log event that carries a non-null `usage` object. Mirrors the writer
|
|
61
|
+
* shape and `?? 0` accumulation convention used by `bin/engine.mjs:411,500`
|
|
62
|
+
* (`usage: outcome.usage ?? null`) and `bin/runner.mjs:1699-1723` (the
|
|
63
|
+
* canonical per-run accumulator) — same field names, same null-safe adds.
|
|
64
|
+
*
|
|
65
|
+
* Only `PHASE_COMMIT`/`PHASE_FAILED` events carry `usage`; other event types
|
|
66
|
+
* are skipped without inspecting their shape.
|
|
67
|
+
*
|
|
68
|
+
* @param {Array<object>} events - run-log events (from readLog())
|
|
69
|
+
* @returns {{input_tokens: number, output_tokens: number, cache_creation_input_tokens: number, cache_read_input_tokens: number} | null}
|
|
70
|
+
* `null` when no event carried usage data (e.g. an all-CLI-backend run) —
|
|
71
|
+
* callers MUST treat `null` as "omit the line", never substitute zeros
|
|
72
|
+
* (a `0 tokens` line would misleadingly imply usage was measured and was
|
|
73
|
+
* zero, rather than "not measured at all").
|
|
74
|
+
*/
|
|
75
|
+
export function aggregateUsage(events) {
|
|
76
|
+
let found = false;
|
|
77
|
+
const total = { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 };
|
|
78
|
+
for (const e of events) {
|
|
79
|
+
if (e.event !== "PHASE_COMMIT" && e.event !== "PHASE_FAILED") continue;
|
|
80
|
+
if (!e.usage || typeof e.usage !== "object") continue;
|
|
81
|
+
found = true;
|
|
82
|
+
total.input_tokens += e.usage.input_tokens ?? 0;
|
|
83
|
+
total.output_tokens += e.usage.output_tokens ?? 0;
|
|
84
|
+
total.cache_creation_input_tokens += e.usage.cache_creation_input_tokens ?? 0;
|
|
85
|
+
total.cache_read_input_tokens += e.usage.cache_read_input_tokens ?? 0;
|
|
86
|
+
}
|
|
87
|
+
return found ? total : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Renders a single usage-summary line from an `aggregateUsage()` result, or
|
|
92
|
+
* `""` when `usage` is `null` — callers must skip printing entirely in that
|
|
93
|
+
* case rather than print a placeholder/zero line.
|
|
94
|
+
* @param {{input_tokens:number, output_tokens:number, cache_creation_input_tokens:number, cache_read_input_tokens:number} | null} usage
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
export function formatUsageLine(usage) {
|
|
98
|
+
if (!usage) return "";
|
|
99
|
+
return ` usage: ${usage.input_tokens} in / ${usage.output_tokens} out (${usage.cache_read_input_tokens} cache-read / ${usage.cache_creation_input_tokens} cache-write)`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Renders the diagnostic block printed below the bare terminal line whenever
|
|
104
|
+
* a run does not terminate `merged` (forge#2175). Reconstructs the failing
|
|
105
|
+
* phase, attempt count, and `PHASE_FAILED.reason` from the durable run-log
|
|
106
|
+
* (the same data `bin/engine.mjs`'s `runPhaseWithRetry()` already appends via
|
|
107
|
+
* `appendEvent()`), plus the final committed/branch/pr state, the aggregate
|
|
108
|
+
* per-run token usage (forge#2399 — omitted when no event carries usage
|
|
109
|
+
* data), and the run-log path — closing the gap where an operator previously
|
|
110
|
+
* had to manually open `~/.forge/runs/{issue}.jsonl` and read engine source
|
|
111
|
+
* to interpret a bare `issue #N -> needs-human` line.
|
|
112
|
+
*
|
|
113
|
+
* Best-effort: if the run-log is empty/unreadable (e.g. a `deferred` early
|
|
114
|
+
* return before any event was appended), only the run-log path line is
|
|
115
|
+
* printed — never throws.
|
|
116
|
+
*
|
|
117
|
+
* Validates `issue` internally (mirrors the `Number.isInteger` guard in
|
|
118
|
+
* `lastLocalRun()` below) rather than relying solely on the caller —
|
|
119
|
+
* `runFromCli()` already validates before calling this, but this is an
|
|
120
|
+
* exported helper and a future caller could forget to (forge#2190).
|
|
121
|
+
*
|
|
122
|
+
* @param {string} dir - runDir() (or an injected override for tests)
|
|
123
|
+
* @param {number} issue
|
|
124
|
+
* @returns {string} multi-line diagnostic block (no trailing newline)
|
|
125
|
+
*/
|
|
126
|
+
export function formatTerminalDiagnostics(dir, issue) {
|
|
127
|
+
if (!Number.isInteger(issue)) {
|
|
128
|
+
return ` run-log: <invalid issue: ${JSON.stringify(issue)}>`;
|
|
129
|
+
}
|
|
130
|
+
const runLogPath = join(dir, `${issue}.jsonl`);
|
|
131
|
+
let events = [];
|
|
132
|
+
try {
|
|
133
|
+
events = readLog(dir, issue);
|
|
134
|
+
} catch {
|
|
135
|
+
// Corrupt/unreadable log — degrade to just the run-log path line below.
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const lines = [];
|
|
139
|
+
if (events.length > 0) {
|
|
140
|
+
const state = deriveState(events);
|
|
141
|
+
const lastFailure = [...events].reverse().find((e) => e.event === "PHASE_FAILED");
|
|
142
|
+
if (lastFailure) {
|
|
143
|
+
lines.push(` phase: ${lastFailure.phase} (failed ${lastFailure.attempt}/${lastFailure.maxAttempts ?? DEFAULT_MAX_ATTEMPTS} attempts)`);
|
|
144
|
+
lines.push(` reason: ${lastFailure.reason}`);
|
|
145
|
+
}
|
|
146
|
+
const committed = state.committed.length ? state.committed.join(",") : "";
|
|
147
|
+
lines.push(` state: committed=[${committed}] branch=${state.branch ?? "null"} pr=${state.pr ?? "null"}`);
|
|
148
|
+
}
|
|
149
|
+
lines.push(` run-log: ${runLogPath}`);
|
|
150
|
+
const usageLine = formatUsageLine(aggregateUsage(events));
|
|
151
|
+
if (usageLine) lines.push(usageLine);
|
|
152
|
+
return lines.join("\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
58
155
|
/**
|
|
59
156
|
* Resolves the repo `gh` would target by default (i.e. resolved from the cwd
|
|
60
157
|
* git remote), via `gh repo view`. Used to validate an explicit `--repo` flag
|
|
@@ -252,13 +349,52 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
252
349
|
await assertRepoMatchesCwd(io, repo);
|
|
253
350
|
const runIssueFn = deps.runIssue ?? runIssue;
|
|
254
351
|
const agentId = `cli_${process.pid}`;
|
|
255
|
-
|
|
352
|
+
// Injectable for tests (forge#2175) — defaults to the real ~/.forge/runs dir.
|
|
353
|
+
const dir = deps.dir ?? runDir();
|
|
354
|
+
// forge#2240: print the run-log path at the very start of the run, not only
|
|
355
|
+
// in the non-merged completion diagnostics (formatTerminalDiagnostics prints
|
|
356
|
+
// it too, but only post-completion and only for non-"merged" outcomes). A
|
|
357
|
+
// caller tailing this process's stdout should be able to find the run-log
|
|
358
|
+
// to inspect immediately, without waiting for the run to finish.
|
|
359
|
+
console.log(`run-log: ${join(dir, `${issue}.jsonl`)}`);
|
|
360
|
+
// forge#2240: phase-boundary progress lines — the only stdout emitted
|
|
361
|
+
// during the run itself. `runIssue()`'s `onProgress` callback defaults to a
|
|
362
|
+
// no-op, so this is purely additive; engine.mjs never calls console.log
|
|
363
|
+
// directly (keeps it injectable/testable — see its onProgress param doc).
|
|
364
|
+
const onProgress = (e) => {
|
|
365
|
+
if (e.event === "phase_enter") console.log(`→ phase ${e.phase} started`);
|
|
366
|
+
else if (e.event === "phase_exit" && e.status === "committed") console.log(`✓ phase ${e.phase} committed`);
|
|
367
|
+
else if (e.event === "phase_exit" && e.status === "blocked") console.log(`✗ phase ${e.phase} blocked: ${e.detail ?? "no detail"}`);
|
|
368
|
+
};
|
|
369
|
+
const res = await runIssueFn({ issue, dir, agentId, lane, io,
|
|
256
370
|
runner: (await import("./runner.mjs")).runCommand, now: () => Date.now(),
|
|
371
|
+
onProgress,
|
|
257
372
|
// Only forwarded when explicitly provided — omitting them preserves
|
|
258
373
|
// runIssue's/runner.mjs's existing defaults (forge#2028).
|
|
259
374
|
...(backend ? { backend } : {}),
|
|
260
375
|
...(model ? { model } : {}) });
|
|
261
376
|
console.log(`issue #${issue} → ${res.terminalReason}`);
|
|
377
|
+
// forge#2175: a non-success termination previously printed nothing beyond
|
|
378
|
+
// the bare reason above — the actual failing phase/attempt/reason was only
|
|
379
|
+
// recoverable by manually reading ~/.forge/runs/{issue}.jsonl and then the
|
|
380
|
+
// engine source. Print it here instead, reconstructed from the same
|
|
381
|
+
// run-log runIssue() just wrote. Skipped for "merged" (the success case)
|
|
382
|
+
// to avoid regressing/cluttering the existing happy-path output.
|
|
383
|
+
if (res.terminalReason !== "merged") {
|
|
384
|
+
console.log(formatTerminalDiagnostics(dir, issue));
|
|
385
|
+
} else {
|
|
386
|
+
// forge#2399: formatTerminalDiagnostics() (and its phase/reason/state
|
|
387
|
+
// block) intentionally stays gated to non-merged outcomes above, but the
|
|
388
|
+
// aggregate token-usage total should print for "total tokens per run" to
|
|
389
|
+
// hold on the common success case too — surface just the usage line here
|
|
390
|
+
// rather than the full diagnostic block.
|
|
391
|
+
try {
|
|
392
|
+
const usageLine = formatUsageLine(aggregateUsage(readLog(dir, issue)));
|
|
393
|
+
if (usageLine) console.log(usageLine);
|
|
394
|
+
} catch {
|
|
395
|
+
// Corrupt/unreadable log — degrade silently, same as formatTerminalDiagnostics.
|
|
396
|
+
}
|
|
397
|
+
}
|
|
262
398
|
return res;
|
|
263
399
|
}
|
|
264
400
|
|