forgedock 1.3.0 → 1.5.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 (58) 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/runlog.mjs +11 -1
  9. package/bin/engine/state.mjs +20 -28
  10. package/bin/engine-cli.mjs +86 -5
  11. package/bin/engine.mjs +543 -36
  12. package/bin/forgedock.mjs +397 -59
  13. package/bin/hooks/interactive-engine.mjs +50 -24
  14. package/bin/hooks/pre-tool-use.mjs +191 -13
  15. package/bin/journey.mjs +586 -44
  16. package/bin/labels.json +5 -0
  17. package/bin/runner.mjs +478 -10
  18. package/bin/watch-utils.mjs +258 -0
  19. package/commands/cleanup.md +34 -5
  20. package/commands/issue.md +14 -4
  21. package/commands/orchestrate/config.md +43 -1
  22. package/commands/orchestrate/phase-1-resolve.md +348 -11
  23. package/commands/orchestrate/phase-3-dependency.md +272 -49
  24. package/commands/orchestrate/phase-4-execution.md +622 -60
  25. package/commands/orchestrate/phase-5-cleanup.md +2 -0
  26. package/commands/quality-gate.md +6 -6
  27. package/commands/review-pr-staging.md +51 -8
  28. package/commands/review-pr.md +120 -39
  29. package/commands/upgrade-deps.md +7 -2
  30. package/commands/work-on/decompose.md +2 -0
  31. package/commands/work-on/investigate.md +22 -4
  32. package/commands/work-on/remediate.md +55 -4
  33. package/commands/work-on/review.md +40 -1
  34. package/commands/work-on.md +19 -4
  35. package/docs/CONFIG.md +120 -0
  36. package/docs/spec/eval-run-result.md +8 -4
  37. package/docs/spec/label-state-machine.md +9 -0
  38. package/package.json +2 -2
  39. package/packages/protocol/src/emit.js +5 -22
  40. package/packages/protocol/src/html-comment-escape.js +68 -0
  41. package/packages/protocol/src/index.js +1 -0
  42. package/packages/protocol/src/parse.js +5 -6
  43. package/packages/protocol/src/phases.js +105 -0
  44. package/packages/protocol/src/types.js +17 -0
  45. package/packages/protocol/src/validate.js +24 -1
  46. package/scripts/check-admission-parity.mjs +167 -0
  47. package/scripts/check-command-side-effects.sh +181 -66
  48. package/scripts/check-phase-registry-drift.mjs +118 -0
  49. package/scripts/check-spec-markers.sh +3 -0
  50. package/scripts/classify-lane.sh +29 -3
  51. package/scripts/derive-finding-milestone.sh +184 -0
  52. package/scripts/doctor-pipeline-state.sh +1 -0
  53. package/scripts/extract-affected-files.sh +232 -0
  54. package/scripts/extract-affected-files.test.sh +331 -0
  55. package/scripts/forge-run.sh +39 -4
  56. package/scripts/issue-dedup.sh +69 -8
  57. package/scripts/issue-dedup.test.sh +142 -0
  58. package/scripts/severity-to-priority.sh +81 -0
@@ -47,10 +47,49 @@ gh pr list {GH_FLAG} --head {BRANCH} --json number,state,url 2>/dev/null
47
47
  ```
48
48
 
49
49
  **Resume check**:
50
- - If PR already exists AND is OPEN → skip to Phase R3 (invoke /review-pr)
50
+ - If PR already exists AND is OPEN → run the **HEAD-unchanged re-review guard** below before proceeding to Phase R3
51
51
  - If PR already exists AND is MERGED → return `REVIEW_RESULT: status: ALREADY_MERGED`
52
52
  - If no `<!-- FORGE:BUILDER -->` comment exists → EXIT with `REVIEW_RESULT: status: BLOCKED`, blocker: "FORGE:BUILDER comment not found — implement phase may not have completed"
53
53
 
54
+ **HEAD-unchanged re-review guard** (MANDATORY when a PR already exists and is OPEN) <!-- Added: forge#2243 --> — a PR whose most recent review verdict was CHANGES REQUESTED must not be resubmitted for a full domain-agent review fan-out if nothing has changed since that verdict. `/review-pr` records the exact commit it reviewed in its verdict comment (`CHANGES REQUESTED: commit {sha} — ...`, see `commands/review-pr.md` Phase 8/9); compare that recorded sha against the PR's current `headRefOid`:
55
+
56
+ ```bash
57
+ # NOTE: the CHANGES REQUESTED verdict is posted via `gh pr review --comment` (see
58
+ # commands/review-pr.md Phase 7B) — that creates a PullRequestReview, which surfaces under
59
+ # the `reviews` field, NOT `comments` (issue/PR comments are a separate GraphQL object).
60
+ # Query both --json reviews and --json comments (same combined-read pattern already used in
61
+ # work-on.md's REVIEW_BODIES/REVIEW_PRESENT checks) so this guard works regardless of which
62
+ # GitHub object type carries the verdict text.
63
+ LAST_VERDICT=$(gh pr view {PR_NUMBER} {GH_FLAG} --json reviews,comments \
64
+ --jq '([.reviews[] | {body, created_at: (.submittedAt // "")}] + [.comments[] | {body, created_at: (.createdAt // "")}]) | map(select(.body | test("CHANGES REQUESTED: commit "))) | sort_by(.created_at) | last | .body // ""' 2>/dev/null)
65
+ LAST_VERDICT_SHA=$(echo "$LAST_VERDICT" | grep -oE 'CHANGES REQUESTED: commit [0-9a-f]+' | grep -oE '[0-9a-f]+$' | head -1)
66
+ CURRENT_SHA_SHORT=$(gh pr view {PR_NUMBER} {GH_FLAG} --json headRefOid --jq '.headRefOid' 2>/dev/null | cut -c1-7)
67
+
68
+ if [ -n "$LAST_VERDICT_SHA" ] && [ "$LAST_VERDICT_SHA" = "$CURRENT_SHA_SHORT" ]; then
69
+ echo "HEAD unchanged since last CHANGES REQUESTED verdict ($LAST_VERDICT_SHA) — skipping re-review."
70
+ # No DRY_RUN/governor guard here — consistent with every other gh issue comment/edit call
71
+ # already in this file (e.g. the Push Failed / Push Blocked sections below), none of which
72
+ # are gated either. This is a report-and-stop action (blocks re-review, does not merge or
73
+ # delete anything), same risk class as those pre-existing calls.
74
+ REREVIEW_SKIP_BODY=$(cat <<SKIP_EOF
75
+ ## Re-Review Skipped — HEAD Unchanged
76
+
77
+ PR #{PR_NUMBER}'s HEAD (${CURRENT_SHA_SHORT}) has not changed since the last CHANGES REQUESTED verdict. Re-running /review-pr would re-review byte-identical code and reproduce the same verdict — this is a pure waste of a full domain-agent fan-out.
78
+
79
+ The PR is already needs-human (or will be shortly, if this is the first time this guard has fired for it). Progress here now depends on remediation (fix the findings, push a new commit, re-review) rather than another raw review submission. If running under /orchestrate, item 6.4 in phase-4-execution.md auto-dispatches remediation for any needs-human-gated issue, including this one (see forge#2243) — no manual action should be needed. If running standalone, invoke /work-on {PR_NUMBER} --remediate --issue {NUMBER} directly.
80
+
81
+ <!-- FORGE:REREVIEW_SKIPPED -->
82
+ SKIP_EOF
83
+ )
84
+ gh issue comment {NUMBER} {GH_FLAG} --body "$REREVIEW_SKIP_BODY" # <!-- allowlist:check-command-side-effects -->
85
+ gh issue edit {NUMBER} {GH_FLAG} --add-label needs-human 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
86
+ # Return REVIEW_RESULT: status: BLOCKED — do not invoke /review-pr again on unchanged HEAD
87
+ exit 1
88
+ fi
89
+ ```
90
+
91
+ If `LAST_VERDICT_SHA` is empty (no prior CHANGES REQUESTED verdict found) or differs from the current HEAD (a new commit was pushed since the last verdict — e.g. by remediation), proceed normally to Phase R3.
92
+
54
93
  ---
55
94
 
56
95
  ## Phase R1: Pre-Push Ancestry Guard
@@ -247,6 +247,8 @@ Extract project prefix and issue number. If `next`/`pick`: list open issues sort
247
247
 
248
248
  ### 0A.1: Remediation Mode Detection (`--remediate`) <!-- Added: forge#1813 -->
249
249
 
250
+ **Engine coverage** (forge#2379): `remediate` is now a registered phase in the headless engine's phase table (`packages/protocol/src/phases.js`, `bin/engine/phases.mjs`) — see `commands/work-on/remediate.md`'s own "Engine coverage" note for the current, documented limitation (a single continuous headless `runIssue()` walk cannot yet reach it; this prose-layer standalone-invocation path below remains the only way `remediate` actually runs today).
251
+
250
252
  **Check first, before any other Phase 0 routing** — if `$ARGUMENTS` contains `--remediate`, this is NOT a normal issue-pipeline invocation. The first positional argument is a **PR number**, not an issue number:
251
253
 
252
254
  ```bash
@@ -524,9 +526,9 @@ a hard dependency.
524
526
 
525
527
  ## Phase 1: Investigation
526
528
 
527
- **Skip if**: `<!-- FORGE:INVESTIGATOR -->` exists with `<!-- INVESTIGATION:COMPLETE -->` in the SAME comment.
529
+ **Skip if**: `<!-- FORGE:INVESTIGATOR -->` exists with `<!-- INVESTIGATION:COMPLETE -->` OR `<!-- INVESTIGATION:INVALID -->` in the SAME comment. `INVESTIGATION:INVALID` is the terminal sentinel Phase 1C emits for an INVALID verdict (see 1C below) — it signals investigation completion just as much as `INVESTIGATION:COMPLETE` does, not an interrupted state.
528
530
 
529
- **Partial investigation**: If investigator comment exists BUT `<!-- INVESTIGATION:COMPLETE -->` is ABSENT → investigation was interrupted. Delete the partial comment and restart:
531
+ **Partial investigation**: If investigator comment exists BUT NEITHER `<!-- INVESTIGATION:COMPLETE -->` NOR `<!-- INVESTIGATION:INVALID -->` is present → investigation was interrupted. Delete the partial comment and restart:
530
532
  ```bash
531
533
  COMMENT_ID=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
532
534
  --jq '.[] | select(.body | contains("FORGE:INVESTIGATOR")) | .id')
@@ -686,9 +688,18 @@ Use `forge.yaml → review.tech_stack` and the issue domain labels to identify e
686
688
 
687
689
  ### 1C: Post investigation comment
688
690
 
689
- The comment MUST include `<!-- INVESTIGATION:COMPLETE -->` at the very end.
691
+ The comment MUST include a terminal sentinel at the very end. **The sentinel is conditional on the resolved Verdict — it is NOT always `<!-- INVESTIGATION:COMPLETE -->`:**
692
+
693
+ - **Verdict is INVALID** → close with `<!-- INVESTIGATION:INVALID -->`. This is a distinct, already-wired-up terminal marker: `bin/engine/phases.mjs`'s `detectOutcome` for the `investigate` phase checks for it explicitly (ahead of `INVESTIGATION:COMPLETE`) and routes to `terminalReason: "invalid"`; `bin/hooks/interactive-engine.mjs`'s `PHASE_MARKERS` table also already treats it as terminal. Emitting `INVESTIGATION:COMPLETE` for an INVALID verdict is what previously caused every completed investigation to read as `{verdict: "CONFIRMED"}` regardless of actual outcome — do NOT regress this (forge#2350).
694
+ - **Verdict is CONFIRMED or PARTIAL** → close with `<!-- INVESTIGATION:COMPLETE -->` as before (PARTIAL still routes to `ready-to-build` below — only INVALID gets the distinct terminal sentinel).
690
695
 
691
696
  ```bash
697
+ if [ "{VERDICT}" = "INVALID" ]; then
698
+ INVESTIGATION_SENTINEL="<!-- INVESTIGATION:INVALID -->"
699
+ else
700
+ INVESTIGATION_SENTINEL="<!-- INVESTIGATION:COMPLETE -->"
701
+ fi
702
+
692
703
  gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:INVESTIGATOR -->
693
704
  ## Investigation Report
694
705
 
@@ -728,9 +739,11 @@ gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:INVESTIGATOR -->
728
739
  **{YES|NO}** — {reason}
729
740
  {if YES: proposed sub-issues with titles and dependencies}
730
741
 
731
- <!-- INVESTIGATION:COMPLETE -->"
742
+ ${INVESTIGATION_SENTINEL}"
732
743
  ```
733
744
 
745
+ **Do not hardcode `<!-- INVESTIGATION:COMPLETE -->` as the closing line.** The closing line MUST be the `${INVESTIGATION_SENTINEL}` variable computed above — it resolves to `<!-- INVESTIGATION:INVALID -->` for an INVALID verdict and `<!-- INVESTIGATION:COMPLETE -->` otherwise. `INVESTIGATION:COMPLETE` and `INVESTIGATION:INVALID` are mutually exclusive within a single posted comment — never emit both.
746
+
734
747
  ### 1D: Correction capture (MANDATORY — run before label update)
735
748
 
736
749
  Before routing, scan all non-agent comments for correction signals from the repository owner. Correction signals are owner comments that contain phrases like "no, use", "actually use", "use X instead", "not X, use Y", or "wrong branch". If found, write the correction to `forge.yaml → learned:` and emit a `FORGE:LEARNED` annotation.
@@ -890,6 +903,8 @@ gh issue close {NUMBER} {GH_FLAG} --comment "Closing as invalid: {reason from in
890
903
 
891
904
  ## Phase 2: Decomposition (Conditional)
892
905
 
906
+ **Engine coverage** (forge#2379): this phase's dispatch target (`work-on/decompose`) is now a real phase in the headless engine's phase table — `decompose` in `packages/protocol/src/phases.js`/`bin/engine/phases.mjs`, live-wired so `investigate`'s `DECOMPOSE:YES` outcome hands off to it instead of terminating the run in place. This prose Phase 2 remains the interactive/prose dispatch path; the engine path is the headless equivalent, both invoking the same `commands/work-on/decompose.md` subcommand.
907
+
893
908
  **Skip if**: Already decomposed, is a sub-issue, or investigation says decompose: NO.
894
909
 
895
910
  **Trigger if**: Investigation assessment says YES — 2+ signals match (at least 1 Strong): multiple task types, 3+ service groups, phased requirements, 6+ files across directories.
package/docs/CONFIG.md CHANGED
@@ -48,6 +48,8 @@ If you have both a local `claude` CLI and `ANTHROPIC_API_KEY` set and want to pi
48
48
  | [`agents`](#agents-optional) | No | Default model for pipeline agents |
49
49
  | [`repos`](#repos-optional) | No | Multi-repository routing |
50
50
  | [`project_board`](#project_board-optional) | No | GitHub Projects v2 integration |
51
+ | [`orchestration`](#orchestration-optional) | No | `/orchestrate` concurrency and cascade admission policy |
52
+ | [`pipeline`](#pipeline-optional) | No | `/orchestrate` batch-engine tuning (stall detection, token budget, narration) |
51
53
  | [`services`](#services-optional) | No | External service URLs and IDs |
52
54
  | [`review`](#review-optional) | No | Context injected into review agents |
53
55
  | [`devdocs`](#devdocs-optional) | No | Devdocs knowledge tree path |
@@ -290,6 +292,124 @@ gh project field-list <project_number> --owner <owner> --format json \
290
292
 
291
293
  ---
292
294
 
295
+ ## `orchestration` (OPTIONAL)
296
+
297
+ Concurrency limits and cascade admission policy for `/orchestrate`'s batch engine. Previously read at runtime (`orchestration.max_concurrent`) but undocumented; this section is the first place it — and the newer `cascade` sub-section (forge#2234) — are formally specified.
298
+
299
+ ```yaml
300
+ orchestration:
301
+ # Maximum number of /work-on sub-agents dispatched concurrently. Guards against
302
+ # saturating the Anthropic API rate limit in one burst on a large ready set.
303
+ # Default: 12.
304
+ max_concurrent: 12
305
+
306
+ cascade:
307
+ # Named preset expanding to the granular keys below. Any granular key set
308
+ # explicitly overrides just that field — the preset supplies defaults, not
309
+ # hard values. Default: "balanced".
310
+ # all — admit everything: no generation cap, no token cap, every
311
+ # heuristic-based defer disabled. For a maintainer draining
312
+ # a backlog who wants /orchestrate to "pick up everything."
313
+ # balanced — the pre-#2234 hardcoded behavior, unchanged: generation
314
+ # cap at 1 (i.e. cascade-spawned findings deferred),
315
+ # 900000-token per-batch ceiling, idle/keyword/same-file
316
+ # heuristics all active.
317
+ # conservative — same admission shape as balanced, with a lower
318
+ # (450000) token ceiling for cost-sensitive or noisy repos.
319
+ policy: "balanced"
320
+
321
+ # Maximum cascade generation depth admitted. Generation 1 = an original
322
+ # issue (not spawned from a review-finding). Generation 2 = spawned from a
323
+ # review-finding. Generation 3 = spawned from a finding that was itself
324
+ # spawned from a review-finding. Accepts a positive integer or the literal
325
+ # string "unlimited". Governs Phase 1 resolve-time admission only (the
326
+ # `cascade`/`review-findings`/`findings` resolution in phase-1-resolve.md)
327
+ # — it does NOT relax Step 4C's absolute autonomous-cascade defer, which
328
+ # stays hardcoded regardless of this value (see phase-4-execution.md Step
329
+ # 4C rule 1). This is what makes "admit gen-2, stop at gen-3" expressible,
330
+ # which the pre-#2234 --allow-gen2 flag (all-or-nothing) could not say.
331
+ # Default: 1 (from the "balanced" preset); "unlimited" under "all".
332
+ max_generation: 1
333
+
334
+ # Per-batch token ceiling for Step 4C's review-finding cascade dispatch.
335
+ # New home for the same lever previously read only as
336
+ # `pipeline.token_budget_per_batch` (kept working as a deprecated alias —
337
+ # see the `pipeline` section below). Accepts a positive integer or
338
+ # "unlimited". Default: 900000 (from "balanced"); 450000 under
339
+ # "conservative"; "unlimited" under "all".
340
+ token_budget: 900000
341
+
342
+ # Whether a fully human-gated original batch (forge#1814's
343
+ # BATCH_FULLY_GATED — every original-batch issue DONE/FAILED/GATED with at
344
+ # least one GATED) suppresses further cascade-finding dispatch. Default:
345
+ # true (from "balanced"); false under "all".
346
+ defer_on_batch_gated: true
347
+
348
+ # Whether the comment/typo title-keyword heuristic defers P3-and-below
349
+ # cascade findings. Default: true (from "balanced"); false under "all".
350
+ keyword_heuristic: true
351
+
352
+ # Whether a P3 finding sharing a file with the active batch is deferred
353
+ # (avoids serializing agents on same-file predecessor edges). Default:
354
+ # true (from "balanced"); false under "all".
355
+ p3_same_file_defer: true
356
+ ```
357
+
358
+ | Field | Type | Required | Description |
359
+ |-------|------|----------|-------------|
360
+ | `max_concurrent` | integer | No | Max concurrent `/work-on` sub-agent dispatch. Default: 12 |
361
+ | `cascade.policy` | string | No | `all` \| `balanced` \| `conservative`. Default: `balanced` |
362
+ | `cascade.max_generation` | integer or `"unlimited"` | No | Max cascade generation admitted at Phase 1 resolve time. Default: 1 |
363
+ | `cascade.token_budget` | integer or `"unlimited"` | No | Per-batch token ceiling for Step 4C cascade dispatch. Default: 900000 (deprecated alias: `pipeline.token_budget_per_batch`) |
364
+ | `cascade.defer_on_batch_gated` | boolean | No | Suppress cascade dispatch when the original batch is fully human-gated. Default: `true` |
365
+ | `cascade.keyword_heuristic` | boolean | No | Defer P3-and-below comment/typo-titled findings. Default: `true` |
366
+ | `cascade.p3_same_file_defer` | boolean | No | Defer P3 findings sharing a file with the active batch. Default: `true` |
367
+
368
+ **Hard invariant — not configurable**: safety exclusions (findings whose `## Problem` section indicates security/billing/anti-bot/auth concerns) are never batched and never auto-admitted by any `cascade.policy`, including `all`. That exclusion is enforced upstream of this section (the P3 batching eligibility check) and has no corresponding key here by design.
369
+
370
+ **CLI overrides**: `--policy`, `--max-generation`, `--token-budget` on `/orchestrate` take precedence over these keys for a single invocation — see `commands/orchestrate/config.md` → "Cascade Policy CLI Flags".
371
+
372
+ **Commands that use this section**: `orchestrate`
373
+
374
+ ---
375
+
376
+ ## `pipeline` (OPTIONAL)
377
+
378
+ Tuning knobs for `/orchestrate`'s batch engine — stall detection, narration verbosity, and (deprecated alias only, see `orchestration.cascade` above) the review-finding cascade token budget. All keys optional; sane defaults apply when omitted.
379
+
380
+ ```yaml
381
+ pipeline:
382
+ # Minutes an agent may sit idle (no FORGE:HEARTBEAT / label change) before
383
+ # /orchestrate's stall detector treats it as stuck and attempts an
384
+ # auto-resume. Default: 15.
385
+ stall_timeout_minutes: 15
386
+
387
+ # Deprecated alias for orchestration.cascade.token_budget (forge#2234) — kept
388
+ # working so existing configs are unaffected. When both are set,
389
+ # orchestration.cascade.token_budget wins. Default: 900000.
390
+ token_budget_per_batch: 900000
391
+
392
+ # Estimated token cost of a single P3-or-lower review-finding /work-on
393
+ # pipeline, used to decrement the token budget above. Default: 150000.
394
+ token_estimate_per_finding: 150000
395
+
396
+ # Narration verbosity for /orchestrate's per-completion status updates.
397
+ # "terse" (default): one line per completion. "verbose": adds a running
398
+ # per-completion recap table. Cosmetic only — never changes which phases run.
399
+ narration: "terse"
400
+ ```
401
+
402
+ | Field | Type | Required | Description |
403
+ |-------|------|----------|-------------|
404
+ | `stall_timeout_minutes` | integer | No | Idle minutes before the stall detector attempts auto-resume. Default: 15 |
405
+ | `token_budget_per_batch` | integer | No | **Deprecated** — use `orchestration.cascade.token_budget`. Default: 900000 |
406
+ | `token_estimate_per_finding` | integer | No | Estimated tokens per P3-or-lower cascade finding. Default: 150000 |
407
+ | `narration` | string | No | `terse` \| `verbose`. Default: `terse` |
408
+
409
+ **Commands that use this section**: `orchestrate`
410
+
411
+ ---
412
+
293
413
  ## `services` (OPTIONAL)
294
414
 
295
415
  External service endpoints and identifiers. Used by analytics and monitoring commands.
@@ -27,7 +27,9 @@ Each headless run of a single corpus issue produces one JSON object:
27
27
  "interventionCount": 0, // integer — number of human interventions (see below)
28
28
 
29
29
  // Optional fields (nullable — do NOT block on missing upstream data)
30
- "cost": null, // null | number — token/USD cost; null until #1255 lands
30
+ "cost": null, // null | number — total billed tokens (input + output); null when
31
+ // the run reports no usage data (e.g. CLI backend, or an errored
32
+ // run). NOT a USD amount — no per-model pricing table exists yet.
31
33
  "iterations": 23, // integer — tool-use loop iteration count
32
34
  "stopReason": "end_turn", // string — Anthropic stop_reason or "max_iterations"
33
35
  "error": null, // null | string — error message when status === "error"
@@ -109,7 +111,8 @@ a scorecard in the format consumed by `scripts/eval-gate-scorecard.mjs` (#1286):
109
111
  "mean_wall_clock_ms": 38200, // number | null — arithmetic mean wall-clock
110
112
  "median_wall_clock_ms": 35000, // number | null — median wall-clock
111
113
  "total_intervention_count": 1, // integer — sum of interventionCount across all runs
112
- "cost": null, // null | number — sum of per-run cost; null if any run has cost: null
114
+ "cost": null, // null | number — sum of per-run billed-token counts (not USD);
115
+ // null if any run has cost: null
113
116
  "run_mode": "full", // "full" | "subset"
114
117
  "spec_sha": "abc123" // string | null — git SHA of commands/ at run time
115
118
  }
@@ -128,8 +131,9 @@ hangs and crashes, not just logic failures.
128
131
  `wallClockMs`, `interventionCount`) or non-integer/non-string values cause
129
132
  exit code 1 (validation error).
130
133
  3. **Cost is nullable**: if any run has `cost: null`, the scorecard's
131
- `cost` is `null`. The field becomes numeric only when every run reports a
132
- real cost value (post-#1255).
134
+ `cost` is `null`. The field becomes numeric once every run reports a
135
+ billed-token count (see the per-run `cost` field above — this is a
136
+ token-count sum, not a USD amount).
133
137
  4. **Rate consistency**: `success_rate_pct` is always computed as
134
138
  `round(successful_runs / issues_run * 100, 2)`. The CI gate (#1286) validates
135
139
  this computed value matches the reported value.
@@ -19,9 +19,12 @@ See also: [FORGE Annotation Protocol §6](forge-protocol-v1.md#6-label-state-mac
19
19
  | `workflow:invalid` | Issue closed as invalid | Investigator agent |
20
20
  | `workflow:decomposed` | Issue decomposed into sub-issues | Decomposer agent |
21
21
  | `needs-human` | Pipeline blocked, human intervention required | Any agent on error |
22
+ | `workflow:engine-error` | Pipeline blocked by the engine/tool itself breaking (CLI crash, missing SDK/API key, exhausted retries with no attempt ever reaching a real outcome) — not a genuine content-level judgment call | `terminate()` in `bin/engine.mjs` |
22
23
 
23
24
  `workflow:awaiting-merge` vs `needs-human`: both are terminal (the pipeline stops advancing the issue automatically), but they mean different things. `needs-human` means the pipeline hit a condition it cannot resolve on its own (genuinely blocked — conflicting PR, failed verdict, calibration/trust escalation, etc.) and a human must diagnose and act. `workflow:awaiting-merge` means the opposite: the PR was previously escalated to `needs-human`, has since been remediated and re-reviewed to a clean `APPROVED` verdict with no mergeability blockers, but does not yet meet the automated auto-land bar (see forge#1809 Q1) — a human only needs to click merge, not diagnose a problem. `scripts/transition-label.sh` only clears `needs-human` (best-effort) when the target state is `workflow:awaiting-merge` — every other forward transition leaves a pre-existing `needs-human` label untouched, preserving its sticky/terminal semantics.
24
25
 
26
+ `workflow:engine-error` vs `needs-human` (taxonomy split — commit `90376f5` / #2261): before this commit, `terminate()` wrote `needs-human` for *every* exhausted-retry blocked outcome, including cases where the engine/CLI tool itself was breaking (crash, missing API key, no SDK) rather than a genuine human-judgment call. As of `90376f5`, `runPhaseWithRetry()` tags an outcome `engine-error` when every exhausted attempt failed via a thrown runner exception (never reached `detectOutcome()`) — the engine/tool is broken, not the issue content — and `terminate()` writes `workflow:engine-error` instead of `needs-human` for that case. A genuine content-level block (unmerged PR, unresolved branch, a fixed-point zero-commits case) still writes `needs-human` unchanged. **Consequence for downstream consumers**: any dashboard, query, or health metric that counts `needs-human` issues computed a different, larger population before `90376f5` than after — a subset of what used to be `needs-human` now shows up as `workflow:engine-error` instead. This is a one-time taxonomy discontinuity, not a real change in pipeline reliability; consumers tracking `needs-human` counts over time should also track `workflow:engine-error` from this commit forward to get a like-for-like comparison. `bin/labels.json` already includes `workflow:engine-error` in this repo; syncing it to consumer/satellite repos is tracked separately (see forge#2346).
27
+
25
28
  ## Transitions
26
29
 
27
30
  ```
@@ -35,8 +38,13 @@ See also: [FORGE Annotation Protocol §6](forge-protocol-v1.md#6-label-state-mac
35
38
  → workflow:invalid [TERMINAL] [Any phase: closed as invalid]
36
39
  → workflow:decomposed [TERMINAL] [Phase 2: split into sub-issues]
37
40
  → needs-human [TERMINAL] [Any phase: pipeline blocked]
41
+ → workflow:engine-error [TERMINAL] [Any phase: the engine/tool itself broke — CLI crash, missing API key, no SDK — not a content-level judgment call]
38
42
  ```
39
43
 
44
+ **`workflow:invalid` after `ready-to-build`/`building`/`in-review`** (#2326): the enforcement hook (`bin/hooks/pre-tool-use.mjs`) allows `workflow:invalid` as a successor of these three states — not only of `workflow:investigating` — because invalidity is sometimes only discovered during architecture planning or build, once the actual code/tests are read (see #2312). This is gated, not unconditional: the hook requires a posted reversal comment already on the issue — a second `FORGE:INVESTIGATOR` annotation carrying `**Verdict**: INVALID` — before it allows the transition through. A bare relabel with no evidence trail is still blocked. `workflow:investigating → workflow:invalid` remains evidence-free (Phase 1D's normal path).
45
+
46
+ The reversal comment must also carry a trusted `authorAssociation` — `OWNER`, `MEMBER`, or `COLLABORATOR` (#2332). Marker+verdict text alone is forgeable by any commenter on a public issue with no repo write access; the hook additionally requires GitHub's own `authorAssociation` classification on that comment (returned by `gh issue view --json comments` at no extra cost) to be one of those three tiers. This check is deliberately identity-agnostic rather than a hardcoded bot-login allowlist, so it survives the pipeline's `gh` identity rotating between accounts (#1722) without needing to be updated.
47
+
40
48
  ## Terminal Labels
41
49
 
42
50
  Processing stops when any of these labels is set:
@@ -46,6 +54,7 @@ Processing stops when any of these labels is set:
46
54
  - `workflow:decomposed`
47
55
  - `workflow:awaiting-merge`
48
56
  - `needs-human`
57
+ - `workflow:engine-error`
49
58
 
50
59
  ## Label Exclusivity
51
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Autonomous dev pipeline for Claude Code. GitHub as institutional memory — every issue, PR, and annotation survives context resets and makes the next agent smarter.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"
@@ -46,7 +46,7 @@
46
46
  "README.md"
47
47
  ],
48
48
  "scripts": {
49
- "test": "node --test \"bin/tests/**/*.test.mjs\" \"packages/protocol/test/**/*.test.mjs\"",
49
+ "test": "node --test \"bin/tests/**/*.test.mjs\" \"bin/engine/**/*.test.mjs\" \"packages/protocol/test/**/*.test.mjs\"",
50
50
  "docs:dev": "vitepress dev docs/site",
51
51
  "docs:build": "vitepress build docs/site",
52
52
  "docs:preview": "vitepress preview docs/site"
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { RESERVED_TYPES, RESERVED_TYPE_NAMES } from './types.js';
15
+ import { escapeHtmlCommentDelimiters } from './html-comment-escape.js';
15
16
 
16
17
  // A folded field/inline value that is itself (whole, trimmed) shaped like a FORGE
17
18
  // opening tag or a generic sentinel line has no legitimate reason to look that way —
@@ -40,7 +41,9 @@ const NEWLINE_RE = /\r\n|\r|\n/g;
40
41
  * subsequent lines including the completion sentinel, and treats a literal
41
42
  * `-->` as ending the enclosing comment early, leaking the remainder as
42
43
  * visible rendered text. Both directions of the delimiter pair must be
43
- * escaped. (forge#1594, forge#1638)
44
+ * escaped. (forge#1594, forge#1638) Delegated to the shared
45
+ * `escapeHtmlCommentDelimiters()` helper (forge#2225) so this scheme has a
46
+ * single implementation shared with `bin/engine/state.mjs`.
44
47
  *
45
48
  * As defense in depth, if a value still folds down to a line that is itself a
46
49
  * bare FORGE tag/sentinel shape, emit() rejects it outright rather than silently
@@ -56,27 +59,7 @@ function sanitizeFieldValue(rawValue) {
56
59
  `emit(): field value resolves to a bare FORGE tag/sentinel line, which is not permitted: ${JSON.stringify(rawValue)}`,
57
60
  );
58
61
  }
59
- // Escape "&" FIRST, before either delimiter-escape pass below. Without this,
60
- // a value that already contains literal entity-like text (e.g. "<!--&gt;"
61
- // or "&lt;!--") collides with the encoded form of an unrelated real
62
- // delimiter — sanitizeFieldValue() would not be injective, and the
63
- // collision is unrecoverable by unescapeFieldValue() on decode
64
- // (forge#2137). Escaping "&" first guarantees every "&lt;!--"/"--&gt;"/
65
- // "--!&gt;" sequence in the output was produced by *this* pass, never by
66
- // pre-existing entity text (which is now doubly-escaped, e.g.
67
- // "&" -> "&amp;", left untouched by the delimiter passes that only match
68
- // literal "<!--"/"--"+">" character sequences).
69
- const withAmpEscaped = folded.replace(/&/g, '&amp;');
70
- // Escape the HTML comment opener ("<!--") so a value cannot start a new
71
- // unterminated HTML comment in GitHub's renderer, which would visually swallow
72
- // subsequent lines — the same rendering-leak the closer escaping below prevents
73
- // from the opposite direction. (forge#1638)
74
- const withOpenerEscaped = withAmpEscaped.replace(/<!--/g, '&lt;!--');
75
- // Escape both HTML comment-close forms ("-->" and "--!>") — only replacing the
76
- // literal character sequence (not just documenting the risk) prevents the
77
- // enclosing comment from terminating early. (forge#1594; the FORGE:STATE codec
78
- // in bin/engine/state.mjs now mirrors this full three-form scheme — forge#2119)
79
- return withOpenerEscaped.replace(/--(!)?>/g, (_, bang) => `--${bang || ''}&gt;`);
62
+ return escapeHtmlCommentDelimiters(folded);
80
63
  }
81
64
 
82
65
  /**
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Shared HTML-comment-delimiter escape/unescape pair for the FORGE annotation
3
+ * wire format.
4
+ *
5
+ * This scheme is used by two independent callers that must agree byte-for-byte:
6
+ * - `packages/protocol/src/emit.js` / `parse.js` (per-field-value escaping)
7
+ * - `bin/engine/state.mjs` (whole-JSON-payload escaping for the FORGE:STATE
8
+ * compact-index block)
9
+ *
10
+ * Extracting it here removes the drift vector between those two call sites —
11
+ * they previously hand-duplicated this exact regex chain, kept in sync only by
12
+ * cross-referencing code comments, and had already drifted once (forge#1638's
13
+ * fix was applied to `emit.js` before `state.mjs` caught up in forge#2119).
14
+ *
15
+ * @license MIT
16
+ */
17
+
18
+ /**
19
+ * Escape the three HTML comment delimiter forms so a payload/value can never
20
+ * open or close an HTML comment in GitHub's renderer:
21
+ * - `<!--` the comment opener — an unescaped opener would start a new
22
+ * (nested-looking) comment, visually swallowing subsequent content.
23
+ * - `-->` and `--!>` the two comment-close forms browsers/HTML parsers
24
+ * honour. An unescaped closer could end the enclosing comment early,
25
+ * leaking the remainder as visible rendered text (CodeQL js/bad-tag-filter).
26
+ *
27
+ * The `&` escape MUST run first, before either delimiter-escape pass. Without
28
+ * it, a value that already contains literal entity-like text (e.g. `<!--&gt;`
29
+ * or `&lt;!--`) collides with the encoded form of an unrelated real delimiter —
30
+ * the encoder would not be injective, and the collision is unrecoverable on
31
+ * decode (forge#2137). Escaping `&` first guarantees every `&lt;!--`/`--&gt;`/
32
+ * `--!&gt;` sequence in the output was produced by *this* pass, never by
33
+ * pre-existing entity text (which is now doubly-escaped, e.g.
34
+ * `&` -> `&amp;`, left untouched by the delimiter passes that only match the
35
+ * literal `<!--`/`--`+`>` character sequences).
36
+ *
37
+ * @param {string} value
38
+ * @returns {string}
39
+ */
40
+ export function escapeHtmlCommentDelimiters(value) {
41
+ return value
42
+ .replace(/&/g, '&amp;')
43
+ .replace(/<!--/g, '&lt;!--')
44
+ .replace(/--(!)?>/g, (_, bang) => `--${bang || ''}&gt;`);
45
+ }
46
+
47
+ /**
48
+ * Inverse of {@link escapeHtmlCommentDelimiters}. Restores `--&gt;`, `--!&gt;`,
49
+ * `&lt;!--`, then `&amp;` — in that order.
50
+ *
51
+ * Unescape order matters: the closer forms are restored first (most-specific
52
+ * `--!&gt;` before `--&gt;`) so a dangling `!` is never left behind, then the
53
+ * opener is restored. The `&amp;` -> `&` unescape MUST run LAST (mirroring the
54
+ * encode side running it FIRST) so it never touches the `&` that is part of a
55
+ * just-restored `<!--`/`-->`/`--!>` sequence (forge#2137; forge#1662 — parse()
56
+ * previously failed to apply the exact inverse of emit()'s escape, corrupting
57
+ * round-trips).
58
+ *
59
+ * @param {string} raw
60
+ * @returns {string}
61
+ */
62
+ export function unescapeHtmlCommentDelimiters(raw) {
63
+ return raw
64
+ .replace(/--!&gt;/g, '--!>')
65
+ .replace(/--&gt;/g, '-->')
66
+ .replace(/&lt;!--/g, '<!--')
67
+ .replace(/&amp;/g, '&');
68
+ }
@@ -30,6 +30,7 @@ export { validate } from './validate.js';
30
30
  export { emit, emitPartial, isKnownType } from './emit.js';
31
31
  export { RESERVED_TYPES, RESERVED_TYPE_NAMES, Category, SentinelState } from './types.js';
32
32
  export { canonicalJson, toBase64url, encodeCard, decodeCardInlineValue } from './card.js';
33
+ export { PHASE_IDS, PHASE_MARKERS } from './phases.js';
33
34
 
34
35
  /**
35
36
  * @typedef {Object} ParsedAnnotation
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { RESERVED_TYPES, RESERVED_TYPE_NAMES, SentinelState } from './types.js';
20
+ import { unescapeHtmlCommentDelimiters } from './html-comment-escape.js';
20
21
 
21
22
  // Matches: <!-- FORGE:TYPE --> or <!-- FORGE:TYPE: value -->
22
23
  // Anchored to require the tag to be the ENTIRE (trimmed) line — a FORGE-tag-shaped
@@ -119,17 +120,15 @@ function escapeRegExp(str) {
119
120
  * on decode is what makes the scheme injective: any pre-existing entity-like
120
121
  * text in the original value (e.g. `&lt;!--` or `<!--&gt;`) is doubly-escaped
121
122
  * at encode time and can never collide with the single-escaped form produced
122
- * by a real delimiter (forge#2137; forge#1662).
123
+ * by a real delimiter (forge#2137; forge#1662). Delegated to the shared
124
+ * `unescapeHtmlCommentDelimiters()` helper (forge#2225) so this scheme has a
125
+ * single implementation shared with `bin/engine/state.mjs`.
123
126
  *
124
127
  * @param {string} raw
125
128
  * @returns {string}
126
129
  */
127
130
  function unescapeFieldValue(raw) {
128
- return raw
129
- .replace(/--!&gt;/g, '--!>')
130
- .replace(/--&gt;/g, '-->')
131
- .replace(/&lt;!--/g, '<!--')
132
- .replace(/&amp;/g, '&');
131
+ return unescapeHtmlCommentDelimiters(raw);
133
132
  }
134
133
 
135
134
  /**
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Canonical FORGE pipeline phase table — single source of truth for phase ids
3
+ * (in dispatch order) and the exact marker/label strings that indicate each
4
+ * phase has committed.
5
+ *
6
+ * Consumers: `bin/engine/phases.mjs` (the authoritative engine gate) and
7
+ * `bin/hooks/interactive-engine.mjs` (the interactive-session SubagentStop
8
+ * hook) both import this module instead of hand-declaring their own copies
9
+ * of the marker strings. Before this module existed, the two files carried
10
+ * independent literal copies of the same strings and drifted out of sync at
11
+ * least twice (forge#1669 established the strict `:COMPLETE` gate for
12
+ * `context`/`architect`; forge#2375/PR#2395 then had to hand-patch the hook's
13
+ * copy back into agreement with the engine's). See forge#2378.
14
+ *
15
+ * `completionMarker` values are sourced from `RESERVED_TYPES[*].completionSentinel`
16
+ * wherever that reserved annotation type exists (INVESTIGATOR, CONTEXT,
17
+ * ARCHITECT, BUILDER) — so the reserved-annotation registry in `types.js` and
18
+ * this phase registry cannot drift apart for those four phases; there is
19
+ * exactly one literal declaration of each of those marker strings in the
20
+ * entire codebase, in `types.js`.
21
+ *
22
+ * `review` and `close` have no corresponding `RESERVED_TYPES` entry with a
23
+ * matching sentinel today (`REVIEWER` has no `completionSentinel` defined in
24
+ * spec §4.1; `close` is gated on a GitHub *label*, not a comment annotation),
25
+ * so those two are declared directly in this file — still single-sourced
26
+ * across the two consumers, just not additionally derived from `types.js`.
27
+ *
28
+ * @license MIT
29
+ */
30
+
31
+ import { RESERVED_TYPES } from './types.js';
32
+
33
+ /** Phase ids, in canonical dispatch order (mirrors `commands/work-on.md`'s
34
+ * "Universal Phase Dispatcher" table and `bin/engine/phases.mjs`'s `PHASES`
35
+ * array).
36
+ *
37
+ * `decompose` and `remediate` (forge#2379) are branch phases, not part of the
38
+ * six-phase linear happy path — `decompose` is reached only when `investigate`
39
+ * signals `DECOMPOSE:YES`, and `remediate` only when `review` escalates to
40
+ * `needs-human`. They are still declared here, in the exact same array
41
+ * position `bin/engine/phases.mjs`'s `PHASES` array declares them, because
42
+ * `scripts/check-phase-registry-drift.mjs` requires the two lists to match
43
+ * index-for-index — see that file's own doc comment. */
44
+ export const PHASE_IDS = ['investigate', 'decompose', 'context', 'architect', 'build', 'review', 'remediate', 'close'];
45
+
46
+ /**
47
+ * @typedef {Object} PhaseMarkerEntry
48
+ * @property {string} [completionMarker] - marker/sentinel string (or label, for `close`)
49
+ * whose presence means this phase has committed.
50
+ * @property {string} [invalidMarker] - (investigate only) marks the issue INVALID.
51
+ * @property {string} [decomposedMarker] - (investigate only) marks the issue DECOMPOSED.
52
+ * @property {string} [partialMarker] - sentinel for an interrupted/partial annotation.
53
+ * @property {string} [presenceMarker] - bare annotation-opener substring, used only by
54
+ * phases whose completion is non-critical (see `context` below) — presence alone
55
+ * (without `:COMPLETE`) is enough to report a soft/visible skip rather than a hard fail.
56
+ * @property {string} [completionLabel] - a GitHub issue *label* (not a comment marker)
57
+ * whose presence means this phase has committed. Only `close` uses this form.
58
+ */
59
+
60
+ /** @type {Object.<string, PhaseMarkerEntry>} */
61
+ export const PHASE_MARKERS = {
62
+ investigate: {
63
+ completionMarker: RESERVED_TYPES.INVESTIGATOR.completionSentinel, // 'INVESTIGATION:COMPLETE'
64
+ invalidMarker: 'INVESTIGATION:INVALID',
65
+ decomposedMarker: 'DECOMPOSE:YES',
66
+ },
67
+ // forge#2379: `decompose` actually runs work-on/decompose (sub-issue
68
+ // fan-out) once `investigate` hands off on `DECOMPOSE:YES` — see
69
+ // bin/engine/phases.mjs's `decompose` phase entry for the handoff mechanics.
70
+ decompose: {
71
+ completionMarker: RESERVED_TYPES.DECOMPOSED.completionSentinel, // 'FORGE:DECOMPOSED:COMPLETE'
72
+ },
73
+ context: {
74
+ completionMarker: RESERVED_TYPES.CONTEXT.completionSentinel, // 'FORGE:CONTEXT:COMPLETE'
75
+ partialMarker: RESERVED_TYPES.CONTEXT.partialSentinel, // 'FORGE:CONTEXT:PARTIAL'
76
+ // Bare presence marker: context is documented as non-critical (spec §7) — a
77
+ // missing completion marker is a visible skip, not a hard fail. Only
78
+ // bin/engine/phases.mjs's `context.detectOutcome` uses this bare form;
79
+ // `context.reconcile` (and everything in interactive-engine.mjs) uses the
80
+ // strict `completionMarker` above. Do not collapse these two fields.
81
+ presenceMarker: `FORGE:${RESERVED_TYPES.CONTEXT.type}`, // 'FORGE:CONTEXT'
82
+ },
83
+ architect: {
84
+ completionMarker: RESERVED_TYPES.ARCHITECT.completionSentinel, // 'FORGE:ARCHITECT:COMPLETE'
85
+ partialMarker: RESERVED_TYPES.ARCHITECT.partialSentinel, // 'FORGE:ARCHITECT:PARTIAL'
86
+ },
87
+ build: {
88
+ completionMarker: RESERVED_TYPES.BUILDER.completionSentinel, // 'FORGE:BUILDER:COMPLETE'
89
+ },
90
+ review: {
91
+ // No RESERVED_TYPES entry defines this sentinel (REVIEWER has no
92
+ // completionSentinel in spec §4.1) — declared directly here.
93
+ completionMarker: 'FORGE:REVIEWER:MERGED',
94
+ },
95
+ // forge#2379: `remediate` re-drives a needs-human PR (commands/work-on/remediate.md).
96
+ // Posted to BOTH the PR and the linked issue (Phase M8) — bin/engine/phases.mjs's
97
+ // `remediate` phase reads it off the issue, consistent with every other phase here.
98
+ remediate: {
99
+ completionMarker: RESERVED_TYPES.REMEDIATION.completionSentinel, // 'FORGE:REMEDIATION:COMPLETE'
100
+ },
101
+ close: {
102
+ // A GitHub label, not a comment marker — see `completionLabel` above.
103
+ completionLabel: 'workflow:merged',
104
+ },
105
+ };
@@ -86,6 +86,23 @@ export const RESERVED_TYPES = {
86
86
  requiredFields: ['Verdict'],
87
87
  verdictValues: ['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED'],
88
88
  },
89
+ /**
90
+ * REMEDIATION — paper trail for a needs-human PR re-driven by
91
+ * commands/work-on/remediate.md. Posted to BOTH the PR and the linked
92
+ * issue (Phase M8) once the re-gate outcome (AUTO-LANDED /
93
+ * HELD-AWAITING-MERGE / RE-ESCALATED / UNFIXABLE) is known. Added in
94
+ * forge#2379 to single-source the completion sentinel that
95
+ * bin/engine/phases.mjs's `remediate` phase gates on. <!-- Added: forge#2379 -->
96
+ */
97
+ REMEDIATION: {
98
+ type: 'REMEDIATION',
99
+ category: Category.LIFECYCLE,
100
+ completionSentinel: 'FORGE:REMEDIATION:COMPLETE',
101
+ partialSentinel: null,
102
+ inlineValue: false,
103
+ requiredFields: [],
104
+ reGateOutcomeValues: ['AUTO-LANDED', 'HELD-AWAITING-MERGE', 'RE-ESCALATED', 'UNFIXABLE'],
105
+ },
89
106
  TRAJECTORY: {
90
107
  type: 'TRAJECTORY',
91
108
  category: Category.LIFECYCLE,