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
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
* The hook looks for FORGE annotation markers in the transcript's tool
|
|
37
37
|
* results (gh issue comment / gh api calls):
|
|
38
38
|
*
|
|
39
|
-
* INVESTIGATION:COMPLETE
|
|
40
|
-
* FORGE:CONTEXT
|
|
41
|
-
* FORGE:ARCHITECT
|
|
42
|
-
* FORGE:BUILDER:COMPLETE
|
|
39
|
+
* INVESTIGATION:COMPLETE → phase "investigate" committed
|
|
40
|
+
* FORGE:CONTEXT:COMPLETE → phase "context" committed
|
|
41
|
+
* FORGE:ARCHITECT:COMPLETE → phase "architect" committed
|
|
42
|
+
* FORGE:BUILDER:COMPLETE → phase "build" committed
|
|
43
43
|
* FORGE:REVIEWER → phase "review" committed
|
|
44
44
|
* workflow:merged label → phase "close" committed (terminal)
|
|
45
45
|
*
|
|
@@ -60,6 +60,7 @@ import { fileURLToPath, pathToFileURL } from "url";
|
|
|
60
60
|
import { dirname, join, resolve } from "path";
|
|
61
61
|
import { existsSync, readFileSync } from "fs";
|
|
62
62
|
import { execFileSync } from "child_process";
|
|
63
|
+
import { PHASE_MARKERS as PHASE_MARKER_REGISTRY } from "../../packages/protocol/src/phases.js";
|
|
63
64
|
|
|
64
65
|
// ---------------------------------------------------------------------------
|
|
65
66
|
// Bootstrap
|
|
@@ -75,18 +76,31 @@ const FORGE_HOME = resolve(__dirname, "..", "..");
|
|
|
75
76
|
// Maps FORGE annotation markers (found in transcript tool results) to phase IDs.
|
|
76
77
|
// ---------------------------------------------------------------------------
|
|
77
78
|
|
|
79
|
+
// forge#2378: marker strings are single-sourced from packages/protocol's phase
|
|
80
|
+
// registry (bin/engine/phases.mjs imports the identical registry) — do NOT
|
|
81
|
+
// reintroduce inline "FORGE:..."/"INVESTIGATION:..."/"workflow:merged" literals
|
|
82
|
+
// here. This table previously hand-duplicated those strings and drifted out of
|
|
83
|
+
// sync with the engine's stricter :COMPLETE gate (forge#2375/PR#2395) — sourcing
|
|
84
|
+
// both consumers from one registry makes that class of drift structurally
|
|
85
|
+
// impossible going forward.
|
|
86
|
+
//
|
|
87
|
+
// Require the :COMPLETE suffix — a bare "FORGE:CONTEXT"/"FORGE:ARCHITECT"
|
|
88
|
+
// substring also matches a partial/interrupted annotation (e.g.
|
|
89
|
+
// FORGE:CONTEXT:PARTIAL), which is not actually committed. bin/engine/phases.mjs's
|
|
90
|
+
// architect detectOutcome() requires the identical :COMPLETE marker to consider
|
|
91
|
+
// the phase committed.
|
|
78
92
|
/** @type {Array<{marker: string, phase: string, terminal?: boolean, terminalReason?: string}>} */
|
|
79
93
|
const PHASE_MARKERS = [
|
|
80
|
-
{ marker:
|
|
81
|
-
{ marker:
|
|
82
|
-
{ marker:
|
|
83
|
-
{ marker:
|
|
84
|
-
{ marker:
|
|
85
|
-
{ marker:
|
|
94
|
+
{ marker: PHASE_MARKER_REGISTRY.investigate.completionMarker, phase: "investigate" },
|
|
95
|
+
{ marker: PHASE_MARKER_REGISTRY.investigate.invalidMarker, phase: "investigate", terminal: true, terminalReason: "invalid" },
|
|
96
|
+
{ marker: PHASE_MARKER_REGISTRY.investigate.decomposedMarker, phase: "investigate", terminal: true, terminalReason: "decomposed" },
|
|
97
|
+
{ marker: PHASE_MARKER_REGISTRY.context.completionMarker, phase: "context" },
|
|
98
|
+
{ marker: PHASE_MARKER_REGISTRY.architect.completionMarker, phase: "architect" },
|
|
99
|
+
{ marker: PHASE_MARKER_REGISTRY.build.completionMarker, phase: "build" },
|
|
86
100
|
// review phase: PR merged is detected from gh label/state
|
|
87
|
-
{ marker:
|
|
101
|
+
{ marker: PHASE_MARKER_REGISTRY.review.completionMarker, phase: "review" },
|
|
88
102
|
// close phase: issue closed with workflow:merged
|
|
89
|
-
{ marker:
|
|
103
|
+
{ marker: PHASE_MARKER_REGISTRY.close.completionLabel, phase: "close", terminal: true, terminalReason: "merged" },
|
|
90
104
|
];
|
|
91
105
|
|
|
92
106
|
/**
|
|
@@ -164,13 +178,15 @@ async function main() {
|
|
|
164
178
|
// missing, block the subagent from completing silently and inject
|
|
165
179
|
// corrective context so the agent knows what to do.
|
|
166
180
|
if (skillInvoked && annotationMissing && phaseId) {
|
|
181
|
+
// forge#2378: sourced from the same registry as PHASE_MARKERS above — no
|
|
182
|
+
// second hand-written copy of these marker strings.
|
|
167
183
|
const PHASE_ANNOTATION_MAP = {
|
|
168
|
-
investigate:
|
|
169
|
-
context:
|
|
170
|
-
architect:
|
|
171
|
-
build:
|
|
172
|
-
review:
|
|
173
|
-
close:
|
|
184
|
+
investigate: `${PHASE_MARKER_REGISTRY.investigate.completionMarker} (or ${PHASE_MARKER_REGISTRY.investigate.invalidMarker} / ${PHASE_MARKER_REGISTRY.investigate.decomposedMarker})`,
|
|
185
|
+
context: PHASE_MARKER_REGISTRY.context.completionMarker,
|
|
186
|
+
architect: PHASE_MARKER_REGISTRY.architect.completionMarker,
|
|
187
|
+
build: PHASE_MARKER_REGISTRY.build.completionMarker,
|
|
188
|
+
review: PHASE_MARKER_REGISTRY.review.completionMarker,
|
|
189
|
+
close: `${PHASE_MARKER_REGISTRY.close.completionLabel} label`,
|
|
174
190
|
};
|
|
175
191
|
const expected = PHASE_ANNOTATION_MAP[phaseId] || `the ${phaseId} phase annotation`;
|
|
176
192
|
// Output additionalContext JSON (v2.1.163+ SubagentStop format).
|
|
@@ -308,6 +324,20 @@ export function parseTranscript(transcriptPath) {
|
|
|
308
324
|
* - Scan tool_result entries for gh CLI output containing FORGE annotation markers
|
|
309
325
|
* - Extract issue number from Skill args or from the skill name context
|
|
310
326
|
*
|
|
327
|
+
* `outputs` is intentionally always `{}` from this function (forge#2375): earlier
|
|
328
|
+
* versions matched a bare `"number"` field and a bare `branch[:space]` token against
|
|
329
|
+
* the full text of every tool_result block, with no scoping to a FORGE-authoritative
|
|
330
|
+
* source. That regularly matched unrelated JSON (e.g. `gh issue view`'s own `"number"`
|
|
331
|
+
* field, matching the issue's own number as if it were a PR) or unrelated prose
|
|
332
|
+
* containing the word "branch", producing corrupt PHASE_COMMIT run-log entries
|
|
333
|
+
* (observed: `branch:"diff"`, `branch:"writer"`, `branch:"refs"`, `pr:<issue#>`).
|
|
334
|
+
* Those values were also consumed as a state fallback by
|
|
335
|
+
* bin/engine/runlog.mjs:deriveState() → bin/engine/phases.mjs:resolveBranch(), so a
|
|
336
|
+
* corrupt hook-derived value could leak into the engine's own resolution path. The
|
|
337
|
+
* engine already resolves branch/PR from GitHub ground truth
|
|
338
|
+
* (bin/engine/phases.mjs:parseBranchFromMarkers(), scoped to the FORGE:BUILDER:COMPLETE
|
|
339
|
+
* comment — forge#2184) on next pickup, so no replacement extraction is needed here.
|
|
340
|
+
*
|
|
311
341
|
* @param {object[]} entries
|
|
312
342
|
* @returns {{ issueNumber: number|null, phaseId: string|null, terminalReason: string|null, outputs: object, skillInvoked: boolean, annotationMissing: boolean }}
|
|
313
343
|
*/
|
|
@@ -352,12 +382,8 @@ export function detectPhase(entries) {
|
|
|
352
382
|
for (const { marker } of PHASE_MARKERS) {
|
|
353
383
|
if (content.includes(marker)) foundMarkers.add(marker);
|
|
354
384
|
}
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
if (prM) outputs.pr = parseInt(prM[1], 10);
|
|
358
|
-
// Extract branch from git push output.
|
|
359
|
-
const branchM = content.match(/(?:refs\/heads\/|branch[:\s]+)([\w\-./]+)/i);
|
|
360
|
-
if (branchM) outputs.branch = branchM[1];
|
|
385
|
+
// No PR/branch extraction here (forge#2375) — see the JSDoc above
|
|
386
|
+
// detectPhase() for why. `outputs` stays empty from this scan.
|
|
361
387
|
}
|
|
362
388
|
|
|
363
389
|
// Also scan assistant message text blocks for FORGE markers.
|
|
@@ -151,17 +151,104 @@ async function getInvariants() {
|
|
|
151
151
|
/**
|
|
152
152
|
* Allowed label transitions: from → [to, ...]
|
|
153
153
|
* A label not in this map can be added freely (not a workflow: label).
|
|
154
|
+
*
|
|
155
|
+
* `workflow:invalid` is reachable from `ready-to-build`, `building`, and
|
|
156
|
+
* `in-review` (issue #2326) — not just from `investigating` — because
|
|
157
|
+
* invalidity is not always discovered where the pipeline first labels the
|
|
158
|
+
* issue "ready to build". A cheap false positive is usually caught during
|
|
159
|
+
* investigation; a subtler one is only disproved once the architect/build
|
|
160
|
+
* phase reads the actual code and tests (see #2312: the premise wasn't
|
|
161
|
+
* disproved until Phase 3C.6 read `bin/tests/engine-crash.test.mjs`).
|
|
162
|
+
*
|
|
163
|
+
* These three post-investigation successors are NOT unconditional, though —
|
|
164
|
+
* see EVIDENCE_REQUIRED_TARGETS below and its enforcement in
|
|
165
|
+
* checkLabelTransition(). Reaching `workflow:invalid` from any of these
|
|
166
|
+
* three states requires a posted reversal comment (a second
|
|
167
|
+
* `FORGE:INVESTIGATOR` comment carrying `**Verdict**: INVALID`) already on
|
|
168
|
+
* the issue — the state machine still refuses a bare relabel with no
|
|
169
|
+
* evidence trail. This preserves the original protection (see history below)
|
|
170
|
+
* while making the terminal state reachable where it is actually discovered.
|
|
171
|
+
*
|
|
172
|
+
* History: this map was introduced by #1250/#1513 to stop arbitrary/skipped
|
|
173
|
+
* label jumps (e.g. investigating → merged in one hop) — it enforces that
|
|
174
|
+
* every workflow label transition follows the pipeline's real phase order,
|
|
175
|
+
* with corrective error messages naming the legal next states. Widening a
|
|
176
|
+
* terminal-reachability edge is safe as long as an equivalent guard (the
|
|
177
|
+
* evidence precondition) replaces the blanket "not reachable at all" rule
|
|
178
|
+
* for the specific case this hook was never asked to consider: legitimate,
|
|
179
|
+
* evidenced invalidation discovered after ready-to-build.
|
|
154
180
|
*/
|
|
155
181
|
const LABEL_TRANSITIONS = {
|
|
156
182
|
"workflow:investigating": ["workflow:ready-to-build", "workflow:invalid", "workflow:decomposed"],
|
|
157
|
-
"workflow:ready-to-build": ["workflow:building"],
|
|
158
|
-
"workflow:building": ["workflow:in-review", "workflow:ready-to-build"], // retry allowed
|
|
159
|
-
"workflow:in-review": ["workflow:merged", "workflow:building"], // review → re-build allowed
|
|
183
|
+
"workflow:ready-to-build": ["workflow:building", "workflow:invalid"],
|
|
184
|
+
"workflow:building": ["workflow:in-review", "workflow:ready-to-build", "workflow:invalid"], // retry allowed; evidenced reversal allowed (#2326)
|
|
185
|
+
"workflow:in-review": ["workflow:merged", "workflow:building", "workflow:invalid"], // review → re-build allowed; evidenced reversal allowed (#2326)
|
|
160
186
|
"workflow:merged": [], // terminal — no successors
|
|
161
187
|
"workflow:invalid": [], // terminal
|
|
162
188
|
"workflow:decomposed": [], // terminal
|
|
163
189
|
};
|
|
164
190
|
|
|
191
|
+
/**
|
|
192
|
+
* States from which a transition to `workflow:invalid` requires posted
|
|
193
|
+
* evidence (a reversal comment), rather than being freely allowed.
|
|
194
|
+
* `workflow:investigating` is deliberately excluded — invalidation
|
|
195
|
+
* discovered during initial investigation is the pipeline's normal,
|
|
196
|
+
* unguarded path (Phase 1D) and needs no additional precondition.
|
|
197
|
+
*/
|
|
198
|
+
const EVIDENCE_REQUIRED_FOR_INVALID_FROM = new Set([
|
|
199
|
+
"workflow:ready-to-build",
|
|
200
|
+
"workflow:building",
|
|
201
|
+
"workflow:in-review",
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* `authorAssociation` values (as returned by `gh issue view --json comments`,
|
|
206
|
+
* which GitHub computes server-side from the commenter's actual repo/org
|
|
207
|
+
* relationship) that are trusted to post reversal evidence. Deliberately
|
|
208
|
+
* NOT a hardcoded bot login: the pipeline's own `gh` identity legitimately
|
|
209
|
+
* rotates (issue #1722 — the primary automation account's token went invalid
|
|
210
|
+
* mid-batch and a session switched to a maintainer's personal account to keep
|
|
211
|
+
* working), and a literal-login allowlist would fail closed the next time
|
|
212
|
+
* identity rotates. Checking the GitHub-computed relationship instead of a
|
|
213
|
+
* specific account name survives that rotation: whichever account is
|
|
214
|
+
* authenticated, as long as it is an org member / repo owner / granted
|
|
215
|
+
* collaborator access, reports one of these three values regardless of which
|
|
216
|
+
* account it is. Everything else — `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR`,
|
|
217
|
+
* `NONE`, or a missing/malformed field — is untrusted and must NOT satisfy
|
|
218
|
+
* the evidence check (issue #2332: this repo is public with no interaction
|
|
219
|
+
* restrictions, so any free GitHub account can otherwise post a comment
|
|
220
|
+
* matching the marker + verdict text below).
|
|
221
|
+
*/
|
|
222
|
+
const TRUSTED_REVERSAL_AUTHOR_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A reversal comment is a `FORGE:INVESTIGATOR` annotation whose verdict is
|
|
226
|
+
* INVALID — i.e. a second investigation report that supersedes the
|
|
227
|
+
* original CONFIRMED/PARTIAL verdict with citable evidence (see #2312's
|
|
228
|
+
* "Investigation Report — CORRECTED" comment for the live example this
|
|
229
|
+
* pattern is modeled on). Matching on the marker + verdict line only (not
|
|
230
|
+
* on incident-specific text like issue numbers or the word "CORRECTED")
|
|
231
|
+
* keeps this a general-purpose evidence check, not a one-off special case.
|
|
232
|
+
*
|
|
233
|
+
* In addition to the marker + verdict text, the comment's author must carry
|
|
234
|
+
* a trusted `authorAssociation` (see TRUSTED_REVERSAL_AUTHOR_ASSOCIATIONS
|
|
235
|
+
* above, issue #2332) — otherwise any commenter, with no write access to the
|
|
236
|
+
* repository, could forge the marker text and unlock this transition.
|
|
237
|
+
*
|
|
238
|
+
* @param {Array<{body?: string, authorAssociation?: string}>} comments
|
|
239
|
+
* @returns {boolean}
|
|
240
|
+
*/
|
|
241
|
+
function hasInvalidReversalEvidence(comments) {
|
|
242
|
+
if (!Array.isArray(comments)) return false;
|
|
243
|
+
return comments.some((c) => {
|
|
244
|
+
const body = String((c && c.body) || "");
|
|
245
|
+
if (!body.includes("FORGE:INVESTIGATOR")) return false;
|
|
246
|
+
if (!/\*\*Verdict\*\*:\s*INVALID/i.test(body)) return false;
|
|
247
|
+
const association = String((c && c.authorAssociation) || "").toUpperCase();
|
|
248
|
+
return TRUSTED_REVERSAL_AUTHOR_ASSOCIATIONS.has(association);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
165
252
|
/** Labels that are never allowed as a PR --base target. */
|
|
166
253
|
const FORBIDDEN_PR_BASES = ["main", "master"];
|
|
167
254
|
|
|
@@ -180,13 +267,29 @@ const WORKFLOW_LABEL_PREFIX = "workflow:";
|
|
|
180
267
|
// ---------------------------------------------------------------------------
|
|
181
268
|
|
|
182
269
|
/**
|
|
183
|
-
* Match a `find` search-root token that
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
270
|
+
* Match a `find` search-root token that reaches the exact same whole-drive,
|
|
271
|
+
* non-terminating scan on Git Bash as the bare filesystem root (`/`) or a
|
|
272
|
+
* bare drive mount (`/c`, `/d`, ...). This includes trailing-slash drive
|
|
273
|
+
* mounts (`/c/`) — a natural way to write the same root, since `/c` and
|
|
274
|
+
* `/c/` are the identical directory — and the dot/double-slash root
|
|
275
|
+
* variants `/.`, `/..`, `//`, which likewise resolve to (or immediately
|
|
276
|
+
* above, which Git Bash still normalizes to) the same whole-drive tree
|
|
277
|
+
* (issue #2113 — PR #2112 review finding SEC-1: `find /c/` was silently
|
|
278
|
+
* ALLOWED because the prior regex had no trailing-slash/dot alternative).
|
|
279
|
+
* This also includes the trailing-slash dot forms `/./` and `/../` — the
|
|
280
|
+
* dot alternative did not originally carry the same optional trailing
|
|
281
|
+
* slash the drive-letter alternative got in #2113, so those two bare
|
|
282
|
+
* tokens reached the identical whole-drive scan but were wrongly ALLOWED
|
|
283
|
+
* (issue #2213).
|
|
284
|
+
*
|
|
285
|
+
* Deliberately does NOT match `/c/Users/...`, `/./foo`, `/../foo` (with
|
|
286
|
+
* anything beyond the trailing slash), or any other scoped absolute path —
|
|
287
|
+
* only the bare root/drive-mount token itself, optionally followed by
|
|
288
|
+
* exactly one trailing slash (drive-letter and dot/double-slash cases) or
|
|
289
|
+
* standing alone. Case-insensitive on the drive letter only (POSIX paths
|
|
290
|
+
* are otherwise case-sensitive).
|
|
188
291
|
*/
|
|
189
|
-
const FIND_ROOT_TOKEN_RE = /^\/(?:[a-zA-Z])?$/;
|
|
292
|
+
const FIND_ROOT_TOKEN_RE = /^\/(?:[a-zA-Z]\/?|\.{1,2}\/?|\/)?$/;
|
|
190
293
|
|
|
191
294
|
/**
|
|
192
295
|
* Shell metacharacters that can glue a command name to an adjacent token
|
|
@@ -493,7 +596,13 @@ function checkPrTarget(command) {
|
|
|
493
596
|
*
|
|
494
597
|
* Reads the current workflow labels from GitHub via `gh issue view` and
|
|
495
598
|
* validates that the new label is a legal successor in the state machine.
|
|
496
|
-
* Falls back to allow (exit 0) on
|
|
599
|
+
* Falls back to allow (exit 0) on a `gh` CLI error for ordinary transitions,
|
|
600
|
+
* so the hook stays resilient to transient `gh`/network failures. The one
|
|
601
|
+
* exception (#2347): when the attempted transition is to `workflow:invalid`
|
|
602
|
+
* (`mayNeedEvidence`), the same lookup also fetches the reversal-evidence
|
|
603
|
+
* comments required by the evidence gate below — a `gh` error there fails
|
|
604
|
+
* *closed* instead, so a transient CLI error can never silently bypass the
|
|
605
|
+
* evidence gate the way a blanket fail-open would.
|
|
497
606
|
*
|
|
498
607
|
* @param {string} command
|
|
499
608
|
* @returns {string|null} Error message to show, or null if allowed.
|
|
@@ -522,11 +631,18 @@ function checkLabelTransition(command) {
|
|
|
522
631
|
// Extract repo (-R flag) if present.
|
|
523
632
|
const repoFlag = extractFlag(command, "-R") || extractFlag(command, "--repo");
|
|
524
633
|
|
|
525
|
-
// Read current labels from GitHub synchronously.
|
|
634
|
+
// Read current labels from GitHub synchronously. Also fetch comments when
|
|
635
|
+
// the requested transition is one that requires reversal evidence (see
|
|
636
|
+
// EVIDENCE_REQUIRED_FOR_INVALID_FROM) — deferred to a single conditional
|
|
637
|
+
// extra field so the common case (any other transition) stays a
|
|
638
|
+
// labels-only fetch, unchanged from before #2326.
|
|
526
639
|
let currentWorkflowLabel = null;
|
|
640
|
+
let comments = null;
|
|
641
|
+
const mayNeedEvidence = newLabel === "workflow:invalid";
|
|
527
642
|
try {
|
|
528
643
|
const { execFileSync: exec } = _require("child_process");
|
|
529
|
-
const
|
|
644
|
+
const fields = mayNeedEvidence ? "labels,comments" : "labels";
|
|
645
|
+
const args = ["issue", "view", issueNum, "--json", fields];
|
|
530
646
|
if (repoFlag) { args.push("-R", repoFlag); }
|
|
531
647
|
const out = exec("gh", args, {
|
|
532
648
|
encoding: "utf-8",
|
|
@@ -543,8 +659,29 @@ function checkLabelTransition(command) {
|
|
|
543
659
|
currentWorkflowLabel = lbl;
|
|
544
660
|
}
|
|
545
661
|
}
|
|
662
|
+
if (mayNeedEvidence) {
|
|
663
|
+
comments = Array.isArray(parsed.comments) ? parsed.comments : [];
|
|
664
|
+
}
|
|
546
665
|
} catch {
|
|
547
|
-
|
|
666
|
+
if (mayNeedEvidence) {
|
|
667
|
+
// #2347: the lookup that failed is the same one that would have
|
|
668
|
+
// fetched the reversal-evidence comments for the workflow:invalid
|
|
669
|
+
// evidence gate (#2326/#2332). Fail closed here instead of silently
|
|
670
|
+
// allowing an unverified workflow:invalid transition through.
|
|
671
|
+
return [
|
|
672
|
+
`[ForgeDock] BLOCKED: Unable to verify workflow:invalid evidence (gh CLI error).`,
|
|
673
|
+
``,
|
|
674
|
+
`Attempted transition: → "workflow:invalid"`,
|
|
675
|
+
``,
|
|
676
|
+
`This transition requires verifying a posted reversal comment, but the`,
|
|
677
|
+
`"gh issue view" lookup used to fetch that evidence failed. Failing`,
|
|
678
|
+
`closed rather than silently bypassing the evidence gate.`,
|
|
679
|
+
``,
|
|
680
|
+
`This is transient, not permanent: retry once "gh issue view ${issueNum}"`,
|
|
681
|
+
`succeeds again (e.g. after a network blip or rate-limit reset).`,
|
|
682
|
+
].join("\n");
|
|
683
|
+
}
|
|
684
|
+
return null; // gh CLI error on an ordinary transition — fail-open
|
|
548
685
|
}
|
|
549
686
|
|
|
550
687
|
if (!currentWorkflowLabel) {
|
|
@@ -581,6 +718,35 @@ function checkLabelTransition(command) {
|
|
|
581
718
|
].join("\n");
|
|
582
719
|
}
|
|
583
720
|
|
|
721
|
+
// Evidence precondition (#2326): reaching workflow:invalid from
|
|
722
|
+
// ready-to-build/building/in-review is structurally allowed above, but
|
|
723
|
+
// still gated against *casual* invalidation — a bare relabel with no
|
|
724
|
+
// paper trail is blocked. Require a posted reversal comment (a second
|
|
725
|
+
// FORGE:INVESTIGATOR annotation carrying "**Verdict**: INVALID") already
|
|
726
|
+
// on the issue before allowing the transition through, from a trusted
|
|
727
|
+
// author (issue #2332 — marker text alone is forgeable by anyone who can
|
|
728
|
+
// comment on a public issue; see TRUSTED_REVERSAL_AUTHOR_ASSOCIATIONS).
|
|
729
|
+
if (newLabel === "workflow:invalid" && EVIDENCE_REQUIRED_FOR_INVALID_FROM.has(currentWorkflowLabel)) {
|
|
730
|
+
if (!hasInvalidReversalEvidence(comments)) {
|
|
731
|
+
return [
|
|
732
|
+
`[ForgeDock] BLOCKED: workflow:invalid requires reversal evidence.`,
|
|
733
|
+
``,
|
|
734
|
+
`Current workflow state: "${currentWorkflowLabel}"`,
|
|
735
|
+
`Attempted transition: → "workflow:invalid"`,
|
|
736
|
+
``,
|
|
737
|
+
`This transition is allowed only when the issue carries a posted reversal:`,
|
|
738
|
+
`a FORGE:INVESTIGATOR comment with "**Verdict**: INVALID" documenting the`,
|
|
739
|
+
`evidence that disproved the original premise (see #2312 for the pattern),`,
|
|
740
|
+
`posted by an author with a trusted authorAssociation (OWNER, MEMBER, or`,
|
|
741
|
+
`COLLABORATOR — see #2332). A comment matching the marker text from any`,
|
|
742
|
+
`other commenter does not satisfy this gate.`,
|
|
743
|
+
``,
|
|
744
|
+
`Fix: post the corrected investigation report first (verdict INVALID, with`,
|
|
745
|
+
`evidence, from a trusted account), then retry this label transition.`,
|
|
746
|
+
].join("\n");
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
584
750
|
return null; // valid transition — allow
|
|
585
751
|
}
|
|
586
752
|
|
|
@@ -682,12 +848,16 @@ function extractLogicalTokens(command) {
|
|
|
682
848
|
|
|
683
849
|
/**
|
|
684
850
|
* Check whether a Bash command contains a `find` invocation whose search
|
|
685
|
-
* root is the filesystem root (`/`)
|
|
686
|
-
* `/d`, ...)
|
|
687
|
-
* root
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
851
|
+
* root is the filesystem root (`/`), a bare Git-Bash drive mount (`/c`,
|
|
852
|
+
* `/d`, ...), a trailing-slash drive mount (`/c/`), or a dot/double-slash
|
|
853
|
+
* root variant (`/.`, `/..`, `//`). On Windows Git Bash, `/` spans every
|
|
854
|
+
* mounted drive, so a root-anchored `find` never terminates and accumulates
|
|
855
|
+
* as an orphaned, CPU-exhausting process (issue #2034 — #1984 was a narrow
|
|
856
|
+
* precursor fix that only patched a single call site + added a prose
|
|
857
|
+
* guardrail; this is the systemic, deterministic follow-up. Issue #2113
|
|
858
|
+
* widened the root-token match to also cover the trailing-slash/dot
|
|
859
|
+
* variants, which reach the identical whole-drive scan but were not
|
|
860
|
+
* originally matched by `FIND_ROOT_TOKEN_RE`).
|
|
691
861
|
*
|
|
692
862
|
* Scans ALL logical tokens for a `find` occurrence (not just the first word
|
|
693
863
|
* of the command), so it catches `find` appearing after `&&`, `;`, `|`, a
|
package/bin/journey.mjs
CHANGED
|
@@ -747,7 +747,8 @@ export async function preflight(ctx) {
|
|
|
747
747
|
// Act II — Forging: command symlinks + SessionStart hook (Task 6)
|
|
748
748
|
// ---------------------------------------------------------------------------
|
|
749
749
|
|
|
750
|
-
import { mkdir, symlink, readlink, lstat, readdir, rename, copyFile, readFile, writeFile, unlink } from "fs/promises";
|
|
750
|
+
import { mkdir, symlink, readlink, lstat, readdir, rename, copyFile, readFile, writeFile, unlink, rm } from "fs/promises";
|
|
751
|
+
import { compareVersions } from "./registry.mjs";
|
|
751
752
|
import { relative, dirname as pathDirname } from "path";
|
|
752
753
|
import {
|
|
753
754
|
installSessionStartHook,
|
|
@@ -1073,6 +1074,60 @@ async function copyDirIfChanged(srcDir, destDir) {
|
|
|
1073
1074
|
return { copied, unchanged };
|
|
1074
1075
|
}
|
|
1075
1076
|
|
|
1077
|
+
/**
|
|
1078
|
+
* Recursively delete entries in `destDir` that no longer exist in `srcDir`.
|
|
1079
|
+
* The mirror-image of copyDirIfChanged(): that function is additive-only (it
|
|
1080
|
+
* copies new/changed files but never removes anything), so a file dropped or
|
|
1081
|
+
* renamed upstream would otherwise linger in the persisted `~/.forge/` copy
|
|
1082
|
+
* forever (forge#2133). A missing `srcDir` or `destDir` is treated as
|
|
1083
|
+
* "nothing to reconcile" rather than an error — mirrors copyDirIfChanged()'s
|
|
1084
|
+
* own ENOENT handling.
|
|
1085
|
+
*
|
|
1086
|
+
* @param {string} srcDir
|
|
1087
|
+
* @param {string} destDir
|
|
1088
|
+
* @returns {Promise<{ removed: number }>}
|
|
1089
|
+
*/
|
|
1090
|
+
async function removeOrphans(srcDir, destDir) {
|
|
1091
|
+
let destEntries;
|
|
1092
|
+
try {
|
|
1093
|
+
destEntries = await readdir(destDir, { withFileTypes: true });
|
|
1094
|
+
} catch (err) {
|
|
1095
|
+
if (err.code === "ENOENT") return { removed: 0 };
|
|
1096
|
+
throw err;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
let srcNames = null;
|
|
1100
|
+
try {
|
|
1101
|
+
const srcEntries = await readdir(srcDir, { withFileTypes: true });
|
|
1102
|
+
srcNames = new Set(srcEntries.map((e) => e.name));
|
|
1103
|
+
} catch (err) {
|
|
1104
|
+
if (err.code !== "ENOENT" && err.code !== "ENOTDIR") throw err;
|
|
1105
|
+
// srcDir missing entirely (ENOENT), or the corresponding source path changed
|
|
1106
|
+
// type and is no longer a directory — e.g. a symlink-to-file replacing what
|
|
1107
|
+
// used to be a real directory (ENOTDIR) — every dest entry is an orphan.
|
|
1108
|
+
// Treating both the same way keeps this a per-path fail-open instead of
|
|
1109
|
+
// letting an uncaught ENOTDIR escape to persistHome()'s single outer catch,
|
|
1110
|
+
// which would otherwise silently skip the *entire* persist for one bad path
|
|
1111
|
+
// (forge#2227).
|
|
1112
|
+
srcNames = new Set();
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
let removed = 0;
|
|
1116
|
+
for (const entry of destEntries) {
|
|
1117
|
+
const destPath = join(destDir, entry.name);
|
|
1118
|
+
if (!srcNames.has(entry.name)) {
|
|
1119
|
+
await rm(destPath, { recursive: true, force: true });
|
|
1120
|
+
removed++;
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
if (entry.isDirectory()) {
|
|
1124
|
+
const sub = await removeOrphans(join(srcDir, entry.name), destPath);
|
|
1125
|
+
removed += sub.removed;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
return { removed };
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1076
1131
|
/**
|
|
1077
1132
|
* Copy ForgeDock's own installable payload (bin/, commands/, scripts/,
|
|
1078
1133
|
* templates/) from wherever the package currently resolved — npm global
|
|
@@ -1115,6 +1170,7 @@ async function copyDirIfChanged(srcDir, destDir) {
|
|
|
1115
1170
|
* version: string,
|
|
1116
1171
|
* filesCopied?: number,
|
|
1117
1172
|
* filesUnchanged?: number,
|
|
1173
|
+
* filesRemoved?: number,
|
|
1118
1174
|
* }>}
|
|
1119
1175
|
*/
|
|
1120
1176
|
export async function persistHome(ctx) {
|
|
@@ -1142,15 +1198,55 @@ export async function persistHome(ctx) {
|
|
|
1142
1198
|
// proceed with version === ""
|
|
1143
1199
|
}
|
|
1144
1200
|
|
|
1201
|
+
// Downgrade guard (forge#2133): if a newer version is already persisted at
|
|
1202
|
+
// ~/.forge/version than the source package we're about to copy from, skip
|
|
1203
|
+
// the copy entirely rather than silently overwriting newer files with
|
|
1204
|
+
// older ones. This happens when a stale resolved package (e.g. a plain
|
|
1205
|
+
// `npx forgedock update` that resolved an old global install) runs after
|
|
1206
|
+
// ~/.forge/ was already refreshed to a newer version by some other path
|
|
1207
|
+
// (e.g. `npx forgedock@latest`). Both `version` and the persisted value
|
|
1208
|
+
// must be non-empty for the guard to apply — an unreadable/missing
|
|
1209
|
+
// ~/.forge/version is treated as "no guard needed" so first-run persist
|
|
1210
|
+
// is never blocked.
|
|
1211
|
+
const persistedVersionPath = join(persistedHome, "version");
|
|
1212
|
+
let existingPersistedVersion = "";
|
|
1213
|
+
let persistedVersionFileExists = true;
|
|
1214
|
+
try {
|
|
1215
|
+
existingPersistedVersion = readFileSync(persistedVersionPath, "utf-8").trim();
|
|
1216
|
+
} catch {
|
|
1217
|
+
// missing/unreadable — proceed, nothing to guard against. Tracked
|
|
1218
|
+
// separately from existingPersistedVersion's "" default so the
|
|
1219
|
+
// versionChanged check below (a fresh persist with no prior version
|
|
1220
|
+
// file) is still correctly treated as "changed" even when the source
|
|
1221
|
+
// package's own version also resolves to "" (e.g. missing package.json).
|
|
1222
|
+
persistedVersionFileExists = false;
|
|
1223
|
+
}
|
|
1224
|
+
if (
|
|
1225
|
+
version &&
|
|
1226
|
+
existingPersistedVersion &&
|
|
1227
|
+
compareVersions(version, existingPersistedVersion) < 0
|
|
1228
|
+
) {
|
|
1229
|
+
return {
|
|
1230
|
+
forgeHome: persistedHome,
|
|
1231
|
+
migrated: false,
|
|
1232
|
+
skipped: true,
|
|
1233
|
+
reason: `refusing to downgrade ~/.forge/ from v${existingPersistedVersion} to v${version} — source package is older than what's already persisted`,
|
|
1234
|
+
version: existingPersistedVersion,
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1145
1238
|
try {
|
|
1146
1239
|
await mkdir(persistedHome, { recursive: true });
|
|
1147
1240
|
|
|
1148
1241
|
let filesCopied = 0;
|
|
1149
1242
|
let filesUnchanged = 0;
|
|
1243
|
+
let filesRemoved = 0;
|
|
1150
1244
|
for (const name of PERSIST_HOME_DIRS) {
|
|
1151
1245
|
const res = await copyDirIfChanged(join(source, name), join(persistedHome, name));
|
|
1152
1246
|
filesCopied += res.copied;
|
|
1153
1247
|
filesUnchanged += res.unchanged;
|
|
1248
|
+
const pruned = await removeOrphans(join(source, name), join(persistedHome, name));
|
|
1249
|
+
filesRemoved += pruned.removed;
|
|
1154
1250
|
}
|
|
1155
1251
|
|
|
1156
1252
|
// Also persist package.json itself (not just PERSIST_HOME_DIRS) — several
|
|
@@ -1181,18 +1277,19 @@ export async function persistHome(ctx) {
|
|
|
1181
1277
|
|
|
1182
1278
|
// Write ~/.forge/version — content-compared like everything else here so
|
|
1183
1279
|
// an unchanged version doesn't touch the file's mtime on every re-run.
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1280
|
+
// Reuses persistedVersionPath/existingPersistedVersion read above for the
|
|
1281
|
+
// downgrade guard rather than re-reading the same file a second time. A
|
|
1282
|
+
// version file that didn't exist yet (persistedVersionFileExists: false)
|
|
1283
|
+
// always counts as "changed", regardless of whether the source's own
|
|
1284
|
+
// version happens to also be "" (e.g. missing package.json) — otherwise
|
|
1285
|
+
// "" !== "" would wrongly read as unchanged on a fresh, never-persisted
|
|
1286
|
+
// ~/.forge/.
|
|
1287
|
+
let versionChanged = !persistedVersionFileExists || existingPersistedVersion !== version;
|
|
1191
1288
|
if (versionChanged) {
|
|
1192
|
-
const tmpVersionPath =
|
|
1289
|
+
const tmpVersionPath = persistedVersionPath + ".tmp";
|
|
1193
1290
|
try {
|
|
1194
1291
|
writeFileSync(tmpVersionPath, version + "\n", "utf-8");
|
|
1195
|
-
renameSync(tmpVersionPath,
|
|
1292
|
+
renameSync(tmpVersionPath, persistedVersionPath);
|
|
1196
1293
|
} catch (err) {
|
|
1197
1294
|
try { unlinkSync(tmpVersionPath); } catch { /* best-effort cleanup */ }
|
|
1198
1295
|
throw err;
|
|
@@ -1201,11 +1298,12 @@ export async function persistHome(ctx) {
|
|
|
1201
1298
|
|
|
1202
1299
|
return {
|
|
1203
1300
|
forgeHome: persistedHome,
|
|
1204
|
-
migrated: filesCopied > 0 || versionChanged,
|
|
1301
|
+
migrated: filesCopied > 0 || filesRemoved > 0 || versionChanged,
|
|
1205
1302
|
skipped: false,
|
|
1206
1303
|
version,
|
|
1207
1304
|
filesCopied,
|
|
1208
1305
|
filesUnchanged,
|
|
1306
|
+
filesRemoved,
|
|
1209
1307
|
};
|
|
1210
1308
|
} catch (err) {
|
|
1211
1309
|
// Fail-open (forge#383): a permission error, disk-full, etc. must never
|
package/bin/labels.json
CHANGED
|
@@ -64,6 +64,11 @@
|
|
|
64
64
|
"color": "E4E669",
|
|
65
65
|
"description": "Pipeline stalled — requires human intervention. Managed by ForgeDock."
|
|
66
66
|
},
|
|
67
|
+
{
|
|
68
|
+
"name": "workflow:engine-error",
|
|
69
|
+
"color": "B60205",
|
|
70
|
+
"description": "Pipeline stalled — engine/tool failure (not a genuine human-judgment block). Managed by ForgeDock."
|
|
71
|
+
},
|
|
67
72
|
{
|
|
68
73
|
"name": "blocked-on-human-merge",
|
|
69
74
|
"color": "006B75",
|