forgedock 1.3.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/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 +201 -35
- package/bin/engine/resolve.mjs +256 -0
- package/bin/engine/resolve.test.mjs +149 -0
- package/bin/engine/state.mjs +20 -28
- package/bin/engine-cli.mjs +80 -5
- package/bin/engine.mjs +390 -18
- package/bin/forgedock.mjs +336 -59
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +169 -13
- package/bin/journey.mjs +8 -2
- 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/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 +221 -22
- package/commands/quality-gate.md +6 -6
- package/commands/review-pr.md +23 -7
- package/commands/upgrade-deps.md +7 -2
- package/commands/work-on/decompose.md +2 -0
- 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/CONFIG.md +120 -0
- package/docs/spec/eval-run-result.md +8 -4
- package/docs/spec/label-state-machine.md +9 -0
- package/package.json +2 -2
- package/packages/protocol/src/emit.js +5 -22
- package/packages/protocol/src/html-comment-escape.js +68 -0
- package/packages/protocol/src/index.js +1 -0
- package/packages/protocol/src/parse.js +5 -6
- package/packages/protocol/src/phases.js +105 -0
- package/packages/protocol/src/types.js +17 -0
- package/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +181 -66
- package/scripts/check-phase-registry-drift.mjs +96 -0
- package/scripts/check-spec-markers.sh +1 -0
- package/scripts/forge-run.sh +35 -4
|
@@ -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
|
|
|
@@ -189,14 +276,20 @@ const WORKFLOW_LABEL_PREFIX = "workflow:";
|
|
|
189
276
|
* above, which Git Bash still normalizes to) the same whole-drive tree
|
|
190
277
|
* (issue #2113 — PR #2112 review finding SEC-1: `find /c/` was silently
|
|
191
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).
|
|
192
284
|
*
|
|
193
|
-
* Deliberately does NOT match `/c/Users/...`, `/./foo`, `/../foo
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* letter only (POSIX paths
|
|
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).
|
|
198
291
|
*/
|
|
199
|
-
const FIND_ROOT_TOKEN_RE = /^\/(?:[a-zA-Z]\/?|\.{1,2}
|
|
292
|
+
const FIND_ROOT_TOKEN_RE = /^\/(?:[a-zA-Z]\/?|\.{1,2}\/?|\/)?$/;
|
|
200
293
|
|
|
201
294
|
/**
|
|
202
295
|
* Shell metacharacters that can glue a command name to an adjacent token
|
|
@@ -503,7 +596,13 @@ function checkPrTarget(command) {
|
|
|
503
596
|
*
|
|
504
597
|
* Reads the current workflow labels from GitHub via `gh issue view` and
|
|
505
598
|
* validates that the new label is a legal successor in the state machine.
|
|
506
|
-
* 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.
|
|
507
606
|
*
|
|
508
607
|
* @param {string} command
|
|
509
608
|
* @returns {string|null} Error message to show, or null if allowed.
|
|
@@ -532,11 +631,18 @@ function checkLabelTransition(command) {
|
|
|
532
631
|
// Extract repo (-R flag) if present.
|
|
533
632
|
const repoFlag = extractFlag(command, "-R") || extractFlag(command, "--repo");
|
|
534
633
|
|
|
535
|
-
// 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.
|
|
536
639
|
let currentWorkflowLabel = null;
|
|
640
|
+
let comments = null;
|
|
641
|
+
const mayNeedEvidence = newLabel === "workflow:invalid";
|
|
537
642
|
try {
|
|
538
643
|
const { execFileSync: exec } = _require("child_process");
|
|
539
|
-
const
|
|
644
|
+
const fields = mayNeedEvidence ? "labels,comments" : "labels";
|
|
645
|
+
const args = ["issue", "view", issueNum, "--json", fields];
|
|
540
646
|
if (repoFlag) { args.push("-R", repoFlag); }
|
|
541
647
|
const out = exec("gh", args, {
|
|
542
648
|
encoding: "utf-8",
|
|
@@ -553,8 +659,29 @@ function checkLabelTransition(command) {
|
|
|
553
659
|
currentWorkflowLabel = lbl;
|
|
554
660
|
}
|
|
555
661
|
}
|
|
662
|
+
if (mayNeedEvidence) {
|
|
663
|
+
comments = Array.isArray(parsed.comments) ? parsed.comments : [];
|
|
664
|
+
}
|
|
556
665
|
} catch {
|
|
557
|
-
|
|
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
|
|
558
685
|
}
|
|
559
686
|
|
|
560
687
|
if (!currentWorkflowLabel) {
|
|
@@ -591,6 +718,35 @@ function checkLabelTransition(command) {
|
|
|
591
718
|
].join("\n");
|
|
592
719
|
}
|
|
593
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
|
+
|
|
594
750
|
return null; // valid transition — allow
|
|
595
751
|
}
|
|
596
752
|
|
package/bin/journey.mjs
CHANGED
|
@@ -1101,8 +1101,14 @@ async function removeOrphans(srcDir, destDir) {
|
|
|
1101
1101
|
const srcEntries = await readdir(srcDir, { withFileTypes: true });
|
|
1102
1102
|
srcNames = new Set(srcEntries.map((e) => e.name));
|
|
1103
1103
|
} catch (err) {
|
|
1104
|
-
if (err.code !== "ENOENT") throw err;
|
|
1105
|
-
// srcDir missing entirely
|
|
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).
|
|
1106
1112
|
srcNames = new Set();
|
|
1107
1113
|
}
|
|
1108
1114
|
|
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",
|