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.
Files changed (45) hide show
  1. package/bin/batch-runner.mjs +31 -2
  2. package/bin/engine/admission.mjs +294 -0
  3. package/bin/engine/admission.test.mjs +265 -0
  4. package/bin/engine/invariants.mjs +17 -4
  5. package/bin/engine/phases.mjs +201 -35
  6. package/bin/engine/resolve.mjs +256 -0
  7. package/bin/engine/resolve.test.mjs +149 -0
  8. package/bin/engine/state.mjs +20 -28
  9. package/bin/engine-cli.mjs +80 -5
  10. package/bin/engine.mjs +390 -18
  11. package/bin/forgedock.mjs +336 -59
  12. package/bin/hooks/interactive-engine.mjs +50 -24
  13. package/bin/hooks/pre-tool-use.mjs +169 -13
  14. package/bin/journey.mjs +8 -2
  15. package/bin/labels.json +5 -0
  16. package/bin/runner.mjs +321 -10
  17. package/bin/watch-utils.mjs +255 -0
  18. package/commands/cleanup.md +7 -3
  19. package/commands/orchestrate/config.md +41 -1
  20. package/commands/orchestrate/phase-1-resolve.md +234 -8
  21. package/commands/orchestrate/phase-3-dependency.md +27 -18
  22. package/commands/orchestrate/phase-4-execution.md +221 -22
  23. package/commands/quality-gate.md +6 -6
  24. package/commands/review-pr.md +23 -7
  25. package/commands/upgrade-deps.md +7 -2
  26. package/commands/work-on/decompose.md +2 -0
  27. package/commands/work-on/investigate.md +22 -4
  28. package/commands/work-on/remediate.md +2 -0
  29. package/commands/work-on/review.md +40 -1
  30. package/commands/work-on.md +19 -4
  31. package/docs/CONFIG.md +120 -0
  32. package/docs/spec/eval-run-result.md +8 -4
  33. package/docs/spec/label-state-machine.md +9 -0
  34. package/package.json +2 -2
  35. package/packages/protocol/src/emit.js +5 -22
  36. package/packages/protocol/src/html-comment-escape.js +68 -0
  37. package/packages/protocol/src/index.js +1 -0
  38. package/packages/protocol/src/parse.js +5 -6
  39. package/packages/protocol/src/phases.js +105 -0
  40. package/packages/protocol/src/types.js +17 -0
  41. package/scripts/check-admission-parity.mjs +167 -0
  42. package/scripts/check-command-side-effects.sh +181 -66
  43. package/scripts/check-phase-registry-drift.mjs +96 -0
  44. package/scripts/check-spec-markers.sh +1 -0
  45. 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 → phase "investigate" committed
40
- * FORGE:CONTEXT → phase "context" committed
41
- * FORGE:ARCHITECT → phase "architect" committed
42
- * FORGE:BUILDER:COMPLETE → phase "build" committed
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: "INVESTIGATION:COMPLETE", phase: "investigate" },
81
- { marker: "INVESTIGATION:INVALID", phase: "investigate", terminal: true, terminalReason: "invalid" },
82
- { marker: "DECOMPOSE:YES", phase: "investigate", terminal: true, terminalReason: "decomposed" },
83
- { marker: "FORGE:CONTEXT", phase: "context" },
84
- { marker: "FORGE:ARCHITECT", phase: "architect" },
85
- { marker: "FORGE:BUILDER:COMPLETE", phase: "build" },
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: "FORGE:REVIEWER:MERGED", phase: "review" },
101
+ { marker: PHASE_MARKER_REGISTRY.review.completionMarker, phase: "review" },
88
102
  // close phase: issue closed with workflow:merged
89
- { marker: "workflow:merged", phase: "close", terminal: true, terminalReason: "merged" },
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: "INVESTIGATION:COMPLETE (or INVESTIGATION:INVALID / DECOMPOSE:YES)",
169
- context: "FORGE:CONTEXT",
170
- architect: "FORGE:ARCHITECT",
171
- build: "FORGE:BUILDER:COMPLETE",
172
- review: "FORGE:REVIEWER:MERGED",
173
- close: "workflow:merged label",
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
- // Extract PR number if present.
356
- const prM = content.match(/"number"\s*:\s*(\d+)/);
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`, or any
194
- * other scoped absolute path — only the bare root/drive-mount token itself,
195
- * optionally followed by exactly one trailing slash (drive-letter case) or
196
- * standing alone (dot/double-slash case). Case-insensitive on the drive
197
- * letter only (POSIX paths are otherwise case-sensitive).
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 any gh CLI error so the hook is fail-open.
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 args = ["issue", "view", issueNum, "--json", "labels"];
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
- return null; // gh CLI error — fail-open
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 — every dest entry is an orphan.
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",