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.
Files changed (78) hide show
  1. package/README.md +8 -2
  2. package/bin/batch-runner.mjs +31 -2
  3. package/bin/engine/admission.mjs +294 -0
  4. package/bin/engine/admission.test.mjs +265 -0
  5. package/bin/engine/invariants.mjs +17 -4
  6. package/bin/engine/phases.mjs +358 -42
  7. package/bin/engine/resolve.mjs +256 -0
  8. package/bin/engine/resolve.test.mjs +149 -0
  9. package/bin/engine/state.mjs +31 -7
  10. package/bin/engine-cli.mjs +138 -2
  11. package/bin/engine.mjs +414 -21
  12. package/bin/forgedock.mjs +552 -44
  13. package/bin/hooks/interactive-engine.mjs +50 -24
  14. package/bin/hooks/pre-tool-use.mjs +189 -19
  15. package/bin/journey.mjs +109 -11
  16. package/bin/labels.json +5 -0
  17. package/bin/runner.mjs +321 -10
  18. package/bin/watch-utils.mjs +255 -0
  19. package/commands/cleanup.md +7 -3
  20. package/commands/forge-stats.md +5 -4
  21. package/commands/issue.md +5 -1
  22. package/commands/orchestrate/config.md +41 -1
  23. package/commands/orchestrate/phase-1-resolve.md +234 -8
  24. package/commands/orchestrate/phase-3-dependency.md +27 -18
  25. package/commands/orchestrate/phase-4-execution.md +223 -22
  26. package/commands/qa-sweep.md +17 -0
  27. package/commands/quality-gate.md +6 -6
  28. package/commands/replay.md +16 -2
  29. package/commands/review-pr-agents/spec-cli.md +179 -0
  30. package/commands/review-pr.md +65 -0
  31. package/commands/upgrade-deps.md +523 -0
  32. package/commands/work-on/build/validate.md +61 -1
  33. package/commands/work-on/build.md +9 -1
  34. package/commands/work-on/decompose.md +10 -1
  35. package/commands/work-on/investigate.md +22 -4
  36. package/commands/work-on/remediate.md +2 -0
  37. package/commands/work-on/review.md +40 -1
  38. package/commands/work-on.md +19 -4
  39. package/docs/AIDER.md +264 -0
  40. package/docs/AUTOPILOT-CRON.md +130 -0
  41. package/docs/CONFIG.md +156 -0
  42. package/docs/CURSOR.md +135 -0
  43. package/docs/OPENCODE.md +312 -0
  44. package/docs/demo.gif +0 -0
  45. package/docs/eval/README.md +4 -4
  46. package/docs/launch/show-hn.md +3 -3
  47. package/docs/site/architecture.md +180 -0
  48. package/docs/site/command-reference.md +17 -0
  49. package/docs/site/configuration.md +243 -0
  50. package/docs/site/for-companies.md +4 -4
  51. package/docs/site/forge-annotation-protocol.md +1 -1
  52. package/docs/site/github-is-the-memory.md +2 -2
  53. package/docs/site/how-it-works.md +1 -1
  54. package/docs/site/public/llms-full.txt +380 -0
  55. package/docs/site/public/llms.txt +35 -0
  56. package/docs/spec/eval-run-result.md +8 -4
  57. package/docs/spec/label-state-machine.md +9 -0
  58. package/docs/spec-graph-schema.md +3 -3
  59. package/package.json +3 -3
  60. package/packages/protocol/README.md +75 -0
  61. package/packages/protocol/src/card.js +114 -0
  62. package/packages/protocol/src/cli.js +4 -77
  63. package/packages/protocol/src/emit.js +5 -11
  64. package/packages/protocol/src/html-comment-escape.js +68 -0
  65. package/packages/protocol/src/index.js +3 -1
  66. package/packages/protocol/src/parse.js +19 -11
  67. package/packages/protocol/src/phases.js +105 -0
  68. package/packages/protocol/src/types.js +18 -1
  69. package/packages/protocol/src/validate.js +11 -0
  70. package/scripts/check-admission-parity.mjs +167 -0
  71. package/scripts/check-command-side-effects.sh +376 -0
  72. package/scripts/check-phase-registry-drift.mjs +96 -0
  73. package/scripts/check-spec-markers.sh +274 -0
  74. package/scripts/classify-lane.sh +29 -7
  75. package/scripts/forge-run.sh +353 -0
  76. package/templates/cross-repo-trigger.yml +282 -0
  77. package/templates/cursor/forge.mdc +148 -0
  78. 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 → 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
 
@@ -180,13 +267,29 @@ const WORKFLOW_LABEL_PREFIX = "workflow:";
180
267
  // ---------------------------------------------------------------------------
181
268
 
182
269
  /**
183
- * Match a `find` search-root token that is exactly the filesystem root (`/`)
184
- * or a bare Git-Bash drive mount (`/c`, `/d`, `/C`, ... — a single letter,
185
- * case-insensitive, optionally followed by nothing else). Deliberately does
186
- * NOT match `/c/Users/...` or any other scoped absolute path — only the bare
187
- * root/drive-mount token itself.
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 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.
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 args = ["issue", "view", issueNum, "--json", "labels"];
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
- 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
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 (`/`) or a bare Git-Bash drive mount (`/c`,
686
- * `/d`, ...). On Windows Git Bash, `/` spans every mounted drive, so a
687
- * root-anchored `find` never terminates and accumulates as an orphaned,
688
- * CPU-exhausting process (issue #2034 — #1984 was a narrow precursor fix
689
- * that only patched a single call site + added a prose guardrail; this is
690
- * the systemic, deterministic follow-up).
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
- const versionPath = join(persistedHome, "version");
1185
- let versionChanged = true;
1186
- try {
1187
- versionChanged = readFileSync(versionPath, "utf-8").trim() !== version;
1188
- } catch {
1189
- // missing/unreadable — treat as changed
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 = versionPath + ".tmp";
1289
+ const tmpVersionPath = persistedVersionPath + ".tmp";
1193
1290
  try {
1194
1291
  writeFileSync(tmpVersionPath, version + "\n", "utf-8");
1195
- renameSync(tmpVersionPath, versionPath);
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",