forgedock 1.8.0 → 1.8.2

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 (67) hide show
  1. package/bin/engine/admission.mjs +57 -1
  2. package/bin/engine/admission.test.mjs +60 -0
  3. package/commands/analytics.md +3 -1
  4. package/commands/audit-agents.md +3 -1
  5. package/commands/audit.md +10 -8
  6. package/commands/autopilot.md +16 -14
  7. package/commands/ci-audit.md +4 -2
  8. package/commands/cleanup.md +1 -1
  9. package/commands/deploy-pr.md +31 -11
  10. package/commands/geo-audit.md +3 -1
  11. package/commands/incident-response.md +3 -1
  12. package/commands/issue.md +4 -2
  13. package/commands/milestone.md +8 -6
  14. package/commands/orchestrate/config.md +2 -2
  15. package/commands/orchestrate/phase-1-resolve.md +56 -12
  16. package/commands/orchestrate/phase-3-dependency.md +15 -5
  17. package/commands/orchestrate/phase-4-execution.md +593 -78
  18. package/commands/orchestrate/phase-5-cleanup.md +4 -2
  19. package/commands/orchestrate/phase-6-report.md +7 -3
  20. package/commands/orchestrate.md +13 -9
  21. package/commands/pipeline-health/phase-4-proposals.md +6 -4
  22. package/commands/pipeline-health/phase-5-report.md +4 -2
  23. package/commands/pipeline-health.md +9 -7
  24. package/commands/pipeline-resume.md +4 -2
  25. package/commands/qa-sweep.md +3 -1
  26. package/commands/quality-gate.md +4 -3
  27. package/commands/recover-orphans.md +172 -13
  28. package/commands/review-pr-agents.md +6 -4
  29. package/commands/review-pr-staging.md +13 -5
  30. package/commands/review-pr.md +187 -19
  31. package/commands/rollback.md +3 -1
  32. package/commands/security-audit.md +3 -1
  33. package/commands/signal-planner.md +6 -4
  34. package/commands/sync-ecosystem.md +4 -2
  35. package/commands/test-gate.md +4 -2
  36. package/commands/upgrade-deps.md +4 -2
  37. package/commands/validate.md +3 -1
  38. package/commands/work-on/build/architect.md +81 -19
  39. package/commands/work-on/build/context.md +61 -18
  40. package/commands/work-on/build/implement.md +62 -18
  41. package/commands/work-on/build/validate.md +240 -60
  42. package/commands/work-on/build.md +344 -146
  43. package/commands/work-on/close.md +617 -246
  44. package/commands/work-on/decompose.md +239 -41
  45. package/commands/work-on/investigate.md +377 -61
  46. package/commands/work-on/remediate.md +42 -9
  47. package/commands/work-on/review.md +240 -29
  48. package/commands/work-on-monolithic.md +48 -7
  49. package/commands/work-on.md +180 -2014
  50. package/docs/CODEX.md +2 -0
  51. package/docs/CONFIG.md +11 -0
  52. package/package.json +1 -1
  53. package/scripts/build-spec-graph.mjs +11 -4
  54. package/scripts/check-spec-markers.sh +11 -1
  55. package/scripts/checkbox-sections.test.sh +8 -23
  56. package/scripts/extract-affected-files.sh +5 -2
  57. package/scripts/extract-affected-files.test.sh +27 -0
  58. package/scripts/forge-root.test.sh +176 -0
  59. package/scripts/is-docs-only.sh +31 -0
  60. package/scripts/is-docs-only.test.sh +64 -0
  61. package/scripts/lint-dispatch-prompt.sh +217 -0
  62. package/scripts/lint-dispatch-prompt.test.sh +307 -0
  63. package/scripts/validate-spec-graph.sh +3 -2
  64. package/scripts/verify-phase-trail.sh +282 -0
  65. package/scripts/verify-phase-trail.test.sh +298 -0
  66. package/scripts/wait-ci-green.sh +98 -0
  67. package/scripts/wait-ci-green.test.sh +99 -0
@@ -1,18 +1,21 @@
1
1
  ---
2
2
  description: Close subcommand — update project board, final issue body, parent tracker, summary report, trajectory log
3
- argument-hint: "[issue number] [--repo GH_REPO] [--gh-flag GH_FLAG] [--pr PR_NUMBER] [--base PR_BASE] [--branch BRANCH] [--worktree WORKTREE_PATH]"
3
+ argument-hint: "[issue number] [--repo GH_REPO] [--gh-flag GH_FLAG] [--pr PR_NUMBER] [--base PR_BASE] [--branch BRANCH] [--worktree WORKTREE_PATH] --terminal-state merged|investigation|decomposed|invalid"
4
+ context: fork
4
5
  ---
5
6
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
6
7
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
7
8
 
8
9
  # work-on/close — Close & Trajectory Subcommand
9
10
 
11
+ > **Skill Name Resolution (forked phase)**: `{FORGE_SKILL_PREFIX}` is the namespace this skill itself was invoked under — invoked as `forgedock:work-on:…` → `forgedock:` (nesting `:`); as `work-on:…` → empty (`install.sh`); as `forge-work-on-…` → `forge-` (Codex, nesting `-`); OpenCode → empty with `-` nesting. Confirm the target name in the available-skills list before calling it. A forked phase receives no resolved value from its caller; never guess, and if the target skill is not listed return BLOCKED "skill not found: <name>".
12
+
10
13
  **Input**: $ARGUMENTS
11
14
 
12
- **Invoked by**: `work-on.md` Phase 6–7, after `review.md` returns `REVIEW_RESULT: status: COMPLETE`.
13
- **Output**: Update project board, close issue, update parent tracker, post trajectory log. Return final summary.
15
+ **Invoked by**: the `work-on` router, as its final step, via `Skill(skill="{FORGE_SKILL_PREFIX}work-on:close", args="...")`. This file declares `context: fork`, so it runs in an isolated sub-agent context that sees ONLY this text and its args — there is no "Phase 0 state" to rely on. Everything not passed as an arg is re-derived from GitHub/git in Phase C0. The router sees ONLY the final `CLOSE_RESULT:` block (see Output), so this file is the single source of truth for the close report, the summary card, the trajectory comment and the decision record.
16
+ **Output**: Update project board, close issue, update parent tracker, post trajectory log, post decision record, clean up the worktree. Final reply is the `CLOSE_RESULT:` block.
14
17
 
15
- **Agent model policy**: `effort: low` (mechanical tier — label transitions, annotation posting, board updates; this file is mechanical end-to-end, so a low effort level is safe here). Fallback: `model: "sonnet"` if rate-limited. Feature gate: pass `effort` only on Claude Code >= 2.1.154. **Note**: this file is dispatched via `Skill("work-on:close", ...)`, which does not support a `model` override — see `work-on.md` section "Model and Effort Tiering — What Actually Applies" for why a `model: "haiku"` claim here would not take effect. <!-- Corrected: forge#1827 -->
18
+ **Agent model policy**: `effort: low` (mechanical tier — label transitions, annotation posting, board updates; this file is mechanical end-to-end, so a low effort level is safe here). Fallback: `model: "sonnet"` if rate-limited. Feature gate: pass `effort` only on Claude Code >= 2.1.154. **Note**: this file is dispatched via `Skill("{FORGE_SKILL_PREFIX}work-on:close", ...)`, which does not support a `model` override — see `work-on.md` section "Model and Effort Tiering — What Actually Applies" for why a `model: "haiku"` claim here would not take effect. <!-- Corrected: forge#1827 -->
16
19
  **NEVER use plan mode (EnterPlanMode).**
17
20
 
18
21
  <!-- FORGE:SPEC_LOADED — work-on/close.md loaded and active. Agent is bound by this spec. -->
@@ -23,39 +26,226 @@ argument-hint: "[issue number] [--repo GH_REPO] [--gh-flag GH_FLAG] [--pr PR_NUM
23
26
 
24
27
  Parse from $ARGUMENTS:
25
28
  - `{NUMBER}` — issue number (required)
26
- - `--repo {GH_REPO}` — GitHub repo (e.g. `{owner}/{repo}` — resolved from `forge.yaml → project`)
27
- - `--gh-flag {GH_FLAG}` — gh CLI repo flag (e.g. `-R {owner}/{repo}`)
28
- - `--pr {PR_NUMBER}` — merged PR number
29
- - `--base {PR_BASE}` — branch the PR merged into (e.g. `milestone/modular-pipeline-architecture`)
29
+ - `--repo {GH_REPO}` — GitHub repo (e.g. `{owner}/{repo}`) (required)
30
+ - `--gh-flag {GH_FLAG}` — gh CLI repo flag (e.g. `-R {owner}/{repo}`) (required)
31
+ - `--pr {PR_NUMBER}` — merged PR number (required when `--terminal-state merged`; empty/absent for PR-less terminals)
32
+ - `--base {PR_BASE}` — branch the PR merged into (e.g. `staging`, `milestone/modular-pipeline-architecture`). Optional — re-derived from the PR when absent
30
33
  - `--branch {BRANCH}` — feature branch name (for worktree cleanup reference)
31
34
  - `--worktree {WORKTREE_PATH}` — absolute path to the git worktree to remove (optional — skip cleanup if not provided)
35
+ - `--terminal-state {TERMINAL_STATE}` — `merged | investigation | decomposed | invalid` (required)
36
+
37
+ Set shell variables from the parsed values (the `{X}` placeholders used throughout this file and the `$X` shell variables are the same values):
38
+
39
+ ```bash
40
+ NUMBER="{NUMBER}"; GH_REPO="{GH_REPO}"; GH_FLAG="{GH_FLAG}"; PR_NUMBER="{PR_NUMBER}"
41
+ PR_BASE="{PR_BASE}"; BRANCH="{BRANCH}"; WORKTREE_PATH="{WORKTREE_PATH}"; TERMINAL_STATE="{TERMINAL_STATE}"
42
+ # Any optional arg that was not supplied is the empty string (never the literal "{...}" text).
43
+ ```
44
+
45
+ **Fail closed.** If `{NUMBER}`, `--repo`, `--gh-flag` or `--terminal-state` is missing, if `--terminal-state` is not one of the four values, or if `--terminal-state merged` is passed without `--pr`: do NOT run any phase; print the `CLOSE_RESULT:` block with `status: FAILED` and the reason in `blocker:` (e.g. `blocker: missing required arg: --terminal-state`) as the final reply and STOP.
46
+
47
+ **Failure handling (status: FAILED)**: MANDATORY phases (C1, C2, C5) retry a failed `gh` write once; if it still fails, STOP at that point and print `CLOSE_RESULT: status: FAILED` with `blocker: "<phase>: <error>"`. Do NOT run C6 after a FAILED stop (the worktree is kept so the run can be retried). Phases marked non-blocking (C1.7, C1.5, C5.1–C5.5) log and continue and never produce FAILED. Every exit path — success, ALREADY_DONE, PHASE_COMPLETE, every guard, every failure — prints the `CLOSE_RESULT:` block.
48
+
49
+ **Terminal-state routing** — which phases run:
50
+
51
+ | `--terminal-state` | Runs | Skips (and why) |
52
+ |---|---|---|
53
+ | `merged` | C0 → C6, all phases | — |
54
+ | `investigation` | C0, C0.5, C1, C1.5, C2, C3, C4, C4.5, C5, C5.1–C5.4, C6 | C1.7, C5.5 (no PR); PR lines in the issue body, report and trajectory use the "no PR" form |
55
+ | `decomposed` | C0, C0.5, C4, C4.5, C5, C5.1–C5.4, C6 | C1, C1.7, C1.5, C2, C3, C5.5 — the parent issue stays OPEN with `workflow:decomposed` (owned by the decompose phase) |
56
+ | `invalid` | C0, C0.5, C4, C4.5, C5, C6 | C1, C1.7, C1.5, C2, C3, C5.1–C5.5 — the issue was already closed with `workflow:invalid` by the investigate phase |
57
+
58
+ Every `gh pr view {PR_NUMBER}` and every other PR-dependent step in this file is guarded on a non-empty `PR_NUMBER`: with an empty PR, the PR-derived values render as `—` and nothing aborts.
59
+
60
+ ## Script resolution
61
+
62
+ Resolve the repo path first (needed by the dossier append, the knowledge indexer and the script resolver), then expand the canonical resolver block. Re-run both blocks if shell variables were lost between tool calls.
63
+
64
+ ```bash
65
+ # REPO_PATH = main checkout root. From the worktree when one was passed (linked worktrees share a git common dir), else the current repo.
66
+ if [ -n "$WORKTREE_PATH" ] && [ -d "$WORKTREE_PATH" ]; then
67
+ _gc=$(git -C "$WORKTREE_PATH" rev-parse --git-common-dir 2>/dev/null)
68
+ case "$_gc" in /*) ;; *) _gc="$WORKTREE_PATH/$_gc" ;; esac
69
+ REPO_PATH=$(dirname "$(cd "$_gc" 2>/dev/null && pwd)")
70
+ fi
71
+ [ -n "${REPO_PATH:-}" ] && [ -d "$REPO_PATH" ] || REPO_PATH=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
72
+ ```
73
+
74
+ **Shell state does not persist between Bash tool calls** (each call is a fresh shell): paste the block below at the top of every bash command in this skill that calls `resolve_script` or uses `FORGE_ROOT`/`UNIVERSAL_DIR`.
75
+
76
+ ```bash
77
+ # Canonical script resolution (keep byte-identical across specs; guarded by scripts/forge-root.test.sh).
78
+ # Shell state does NOT persist between Bash tool calls: include this whole block at the top of
79
+ # every command that uses resolve_script or FORGE_ROOT.
80
+ REPO_PATH="${REPO_PATH:-$(yq '.paths.root // ""' forge.yaml 2>/dev/null)}"
81
+ [ -n "$REPO_PATH" ] && [ "$REPO_PATH" != "null" ] || REPO_PATH="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
82
+ ADAPTIVE_DIR_RAW="${REPO_PATH}/$(yq '.adaptive_scripts.directory // ".forgedock/scripts"' forge.yaml 2>/dev/null || echo '.forgedock/scripts')"
83
+ ADAPTIVE_DIR=$(realpath -m "$ADAPTIVE_DIR_RAW" 2>/dev/null || echo "$ADAPTIVE_DIR_RAW")
84
+ ADAPTIVE_ENABLED=$(yq '.adaptive_scripts.enabled // "true"' forge.yaml 2>/dev/null || echo 'true')
85
+ # Bounds check: reject adaptive_scripts.directory values that escape the repo root.
86
+ # Normalize REPO_PATH the same way ADAPTIVE_DIR is normalized (realpath -m) so a trailing
87
+ # slash in paths.root does not inject a '//' into the glob and trigger a false positive.
88
+ REPO_PATH_NORM=$(realpath -m "$REPO_PATH" 2>/dev/null || echo "$REPO_PATH")
89
+ if [[ "$ADAPTIVE_DIR" != "${REPO_PATH_NORM}/"* ]]; then
90
+ echo "WARNING: adaptive_scripts.directory resolves outside repo root ('$ADAPTIVE_DIR') — adaptive tier disabled" >&2
91
+ ADAPTIVE_ENABLED=false
92
+ fi
93
+ # ForgeDock's own install root (holds scripts/ and commands/), resolved ONCE by the canonical bootstrap
94
+ # below — never the consumer repo: plugin installs set neither FORGE_HOME nor FORGEDOCK_HOME, and a
95
+ # repo-relative fallback would execute (or miss) a same-named script controlled by the consumer repo.
96
+ # Resolution: $FORGEDOCK_HOME (authoritative when set) > $FORGE_HOME > $CLAUDE_PLUGIN_ROOT > the
97
+ # ~/.claude/commands symlink target > the Claude Code plugin cache/marketplace dirs. <!-- forge#3098 -->
98
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
99
+ FORGE_ROOT=""
100
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
101
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
102
+ _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
103
+ # newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
104
+ _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5-)"
105
+ _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
106
+ # '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
107
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
108
+ while IFS= read -r _c; do
109
+ case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
110
+ done <<< "$_k"
111
+ fi
112
+ UNIVERSAL_DIR="${FORGE_ROOT:+$FORGE_ROOT/scripts}" # empty => tier 3 skipped, prose tier
113
+ # NOTE: never resolve this via `which` or `find` — universal scripts are
114
+ # install-relative, not installed on $PATH, so a PATH lookup always misses.
115
+ # FORGE_ROOT (above) is the deterministic resolution; there is NO repo-path fallback.
116
+ # Pipeline agents MUST NOT use `find` (unbounded or filesystem-wide) to
117
+ # locate pipeline scripts under any circumstances: if UNIVERSAL_DIR/${operation}.sh
118
+ # does not exist, resolve_script() falls through to Tier 4 (prose) below,
119
+ # which is always safe and available. A missing script is never a reason
120
+ # to search the filesystem. <!-- Added: forge#1984 -->
121
+
122
+ resolve_script() {
123
+ local operation="$1"
124
+ # Tier 2: per-repo adaptive (skip if disabled)
125
+ if [ "$ADAPTIVE_ENABLED" != "false" ] && [ -f "${ADAPTIVE_DIR}/${operation}.sh" ]; then
126
+ echo "adaptive:${ADAPTIVE_DIR}/${operation}.sh"
127
+ return
128
+ fi
129
+ # Tier 3: universal script
130
+ if [ -n "$UNIVERSAL_DIR" ] && [ -f "${UNIVERSAL_DIR}/${operation}.sh" ]; then
131
+ echo "universal:${UNIVERSAL_DIR}/${operation}.sh"
132
+ return
133
+ fi
134
+ # Tier 4: prose fallback
135
+ echo "prose:"
136
+ }
137
+ ```
138
+
139
+ Workflow-state transitions use the tiered `transition-label` script (adaptive → universal → prose). A transition to state X removes every other `workflow:*` label (full set: investigating, ready-to-build, building, in-review, awaiting-merge, merged, invalid, decomposed). `set_workflow_state <issue> <state>` is used by C2 and C3:
140
+
141
+ ```bash
142
+ set_workflow_state() {
143
+ local _issue="$1" _state="$2" _res _tier _path _s _rm=""
144
+ _res=$(resolve_script 'transition-label'); _tier="${_res%%:*}"; _path="${_res#*:}"
145
+ case "$_tier" in
146
+ adaptive|universal) bash "$_path" "$_issue" $GH_FLAG "$_state" ;;
147
+ prose)
148
+ for _s in investigating ready-to-build building in-review awaiting-merge merged invalid decomposed; do
149
+ [ "$_s" = "$_state" ] || _rm="${_rm:+$_rm,}workflow:$_s"
150
+ done
151
+ gh issue edit "$_issue" $GH_FLAG --add-label "workflow:$_state" --remove-label "$_rm" 2>/dev/null || true # allowlist:check-command-side-effects
152
+ ;;
153
+ esac
154
+ }
155
+ ```
32
156
 
33
157
  ---
34
158
 
35
159
  ## Phase C0: Load State from GitHub (MANDATORY)
36
160
 
37
- Re-read current state before doing anything:
161
+ Re-read current state before doing anything, and re-derive every value this file used to receive from the router. All derivations are best-effort: a value that cannot be found degrades (`—`, `unknown`, `0`) and never aborts the close.
38
162
 
39
163
  ```bash
40
164
  # Issue full context
41
- gh issue view {NUMBER} {GH_FLAG} --json number,title,body,labels,state,milestone
42
-
43
- # PR state
44
- gh pr view {PR_NUMBER} {GH_FLAG} --json state,mergedAt,mergeCommit
165
+ ISSUE_JSON=$(gh issue view {NUMBER} {GH_FLAG} --json number,title,body,labels,state,milestone)
166
+ TITLE=$(printf '%s' "$ISSUE_JSON" | jq -r '.title // ""')
167
+ ISSUE_STATE=$(printf '%s' "$ISSUE_JSON" | jq -r '.state // "UNKNOWN"')
168
+
169
+ # PR state — only when a PR exists (PR-less terminals: investigation | decomposed | invalid)
170
+ PR_STATE=""; PR_BASE_REF=""
171
+ if [ -n "$PR_NUMBER" ]; then
172
+ PR_JSON=$(gh pr view {PR_NUMBER} {GH_FLAG} --json state,mergedAt,mergeCommit,baseRefName 2>/dev/null || echo "")
173
+ PR_STATE=$(printf '%s' "$PR_JSON" | jq -r '.state // ""' 2>/dev/null)
174
+ PR_BASE_REF=$(printf '%s' "$PR_JSON" | jq -r '.baseRefName // ""' 2>/dev/null)
175
+ fi
176
+ [ -n "$PR_BASE" ] || PR_BASE="$PR_BASE_REF"
45
177
 
46
- # All agent comments (to reconstruct pipeline results)
47
- gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
48
- --jq '.[] | {body: .body, created_at: .created_at}'
178
+ # All agent comments (to reconstruct pipeline results) — one paginated read, reused below
179
+ COMMENTS_JSON=$(gh api --paginate repos/{GH_REPO}/issues/{NUMBER}/comments 2>/dev/null | jq -s 'add // []' 2>/dev/null || echo '[]')
180
+ [ -n "$COMMENTS_JSON" ] || COMMENTS_JSON='[]'
181
+ last_comment_body() { # $1 = marker substring; prints the newest matching comment body (or empty)
182
+ printf '%s' "$COMMENTS_JSON" | jq -r --arg m "$1" '[.[] | select(.body | contains($m)) | .body] | last // ""' 2>/dev/null
183
+ }
184
+ INVESTIGATOR_BODY=$(last_comment_body "FORGE:INVESTIGATOR")
185
+ BUILDER_BODY=$(last_comment_body "FORGE:BUILDER")
186
+ FAST_PATH_BODY=$(last_comment_body "FORGE:FAST_PATH")
49
187
  ```
50
188
 
51
- Extract from agent comments:
52
- - From FORGE:INVESTIGATOR: verdict, confidence, task type
53
- - From FORGE:BUILDER: branch, commits, files changed
54
- - From FORGE:TRAJECTORY (if exists): prior trajectory entries
189
+ **Merged guard**: if `TERMINAL_STATE` is `merged` and `PR_STATE` is not `MERGED` → print `CLOSE_RESULT: status: FAILED` with `blocker: "PR #{PR_NUMBER} is not merged (state: <PR_STATE>)"` and STOP.
55
190
 
56
191
  **Resume check**:
57
- - If `<!-- FORGE:TRAJECTORY -->` comment already exists → trajectory already posted, EXIT with `CLOSE_RESULT: status: ALREADY_DONE`
58
- - If issue is already CLOSED and PR is MERGED → skip to C3 (just post trajectory if missing)
192
+ - If a `FORGE:TRAJECTORY` comment already exists (`last_comment_body "FORGE:TRAJECTORY"` is non-empty) → trajectory already posted. Print `CLOSE_RESULT: status: ALREADY_DONE` (with `trajectory_url` = that comment's `html_url`, `issue_state` from the live issue) and STOP.
193
+ - If the issue is already CLOSED and the PR is MERGED → C1, C1.7, C1.5 and C2 already ran: set `REMAINING_AFTER=0` and continue at C3 (then C4 → C4.5 → C5 → … — the trajectory is missing, and C5 posts it).
194
+
195
+ ### Derive the values the router no longer passes
196
+
197
+ ```bash
198
+ # --- Lane: --base wins (staging -> fast, milestone/* -> feature); otherwise the PR's baseRefName; otherwise unknown ---
199
+ lane_from_base() { case "$1" in staging) echo fast ;; milestone/*) echo feature ;; *) echo "" ;; esac; }
200
+ LANE=$(lane_from_base "$PR_BASE")
201
+ [ -n "$LANE" ] || LANE=$(lane_from_base "$PR_BASE_REF")
202
+ if [ -z "$LANE" ]; then
203
+ if [ -n "$PR_BASE" ]; then LANE=feature; else LANE=unknown; fi # any other base (e.g. main) is treated as feature
204
+ fi
205
+ case "$LANE" in fast) LANE_LABEL="FAST" ;; feature) LANE_LABEL="FEATURE" ;; *) LANE_LABEL="UNKNOWN" ;; esac
206
+
207
+ # --- Investigation verdict / confidence (FORGE:INVESTIGATOR) — ERE/sed only, no PCRE ---
208
+ VERDICT=$(printf '%s\n' "$INVESTIGATOR_BODY" | sed -n 's/.*\*\*Verdict\*\*: *\([A-Za-z0-9_]*\).*/\1/p' | head -1)
209
+ CONFIDENCE=$(printf '%s\n' "$INVESTIGATOR_BODY" | sed -n 's/.*\*\*Confidence\*\*: *\([A-Za-z0-9_]*\).*/\1/p' | head -1)
210
+
211
+ # --- Task type + complexity band (FORGE:FAST_PATH; fall back to the investigator's task type) ---
212
+ TASK_TYPE=$(printf '%s\n' "$FAST_PATH_BODY" | sed -n 's/.*\*\*Task [Tt]ype\*\*: *\(.*[^ ]\) *$/\1/p' | head -1)
213
+ [ -n "$TASK_TYPE" ] || TASK_TYPE=$(printf '%s\n' "$INVESTIGATOR_BODY" | sed -n 's/.*\*\*Task Type\*\*: *\(.*[^ ]\) *$/\1/p' | head -1)
214
+ TASK_TYPE=${TASK_TYPE:-unknown}
215
+ COMPLEXITY_BAND=$(printf '%s\n' "$FAST_PATH_BODY" | sed -n 's/.*\*\*COMPLEXITY_BAND\*\*: *\([A-Za-z_]*\).*/\1/p' | head -1 | tr '[:lower:]' '[:upper:]')
216
+ COMPLEXITY_BAND=${COMPLEXITY_BAND:-unknown}
217
+
218
+ # --- Files changed (FORGE:BUILDER) ---
219
+ FILES_CHANGED=$(printf '%s\n' "$BUILDER_BODY" | sed -n 's/.*\*\*Files changed\*\*: *\([0-9][0-9]*\).*/\1/p' | head -1)
220
+ FILES_CHANGED=${FILES_CHANGED:-—}
221
+ case "$FILES_CHANGED" in ''|*[!0-9]*) FILES_CHANGED_JSON=null ;; *) FILES_CHANGED_JSON="$FILES_CHANGED" ;; esac
222
+
223
+ # --- Quality gate: one FORGE:QUALITY_GATE comment per gate run => iteration count ---
224
+ GATE_ITERATIONS=$(printf '%s' "$COMMENTS_JSON" | jq '[.[] | select(.body | contains("FORGE:QUALITY_GATE"))] | length' 2>/dev/null)
225
+ GATE_ITERATIONS=${GATE_ITERATIONS:-0}
226
+ GATE_RESULT=$(last_comment_body "FORGE:QUALITY_GATE" | sed -n 's/.*\*\*Result\*\*: *\([A-Za-z]*\).*/\1/p' | head -1 | tr '[:upper:]' '[:lower:]')
227
+ if [ "$GATE_ITERATIONS" -eq 0 ]; then GATE_ROW="⏭ No gate record"; GATE_NOTE="docs-only change or gate marker not recorded"
228
+ elif [ "$GATE_RESULT" = "pass" ]; then GATE_ROW="✅ Gate passed"; GATE_NOTE="${GATE_ITERATIONS} iteration(s)"
229
+ else GATE_ROW="⚠ Gate result: ${GATE_RESULT:-unknown}"; GATE_NOTE="${GATE_ITERATIONS} iteration(s)"; fi
230
+ GATE_PASS_FAIL=${GATE_RESULT:-unknown}
231
+
232
+ # --- Verification status: the "**Verification Status**" line of the FORGE:BUILDER comment ---
233
+ VERIFICATION_LINE=$(printf '%s\n' "$BUILDER_BODY" | sed -n 's/.*\*\*Verification Status\*\*: *\(.*\)$/\1/p' | head -1)
234
+ VERIFICATION_SKIPPED_CHECKS=""
235
+ case "$VERIFICATION_LINE" in
236
+ "") VERIFICATION_ROW="— (no verification status recorded)" ;;
237
+ *"Verification NOT run:"*)
238
+ VERIFICATION_SKIPPED_CHECKS=$(printf '%s' "$VERIFICATION_LINE" | sed 's/.*Verification NOT run: *//; s/ — verification.commands.*//')
239
+ VERIFICATION_ROW="⚠ Skipped — verification.commands not configured for: ${VERIFICATION_SKIPPED_CHECKS}" ;;
240
+ *) VERIFICATION_ROW="✅ Ran" ;;
241
+ esac
242
+
243
+ # --- PR reference line for the issue body (PR-less terminals use the no-PR form) ---
244
+ if [ -n "$PR_NUMBER" ]; then PR_LINE="**PR**: #${PR_NUMBER} → merged to \`${PR_BASE}\`"
245
+ else PR_LINE="**Result**: ${TERMINAL_STATE} (no PR)"; fi
246
+ ```
247
+
248
+ Also available from the comments read above: from FORGE:INVESTIGATOR — verdict, confidence, task type; from FORGE:BUILDER — branch, commits, files changed; from FORGE:TRAJECTORY (if exists) — prior trajectory entries.
59
249
 
60
250
  ---
61
251
 
@@ -69,6 +259,12 @@ log — it does NOT abort the close phase (advisory enforcement: flag, then cont
69
259
  **Skip if**: `forge-invariants.yaml` is absent or `bin/engine/invariants.mjs`
70
260
  is unavailable (e.g. fresh install before this file ships). Fail-open.
71
261
 
262
+ The evaluator module is imported from ForgeDock's OWN install root (`FORGE_ROOT`, else
263
+ `FORGEDOCK_HOME`, else `FORGE_HOME` — absolute paths only), never from the consumer cwd, so a
264
+ consumer repo cannot supply the JS that runs here. If none of them holds
265
+ `bin/engine/invariants.mjs`, the check is skipped (fail-open). Paths are passed as argv and
266
+ converted with `pathToFileURL`, never concatenated into a `file://` string.
267
+
72
268
  ```bash
73
269
  # Read local run-log for this issue (absolute path matches engine run-log dir)
74
270
  RUN_LOG_DIR="${HOME}/.forge/runs"
@@ -76,16 +272,21 @@ RUN_LOG_FILE="${RUN_LOG_DIR}/{NUMBER}.jsonl"
76
272
 
77
273
  INVARIANT_ANOMALIES=""
78
274
 
79
- if [ -f "${RUN_LOG_FILE}" ] && [ -f "$(dirname "$(which node)")/node" ] 2>/dev/null; then
275
+ INV_MODULE=""
276
+ for _r in "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}"; do
277
+ case "$_r" in /*) [ -z "$INV_MODULE" ] && [ -r "$_r/bin/engine/invariants.mjs" ] && INV_MODULE="$_r/bin/engine/invariants.mjs" ;; esac
278
+ done
279
+
280
+ if [ -n "$INV_MODULE" ] && [ -f "${RUN_LOG_FILE}" ] && [ -f "$(dirname "$(which node)")/node" ] 2>/dev/null; then
80
281
  # Check close-scope invariants via the evaluator
81
282
  INVARIANT_RESULT=$(node -e "
82
- import(new URL('file://$(pwd)/bin/engine/invariants.mjs'))
283
+ import(require('node:url').pathToFileURL(process.argv[1]).href)
83
284
  .then(m => {
84
- const decls = m.loadInvariants('$(pwd)/forge-invariants.yaml');
285
+ const decls = m.loadInvariants(process.argv[2]);
85
286
  const fs = require('fs');
86
287
  let events = [];
87
288
  try {
88
- const lines = fs.readFileSync('${RUN_LOG_FILE}', 'utf-8').split('\n').filter(Boolean);
289
+ const lines = fs.readFileSync(process.argv[3], 'utf-8').split('\n').filter(Boolean);
89
290
  events = lines.flatMap(l => { try { return [JSON.parse(l)]; } catch { return []; } });
90
291
  } catch {}
91
292
  const results = m.assertCloseInvariants(decls, events);
@@ -96,7 +297,7 @@ if [ -f "${RUN_LOG_FILE}" ] && [ -f "$(dirname "$(which node)")/node" ] 2>/dev/n
96
297
  }
97
298
  })
98
299
  .catch(() => process.exit(0)); // fail-open on any error
99
- " 2>&1) || INVARIANT_ANOMALIES="${INVARIANT_RESULT}"
300
+ " "$INV_MODULE" "$(pwd)/forge-invariants.yaml" "${RUN_LOG_FILE}" 2>&1) || INVARIANT_ANOMALIES="${INVARIANT_RESULT}"
100
301
 
101
302
  if [ -n "$INVARIANT_ANOMALIES" ]; then
102
303
  echo "CLOSE-SCOPE INVARIANT ANOMALY (flagging — close continues):"
@@ -112,7 +313,7 @@ fi
112
313
  CLOSE_INVARIANT_ISSUE_CHECK=true
113
314
  ```
114
315
 
115
- **Post-C2 check** (evaluate after Phase C2 runs the `gh issue close` command):
316
+ **Post-C2 check** (evaluate after Phase C2 runs the `gh issue close` command; only on paths where C2 reached its close branch — `REMAINING_AFTER == 0` and `TERMINAL_STATE` is `merged` or `investigation` — otherwise skip it):
116
317
 
117
318
  ```bash
118
319
  if [ "${CLOSE_INVARIANT_ISSUE_CHECK:-false}" = "true" ]; then
@@ -125,13 +326,17 @@ if [ "${CLOSE_INVARIANT_ISSUE_CHECK:-false}" = "true" ]; then
125
326
  fi
126
327
  ```
127
328
 
128
- The `INVARIANT_ANOMALIES` variable is read in Phase C4.5 (trajectory post) and written to the **Anomalies** field.
329
+ The `INVARIANT_ANOMALIES` variable is read in Phase C5 (Step 1) and rendered in the trajectory's **Anomalies** field (joined with `; `; `None` only when it is empty and the review ran).
129
330
 
130
331
  ---
131
332
 
132
333
 
133
334
  ## Phase C1: Final Issue Body Update
134
335
 
336
+ **Skip if**: `TERMINAL_STATE` is `decomposed` or `invalid` (set `REMAINING_AFTER=0`; nothing to check off).
337
+
338
+ **Fresh read — intentionally NOT covered by the session-state cache.** The body is about to be rewritten below, and the review phase's `/review-pr` invocation is an external process that can post comments/edits between the last read and here. Writing back a stale cached body would silently revert any concurrent change, so this file always fetches the body fresh immediately before a body mutation (the parent body in Phase C3 likewise).
339
+
135
340
  **Multi-phase guard**: Before checking off items, detect whether the issue has multiple phases. Only check off items belonging to the current completed phase — not all remaining items across future phases.
136
341
 
137
342
  ```bash
@@ -144,8 +349,8 @@ REMAINING_BEFORE=$(printf '%s\n' "$BODY" | grep -cE '^[-*+] \[ \]' || true)
144
349
 
145
350
  **If `REMAINING_BEFORE == 0`** (no unchecked items): skip body edit — all items already checked, proceed to add PR reference only:
146
351
  ```bash
147
- UPDATED_BODY="${BODY}"$'\n\n'"**PR**: #{PR_NUMBER} → merged to \`{PR_BASE}\`"
148
- gh issue edit {NUMBER} {GH_FLAG} --body "{UPDATED_BODY}"
352
+ UPDATED_BODY="${BODY}"$'\n\n'"${PR_LINE}"
353
+ gh issue edit {NUMBER} {GH_FLAG} --body "$UPDATED_BODY" # allowlist:check-command-side-effects
149
354
  REMAINING_AFTER=0
150
355
  ```
151
356
 
@@ -184,21 +389,21 @@ CHECKBOX_SECTIONS=$(printf '%s\n' "$BODY_STRIPPED" | awk '
184
389
  # issue forces multi-phase.
185
390
  SUBISSUE_ITEMS=$(printf '%s\n' "$BODY_STRIPPED" | grep -cE '^[-*+] \[ \] #[0-9]+' || true)
186
391
 
187
- # Keep this consuming guard synchronized with Phase 6A's Sync invariant in
188
- # commands/work-on.md.
392
+ # Keep this classifier and consuming guard unchanged unless scripts/checkbox-sections.test.sh
393
+ # is updated with them.
189
394
  # Both counters are default-expanded: a failed extraction yields an empty string,
190
395
  # not 0, which would make the integer test error out rather than evaluate false.
191
396
  if [ "${CHECKBOX_SECTIONS:-0}" -ge 2 ] || [ "${SUBISSUE_ITEMS:-0}" -gt 0 ]; then
192
397
  # Multi-phase issue: do NOT check off any [ ] items
193
398
  # Only add the PR reference so progress is recorded
194
- UPDATED_BODY="${BODY}"$'\n\n'"**PR**: #{PR_NUMBER} → merged to \`{PR_BASE}\` (phase complete — remaining phases open)"
195
- gh issue edit {NUMBER} {GH_FLAG} --body "{UPDATED_BODY}"
399
+ UPDATED_BODY="${BODY}"$'\n\n'"${PR_LINE} (phase complete — remaining phases open)"
400
+ gh issue edit {NUMBER} {GH_FLAG} --body "$UPDATED_BODY" # allowlist:check-command-side-effects
196
401
  REMAINING_AFTER="$REMAINING_BEFORE"
197
402
  else
198
403
  # Single-phase issue: check off all remaining GFM task items
199
404
  UPDATED_BODY=$(printf '%s\n' "$BODY" | sed 's/^\([-*+]\) \[ \]/\1 [x]/g')
200
- UPDATED_BODY="${UPDATED_BODY}"$'\n\n'"**PR**: #{PR_NUMBER} → merged to \`{PR_BASE}\`"
201
- gh issue edit {NUMBER} {GH_FLAG} --body "{UPDATED_BODY}"
405
+ UPDATED_BODY="${UPDATED_BODY}"$'\n\n'"${PR_LINE}"
406
+ gh issue edit {NUMBER} {GH_FLAG} --body "$UPDATED_BODY" # allowlist:check-command-side-effects
202
407
  REMAINING_AFTER=0
203
408
  fi
204
409
  ```
@@ -213,7 +418,7 @@ The `REMAINING_AFTER` variable is passed to Phase C2 to decide whether to close.
213
418
 
214
419
  **This phase is non-blocking** — if the dossier write fails, log the reason and continue to Phase C1.5. Never stall close for dossier maintenance.
215
420
 
216
- **Skip if**: `{PR_NUMBER}` is empty (investigation-only tasks) OR `{REPO_PATH}` is unset OR `devdocs/index.yaml` does not contain a `modules:` section OR no PR files match any module glob.
421
+ **Skip if**: `{PR_NUMBER}` is empty (investigation / decomposed / invalid terminals — `TERMINAL_STATE` is not `merged`) OR `$REPO_PATH` is unset OR `devdocs/index.yaml` does not contain a `modules:` section OR no PR files match any module glob.
217
422
 
218
423
  ### Step 1: Resolve affected files from FORGE:BUILDER comment
219
424
 
@@ -252,7 +457,7 @@ fi
252
457
  ```bash
253
458
  CONFIG_FILE="${FORGE_CONFIG:-forge.yaml}"
254
459
  DEVDOCS_REL=$(yq '.devdocs.path // "devdocs"' "$CONFIG_FILE" 2>/dev/null || echo "devdocs")
255
- DEVDOCS_PATH="${REPO_PATH:-{REPO_PATH}}/${DEVDOCS_REL}"
460
+ DEVDOCS_PATH="${REPO_PATH}/${DEVDOCS_REL}"
256
461
  INDEX_PATH="${DEVDOCS_PATH}/index.yaml"
257
462
 
258
463
  if [ ! -f "$INDEX_PATH" ]; then
@@ -359,7 +564,7 @@ done <<< "$MODULE_ENTRIES"
359
564
  ```bash
360
565
  if [ -n "$DOSSIER_UPDATED_MODULES" ]; then
361
566
  # Commit the updated dossier files
362
- cd "{REPO_PATH}"
567
+ cd "${REPO_PATH}"
363
568
  CHANGED_DOSSIER_FILES=$(echo "$DOSSIER_UPDATED_MODULES" | tr ' ' '\n' | while IFS= read -r mod; do
364
569
  yq ".modules[]? | select(.name == \"${mod}\") | \"${DEVDOCS_REL}/\" + .path" "$INDEX_PATH" 2>/dev/null
365
570
  done | grep -v '^$')
@@ -369,11 +574,11 @@ if [ -n "$DOSSIER_UPDATED_MODULES" ]; then
369
574
  # spaces are staged individually rather than word-split by the shell.
370
575
  while IFS= read -r dossier_file; do
371
576
  [ -n "$dossier_file" ] || continue
372
- git -C "{REPO_PATH}" add "$dossier_file" 2>/dev/null || true
577
+ git -C "${REPO_PATH}" add "$dossier_file" 2>/dev/null || true
373
578
  done <<< "$CHANGED_DOSSIER_FILES"
374
579
  # Only commit if there are staged changes (new or modified dossier files)
375
- if ! git -C "{REPO_PATH}" diff --cached --quiet 2>/dev/null; then
376
- git -C "{REPO_PATH}" commit -s -m "docs(dossier): append entry for PR #{PR_NUMBER} (#${NUMBER})" 2>/dev/null || true
580
+ if ! git -C "${REPO_PATH}" diff --cached --quiet 2>/dev/null; then
581
+ git -C "${REPO_PATH}" commit -s -m "docs(dossier): append entry for PR #{PR_NUMBER} (#${NUMBER})" 2>/dev/null || true
377
582
  echo "Phase C1.7: Dossier commit created for modules:${DOSSIER_UPDATED_MODULES}"
378
583
  else
379
584
  echo "Phase C1.7: No staged dossier changes — skipping commit"
@@ -397,9 +602,11 @@ fi
397
602
 
398
603
  ## Phase C1.5: Project Board Update (Status=Done, Workflow=Merged)
399
604
 
605
+ **Skip if**: `TERMINAL_STATE` is `decomposed` or `invalid`. Non-blocking: a missing board, item or option never stalls the close.
606
+
400
607
  Update the project board to reflect the merged state. This replaces the old Phase 5E project board update that existed before the modular refactor.
401
608
 
402
- **Read project board config from `forge.yaml`**. If the `project_board` section is absent or commented out, skip this phase entirely:
609
+ **Read project board config from `forge.yaml → project_board`** (`owner`, `project_number`, `project_id`, `field_ids`, `option_ids`). **Fallback**: if the `project_board` section is absent, fall back to `forge.yaml → project.owner` and project number `1`, and resolve the project id, field ids and option ids from the board itself (`gh project view` / `gh project field-list`). If even that yields no owner, project id or Status field, skip the board update:
403
610
 
404
611
  ```bash
405
612
  # Read project board config from forge.yaml
@@ -412,8 +619,23 @@ WORKFLOW_FIELD_ID=$(yq '.project_board.field_ids.workflow // ""' "$CONFIG_FILE"
412
619
  STATUS_DONE_OPTION_ID=$(yq '.project_board.option_ids.status.done // ""' "$CONFIG_FILE" 2>/dev/null || echo "")
413
620
  WORKFLOW_MERGED_OPTION_ID=$(yq '.project_board.option_ids.workflow.merged // ""' "$CONFIG_FILE" 2>/dev/null || echo "")
414
621
 
622
+ # project_board section absent -> fall back to project.owner + project number 1 and discover the ids
623
+ if [ -z "$PROJECT_BOARD_OWNER" ] && [ -z "$PROJECT_ID" ]; then
624
+ PROJECT_BOARD_OWNER=$(yq '.project.owner // ""' "$CONFIG_FILE" 2>/dev/null || echo "")
625
+ PROJECT_NUMBER="${PROJECT_NUMBER:-1}"
626
+ if [ -n "$PROJECT_BOARD_OWNER" ]; then
627
+ PROJECT_ID=$(gh project view "$PROJECT_NUMBER" --owner "$PROJECT_BOARD_OWNER" --format json --jq '.id' 2>/dev/null || echo "")
628
+ FIELDS_JSON=$(gh project field-list "$PROJECT_NUMBER" --owner "$PROJECT_BOARD_OWNER" --format json 2>/dev/null || echo "")
629
+ STATUS_FIELD_ID=$(printf '%s' "$FIELDS_JSON" | jq -r '.fields[]? | select(.name == "Status") | .id' 2>/dev/null | head -1)
630
+ STATUS_DONE_OPTION_ID=$(printf '%s' "$FIELDS_JSON" | jq -r '.fields[]? | select(.name == "Status") | .options[]? | select(.name == "Done") | .id' 2>/dev/null | head -1)
631
+ WORKFLOW_FIELD_ID=$(printf '%s' "$FIELDS_JSON" | jq -r '.fields[]? | select(.name == "Workflow") | .id' 2>/dev/null | head -1)
632
+ WORKFLOW_MERGED_OPTION_ID=$(printf '%s' "$FIELDS_JSON" | jq -r '.fields[]? | select(.name == "Workflow") | .options[]? | select(.name == "Merged") | .id' 2>/dev/null | head -1)
633
+ fi
634
+ fi
635
+ PROJECT_NUMBER="${PROJECT_NUMBER:-1}"
636
+
415
637
  if [ -z "$PROJECT_BOARD_OWNER" ] || [ -z "$PROJECT_ID" ] || [ -z "$STATUS_FIELD_ID" ]; then
416
- echo "INFO: project_board not configured in forge.yaml — skipping board update"
638
+ echo "INFO: project board not configured or not discoverable — skipping board update"
417
639
  # → STOP: do not proceed to ITEM_ID fetch or board update — continue to Phase C2
418
640
  else
419
641
  # Project board is configured — find the item and update it
@@ -462,7 +684,9 @@ To find your project IDs: `gh project list --owner {owner}` and `gh project fiel
462
684
 
463
685
  ## Phase C2: Ensure Issue is Closed
464
686
 
465
- **Multi-phase guard**: If `REMAINING_AFTER > 0` (set in Phase C1), uncompleted phases remain — do NOT close the issue. Instead, post a phase-complete comment and exit early so the router can pick up the next phase on the next pipeline iteration.
687
+ **Skip if**: `TERMINAL_STATE` is `decomposed` or `invalid` (the issue is not closed by this phase — see Terminal-state routing).
688
+
689
+ **Multi-phase guard**: If `REMAINING_AFTER > 0` (set in Phase C1), uncompleted phases remain — do NOT close the issue. Instead, post a phase-complete comment and return early so the router can pick up the next phase.
466
690
 
467
691
  ```bash
468
692
  ISSUE_STATE=$(gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state')
@@ -470,107 +694,108 @@ ISSUE_STATE=$(gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state')
470
694
 
471
695
  **If `REMAINING_AFTER > 0`** (multi-phase: uncompleted phases remain):
472
696
  ```bash
473
- # Post phase-complete marker — work-on.md's Universal continuation rule will re-read labels and continue to the next phase
697
+ # Post phase-complete marker — the router's continuation rule re-reads labels and continues to the next phase
474
698
  gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:PHASE:COMPLETE -->
475
- Phase complete. PR #{PR_NUMBER} merged to \`{PR_BASE}\`. ${REMAINING_AFTER} phase item(s) remain — leaving issue open for next pipeline iteration."
699
+ Phase complete. PR #{PR_NUMBER} merged to \`{PR_BASE}\`. ${REMAINING_AFTER} phase item(s) remain — leaving issue open for next pipeline iteration." # allowlist:check-command-side-effects
700
+
701
+ # Reset the workflow label to investigating so the router has a signal for which phase comes next
702
+ # (fixes: issue #1381). The transition removes every other workflow:* label (tiered transition-label dispatch).
703
+ set_workflow_state {NUMBER} investigating
704
+ ```
476
705
 
477
- # Update labels to reflect phase complete — remove current-phase labels and set next-phase label
478
- # so the router has a signal for which phase comes next (fixes: issue #1381)
479
- gh issue edit {NUMBER} {GH_FLAG} \
480
- --remove-label "workflow:in-review,workflow:building,workflow:investigating" \
481
- --add-label "workflow:investigating" 2>/dev/null || true
706
+ Then STOP — do not close, do not post a trajectory, do not run C3–C6 (the worktree is kept for the next phase). Print the final reply:
482
707
 
483
- # EXIT — do not close, do not post trajectory, do not run C3–C6
484
- # Return CLOSE_RESULT: status: PHASE_COMPLETE to caller
485
- exit 0
708
+ ```
709
+ CLOSE_RESULT:
710
+ status: PHASE_COMPLETE
711
+ issue_state: open
712
+ trajectory_url:
713
+ decision_record_url:
714
+ parent_updated: false
715
+ parent_closed: false
716
+ blocker:
486
717
  ```
487
718
 
488
719
  **If `REMAINING_AFTER == 0`** (all phases complete — single-phase or final phase of multi-phase):
489
720
 
490
721
  If state is `OPEN`:
491
722
  ```bash
492
- gh issue close {NUMBER} {GH_FLAG} \
493
- --comment "Closed: PR #{PR_NUMBER} merged to \`{PR_BASE}\`. Closes #{NUMBER}."
723
+ if [ "$ISSUE_STATE" = "OPEN" ]; then
724
+ if [ -n "$PR_NUMBER" ]; then CLOSE_COMMENT="Closed: PR #{PR_NUMBER} merged to \`{PR_BASE}\`. Closes #{NUMBER}."
725
+ else CLOSE_COMMENT="Closed: investigation complete (no PR). Closes #{NUMBER}."; fi
726
+ gh issue close {NUMBER} {GH_FLAG} --comment "$CLOSE_COMMENT" # allowlist:check-command-side-effects
727
+ fi
494
728
  ```
495
729
 
496
- Add merged label:
730
+ Transition the workflow label to `merged` (tiered `transition-label` dispatch; the prose tier removes the FULL set: investigating, ready-to-build, building, in-review, awaiting-merge, invalid, decomposed):
497
731
  ```bash
498
- gh issue edit {NUMBER} {GH_FLAG} \
499
- --add-label "workflow:merged" \
500
- --remove-label "workflow:in-review,workflow:building,workflow:investigating" 2>/dev/null || true
732
+ set_workflow_state {NUMBER} merged
501
733
  ```
502
734
 
503
735
  ---
504
736
 
505
737
  ## Phase C3: Parent Tracker Update (Sub-Issues Only)
506
738
 
507
- **Skip if**: Issue body does NOT contain a parent issue reference (e.g. `Part of #NNN`) or the issue has no parent in its milestone tracker.
739
+ **Skip if**: `TERMINAL_STATE` is `decomposed` or `invalid`, OR the issue body does NOT contain a parent issue reference (e.g. `Part of #NNN`) or the issue has no parent in its milestone tracker.
508
740
 
509
- Detect parent reference. Markdown emphasis markers (`**bold**`, `__bold__`, `*italic*`) are stripped before matching, since sub-issue bodies commonly render the label as `**Parent**: #NNN` and the bare label alternation below would otherwise fail to match past the emphasis characters:
741
+ Detect parent reference. Markdown emphasis markers (`**bold**`, `__bold__`, `*italic*`) are stripped before matching, since sub-issue bodies commonly render the label as `**Parent**: #NNN` and the bare label alternation below would otherwise fail to match past the emphasis characters. POSIX ERE only (no PCRE — `grep -P` is unavailable on macOS/BSD grep):
510
742
  ```bash
511
743
  PARENT_REF=$(gh issue view {NUMBER} {GH_FLAG} --json body --jq '.body' \
512
744
  | sed -E 's/[*_]+//g' \
513
- | grep -oP '(?i)(part of|spawned from|sub-issue of|parent issue[:]?|parent[:])\s*#\K\d+' \
514
- | head -1)
745
+ | grep -ioE '(part of|spawned from|sub-issue of|parent issue:?|parent:)[[:space:]]*#[0-9]+' \
746
+ | head -1 | sed 's/.*#//')
747
+ PARENT_UPDATED=false; PARENT_CLOSED=false
515
748
  ```
516
749
 
517
- If no parent reference found → log a warning and skip this phase:
750
+ If no parent reference found → log a warning and skip this phase (`PARENT_STATUS="⏭ Skipped"`, `PARENT_NOTES="No parent tracker"`):
518
751
  ```bash
519
752
  echo "WARNING: No parent reference found in issue body — skipping parent tracker update"
520
753
  ```
521
754
 
522
755
  If parent found:
523
756
  ```bash
524
- # Read parent body
525
- PARENT_BODY=$(gh issue view {PARENT_REF} {GH_FLAG} --json body --jq '.body')
757
+ # Read parent body (fresh read immediately before the body mutation)
758
+ PARENT_BODY=$(gh issue view "$PARENT_REF" {GH_FLAG} --json body --jq '.body')
526
759
 
527
- # Check off this sub-issue in parent body (replace "- [ ] #{NUMBER}" with "- [x] #{NUMBER}")
528
- UPDATED_PARENT=$(echo "$PARENT_BODY" | sed "s/- \[ \] #${NUMBER}/- [x] #${NUMBER}/g")
529
- gh issue edit {PARENT_REF} {GH_FLAG} --body "$UPDATED_PARENT"
760
+ # Check off this sub-issue in parent body (replace "- [ ] #{NUMBER}" with "- [x] #{NUMBER}"; the trailing guard stops #12 matching #123)
761
+ UPDATED_PARENT=$(printf '%s\n' "$PARENT_BODY" | sed -E "s/- \[ \] #${NUMBER}([^0-9]|\$)/- [x] #${NUMBER}\1/g")
762
+ gh issue edit "$PARENT_REF" {GH_FLAG} --body "$UPDATED_PARENT" # allowlist:check-command-side-effects
763
+ PARENT_UPDATED=true
530
764
 
531
- # Check if all sub-issues are now closed
532
- OPEN_SUBS=$(echo "$UPDATED_PARENT" | grep -c '\- \[ \]' || true)
765
+ # Check if all sub-issues are now checked off
766
+ OPEN_SUBS=$(printf '%s\n' "$UPDATED_PARENT" | grep -cE '^[[:space:]]*[-*+] \[ \]' || true)
767
+ OPEN_SUBS=${OPEN_SUBS:-0}
533
768
  ```
534
769
 
535
- If `OPEN_SUBS == 0` (all sub-issues checked off):
770
+ If `OPEN_SUBS == 0` (all sub-issues checked off) — close the parent and transition it to `merged` with the same tiered dispatch and FULL remove list as C2:
536
771
  ```bash
537
- gh issue close {PARENT_REF} {GH_FLAG} \
538
- --comment "All sub-issues complete. Closing parent tracker. Last completed: #{NUMBER} (PR #{PR_NUMBER})."
539
- gh issue edit {PARENT_REF} {GH_FLAG} --add-label "workflow:merged"
772
+ if [ "$OPEN_SUBS" -eq 0 ]; then
773
+ gh issue close "$PARENT_REF" {GH_FLAG} --comment "All sub-issues complete. Closing parent tracker. Last completed: #{NUMBER}${PR_NUMBER:+ (PR #${PR_NUMBER})}." # allowlist:check-command-side-effects
774
+ set_workflow_state "$PARENT_REF" merged
775
+ PARENT_CLOSED=true
776
+ fi
540
777
  ```
541
778
 
779
+ Set `PARENT_STATUS="✅ Complete"` and `PARENT_NOTES="Checked off in #${PARENT_REF}"` when the parent was updated.
780
+
542
781
  ---
543
782
 
544
783
  ## Phase C4: Summary Report
545
784
 
546
- Reconstruct the pipeline summary from GitHub state:
547
-
548
- ```bash
549
- # Get investigation verdict from FORGE:INVESTIGATOR comment
550
- VERDICT=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
551
- --jq '.[] | select(.body | contains("FORGE:INVESTIGATOR")) | .body' \
552
- | grep -oP '(?<=\*\*Verdict\*\*: )\w+' | head -1)
553
-
554
- CONFIDENCE=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
555
- --jq '.[] | select(.body | contains("FORGE:INVESTIGATOR")) | .body' \
556
- | grep -oP '(?<=\*\*Confidence\*\*: )\w+' | head -1)
557
-
558
- # Get files changed from FORGE:BUILDER comment
559
- FILES_CHANGED=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
560
- --jq '.[] | select(.body | contains("FORGE:BUILDER")) | .body' \
561
- | grep -oP '(?<=\*\*Files changed\*\*: )\d+' | head -1)
562
- ```
785
+ All values (`TITLE`, `VERDICT`, `CONFIDENCE`, `LANE_LABEL`, `FILES_CHANGED`, …) were derived from GitHub state in Phase C0 — do not re-guess them.
563
786
 
564
787
  Output to stdout (returned to calling agent):
565
788
 
566
789
  ```
567
790
  ## Done: #{NUMBER} — {TITLE}
568
791
  - Investigation: {VERDICT} ({CONFIDENCE})
569
- - Lane: FEATURE
792
+ - Lane: {LANE_LABEL}
570
793
  - Fix: {BRANCH} → PR #{PR_NUMBER} → merged to `{PR_BASE}`
571
794
  - Files changed: {FILES_CHANGED}
572
795
  ```
573
796
 
797
+ For PR-less terminals replace the `Fix:` line with `- Outcome: {TERMINAL_STATE} (no PR)`. Render a missing `VERDICT`/`CONFIDENCE` as `—`.
798
+
574
799
  ---
575
800
 
576
801
  ## Phase C4.5: Pipeline Summary Card (MANDATORY)
@@ -585,16 +810,18 @@ value renders as `—` and NEVER aborts the card. Do NOT fabricate stats.**
585
810
  ### C4.5a: Gather real stats
586
811
 
587
812
  ```bash
588
- # Commit / diff stats from the merged PR (single API call). Fallbacks to "—" if absent.
589
- PR_STATS=$(gh pr view {PR_NUMBER} {GH_FLAG} --json commits,additions,deletions,baseRefName,isDraft 2>/dev/null)
813
+ # Commit / diff stats from the merged PR (single API call). Fallbacks to "—" if absent or PR-less.
814
+ PR_STATS=""
815
+ [ -n "$PR_NUMBER" ] && PR_STATS=$(gh pr view {PR_NUMBER} {GH_FLAG} --json commits,additions,deletions,baseRefName,isDraft 2>/dev/null)
590
816
  COMMITS=$(echo "$PR_STATS" | jq -r '(.commits | length) // empty' 2>/dev/null); COMMITS=${COMMITS:-—}
591
817
  ADDITIONS=$(echo "$PR_STATS" | jq -r '.additions // empty' 2>/dev/null); ADDITIONS=${ADDITIONS:-—}
592
818
  DELETIONS=$(echo "$PR_STATS" | jq -r '.deletions // empty' 2>/dev/null); DELETIONS=${DELETIONS:-—}
593
- PR_TARGET=$(echo "$PR_STATS" | jq -r '.baseRefName // empty' 2>/dev/null); PR_TARGET=${PR_TARGET:-{PR_BASE}}
819
+ PR_TARGET=$(echo "$PR_STATS" | jq -r '.baseRefName // empty' 2>/dev/null); PR_TARGET=${PR_TARGET:-${PR_BASE:-—}}
594
820
  IS_DRAFT=$(echo "$PR_STATS" | jq -r '.isDraft // false' 2>/dev/null)
595
821
 
596
- # Review summary — count domain-agent verdicts posted by /review-pr on the PR.
597
- REVIEW_BODIES=$(gh pr view {PR_NUMBER} {GH_FLAG} --json reviews,comments \
822
+ # Review summary — count domain-agent verdicts posted by /review-pr on the PR (PR-less: nothing to count).
823
+ REVIEW_BODIES=""
824
+ [ -n "$PR_NUMBER" ] && REVIEW_BODIES=$(gh pr view {PR_NUMBER} {GH_FLAG} --json reviews,comments \
598
825
  --jq '[.reviews[].body // ""] + [.comments[].body // ""] | .[]' 2>/dev/null)
599
826
  # NOTE: `grep -c` already prints `0` on no match (and exits non-zero) — do NOT add
600
827
  # `|| echo 0`, which would append a second line ("0\n0") and break the arithmetic
@@ -624,13 +851,15 @@ if [ -n "$FIRST_TS" ]; then
624
851
  else ELAPSED="—"; ELAPSED_SECS=0; fi
625
852
  else ELAPSED="—"; ELAPSED_SECS=0; fi
626
853
 
627
- # Pipeline line + status — reflect the ACTUAL terminal state.
628
- # merged → investigate → architect → build → review → merge ✓
629
- # decomposed→ investigate → decompose ⏹ (sub-issues spawned)
630
- # invalid → investigate → invalid ✗
631
- # blocked → investigate → … → blocked ⚠ (needs-human)
632
- # draft PR → append "(draft)" to the merge segment
633
- case "{TERMINAL_STATE}" in
854
+ # Pipeline line + status — reflect the ACTUAL terminal state (from --terminal-state).
855
+ # merged → investigate → architect → build → review → merge ✓
856
+ # investigation → investigate ✓ (investigation only, no PR)
857
+ # decomposed → investigate → decompose ⏹ (sub-issues spawned)
858
+ # invalid → investigate → invalid ✗
859
+ # blocked → investigate → … → blocked ⚠ (needs-human; not a --terminal-state value, kept for card rendering)
860
+ # draft PR → append "(draft)" to the merge segment
861
+ case "$TERMINAL_STATE" in
862
+ investigation) PIPELINE_LINE="investigate ✓ (investigation only)"; CARD_STATUS="investigation" ;;
634
863
  decomposed) PIPELINE_LINE="investigate → decompose ⏹"; CARD_STATUS="decomposed" ;;
635
864
  invalid) PIPELINE_LINE="investigate → invalid ✗"; CARD_STATUS="invalid" ;;
636
865
  blocked) PIPELINE_LINE="investigate → build → blocked ⚠"; CARD_STATUS="blocked" ;;
@@ -641,7 +870,7 @@ esac
641
870
 
642
871
  ### C4.5b: Render the card to stdout
643
872
 
644
- Print the card to stdout (the calling agent surfaces it in the terminal). Card inner
873
+ Print the card to stdout (the calling agent surfaces it in the terminal; the router relays only the `CLOSE_RESULT:` block, so print the card BEFORE it). Card inner
645
874
  width is **51** columns. Truncate the title with an ellipsis (`…`) if `#{NUMBER} — {TITLE}`
646
875
  exceeds the field; pad shorter lines with spaces so the right border `║` stays aligned.
647
876
 
@@ -661,6 +890,7 @@ exceeds the field; pad shorter lines with spaces so the right border `║` stays
661
890
  ```
662
891
 
663
892
  **Edge-case rendering**:
893
+ - Investigation-only: header `ForgeDock Pipeline — Investigation Complete`; `Pipeline:` shows `investigate ✓ (investigation only)`; `PR:`, `Review:`, `Commits:` render `—`.
664
894
  - Decomposed: title line stays; `Pipeline:` shows `investigate → decompose ⏹`; `PR:`, `Review:`, `Commits:` render `—`; the header reads `ForgeDock Pipeline — Decomposed`.
665
895
  - Invalid: header `ForgeDock Pipeline — Closed (invalid)`; `Pipeline:` shows `investigate → invalid ✗`; downstream stats `—`.
666
896
  - Blocked / needs-human: header `ForgeDock Pipeline — Blocked`; `Review:`/`PR:` reflect last known state; remaining stats `—`.
@@ -668,16 +898,16 @@ exceeds the field; pad shorter lines with spaces so the right border `║` stays
668
898
 
669
899
  ### C4.5c: Build the machine-readable twin
670
900
 
671
- Assemble the JSON object below (used verbatim by Phase C5). Numeric stats that were `—`
901
+ Assemble the JSON object below. Its field set is exactly what Phase C5 passes to the codec `emit CARD --b64` call (including `title` and `blockers`); the JSON itself is for local use/debugging and is not embedded. Numeric stats that were `—`
672
902
  become `null` in JSON; never emit `"—"` as a number.
673
903
 
674
904
  ```bash
675
905
  CARD_JSON=$(jq -nc \
676
906
  --argjson issue {NUMBER} \
677
- --arg title "{TITLE}" \
907
+ --arg title "$TITLE" \
678
908
  --arg status "$CARD_STATUS" \
679
909
  --arg pipeline "$PIPELINE_LINE" \
680
- --arg pr "{PR_NUMBER}" \
910
+ --arg pr "$PR_NUMBER" \
681
911
  --arg target "$PR_TARGET" \
682
912
  --arg commits "$COMMITS" --arg adds "$ADDITIONS" --arg dels "$DELETIONS" \
683
913
  --arg review "$REVIEW_SUMMARY" --argjson blockers "${BLOCKERS:-0}" \
@@ -694,18 +924,92 @@ CARD_JSON=$(jq -nc \
694
924
 
695
925
  ## Phase C5: Trajectory Log (MANDATORY)
696
926
 
697
- **CODEC PATH (forge#1727)**: Post the `<!-- FORGE:TRAJECTORY -->` comment via the protocol codec — do NOT hand-roll the opening tag. Use `forge-annotation.sh write TRAJECTORY --field ...` or `node packages/protocol/src/cli.js emit TRAJECTORY` to produce the opening tag. The codec handles any field escaping automatically.
927
+ **CODEC PATH (forge#1727)**: Post the `<!-- FORGE:TRAJECTORY -->` comment via the protocol codec — do NOT hand-roll the opening tag. Use `node "$CODEC_CLI" emit TRAJECTORY` (or `forge-annotation.sh write TRAJECTORY --field ...`) to produce the opening tag; the codec handles any field escaping. `CODEC_CLI` resolves from the ForgeDock install root (`FORGE_ROOT`, from the script-resolution block), never from the consumer's cwd.
928
+
929
+ **This file is the single source** for the trajectory comment, the summary card and the decision record — there is no second inline copy to keep in sync.
930
+
931
+ ### Step 1: Resolve the codec, review row, anomalies, decisions
932
+
933
+ ```bash
934
+ CODEC_CLI=""
935
+ [ -n "${FORGE_ROOT:-}" ] && [ -f "$FORGE_ROOT/packages/protocol/src/cli.js" ] && CODEC_CLI="$FORGE_ROOT/packages/protocol/src/cli.js"
936
+ [ -z "$CODEC_CLI" ] && [ -f "$REPO_PATH/packages/protocol/src/cli.js" ] && CODEC_CLI="$REPO_PATH/packages/protocol/src/cli.js"
937
+ TRAJECTORY_HEADER=""
938
+ [ -n "$CODEC_CLI" ] && TRAJECTORY_HEADER=$(node "$CODEC_CLI" emit TRAJECTORY 2>/dev/null)
939
+ # Codec CLI genuinely absent (not installed): the literal opening tag is the exact string the codec emits.
940
+ [ -n "$TRAJECTORY_HEADER" ] || TRAJECTORY_HEADER="<!-- FORGE:TRAJECTORY -->"
941
+ ```
942
+
943
+ **Review-presence check** (run before filling in the Review + PR row): <!-- Added: forge#381 -->
944
+ ```bash
945
+ # Check whether /review-pr was actually invoked — look for review agent comments on the PR
946
+ REVIEW_PRESENT="false"
947
+ if [ -n "$PR_NUMBER" ]; then
948
+ REVIEW_PRESENT=$(gh pr view {PR_NUMBER} {GH_FLAG} --json reviews,comments \
949
+ --jq '([.reviews[].body // ""] + [.comments[].body // ""]) |
950
+ map(select(test("APPROVED:|CHANGES REQUESTED:|FORGE:REVIEWER|review-pr";"i"))) |
951
+ length > 0' 2>/dev/null || echo "false")
952
+ # Set the Review + PR row: ✅ Merged if review present, ⚠ Skipped (no review) if not
953
+ REVIEW_ROW=$([ "$REVIEW_PRESENT" = "true" ] && echo "✅ Merged" || echo "⚠ Skipped (no review)")
954
+ else
955
+ REVIEW_ROW="⏭ N/A (no PR)"
956
+ fi
957
+
958
+ # Anomalies: close-scope invariant violations (Phase C0.5 + post-C2 check) and a skipped review.
959
+ ANOMALIES=""
960
+ if [ -n "${INVARIANT_ANOMALIES:-}" ]; then
961
+ ANOMALIES=$(printf '%s' "$INVARIANT_ANOMALIES" | tr '\n' ' ' | sed 's/ */ /g; s/ *$//')
962
+ fi
963
+ if [ -n "$PR_NUMBER" ] && [ "$REVIEW_PRESENT" != "true" ]; then
964
+ ANOMALIES="${ANOMALIES:+$ANOMALIES; }review skipped: no review-pr output found on PR #${PR_NUMBER}"
965
+ fi
966
+ ANOMALIES_TEXT="${ANOMALIES:-None}"
967
+ ```
968
+
969
+ This check is **audit-only** — it annotates the trajectory for visibility and cannot retroactively block a merged PR. A `⚠ Skipped (no review)` is always logged in the Anomalies field so the skip is surfaced during pipeline health review.
970
+
971
+ **Rows and decisions are parameterised by `TERMINAL_STATE` and `LANE`** — nothing is hard-coded to "Feature lane" / "✅ Merged" / "Anomalies: None":
698
972
 
699
973
  ```bash
700
- # Codec produces the opening <!-- FORGE:TRAJECTORY --> tag
701
- TRAJECTORY_HEADER=$(node packages/protocol/src/cli.js emit TRAJECTORY)
702
- # $TRAJECTORY_HEADER = "<!-- FORGE:TRAJECTORY -->"
703
- # Append the Markdown body sections, then post via gh issue comment.
974
+ if [ -n "$PR_BASE" ]; then LANE_NOTE="${LANE_LABEL} lane → \`${PR_BASE}\`"; else LANE_NOTE="${LANE_LABEL} lane"; fi
975
+ case "$TERMINAL_STATE" in
976
+ merged)
977
+ PHASE2_ROW="⏭ Skipped | Single-concern change, no decomposition needed"
978
+ BUILD_ROW="✅ Complete | Branch: \`${BRANCH}\`"
979
+ GATE_CELL="${GATE_ROW} | ${GATE_NOTE}"
980
+ VERIF_CELL="${VERIFICATION_ROW} |"
981
+ REVIEW_CELL="${REVIEW_ROW} | PR #${PR_NUMBER} → \`${PR_BASE}\`"
982
+ CLOSE_CELL="✅ Complete | Issue closed"
983
+ DECISIONS_BLOCK="- Decomposition skipped: single-concern change, no decomposition needed
984
+ - PR merged to: \`${PR_BASE}\` (${LANE} lane)" ;;
985
+ investigation)
986
+ PHASE2_ROW="⏭ Skipped | Investigation-only task"
987
+ BUILD_ROW="✅ Complete | Investigation deliverables created (no PR)"
988
+ GATE_CELL="⏭ N/A | no code change"; VERIF_CELL="⏭ N/A |"; REVIEW_CELL="⏭ N/A (no PR) |"
989
+ CLOSE_CELL="✅ Complete | Issue closed"
990
+ DECISIONS_BLOCK="- Investigation-only task: no PR produced" ;;
991
+ decomposed)
992
+ PHASE2_ROW="✅ Decomposed | Sub-issues spawned (see FORGE:DECOMPOSED)"
993
+ BUILD_ROW="⏭ Skipped | Handled by sub-issues"
994
+ GATE_CELL="⏭ N/A |"; VERIF_CELL="⏭ N/A |"; REVIEW_CELL="⏭ N/A (no PR) |"
995
+ CLOSE_CELL="✅ Complete | Issue left open (sub-issue tracker)"
996
+ DECISIONS_BLOCK="- Decomposed into sub-issues; this issue stays open as the tracker" ;;
997
+ invalid)
998
+ PHASE2_ROW="⏭ Skipped | Issue invalid"
999
+ BUILD_ROW="⏭ Skipped | Issue invalid"
1000
+ GATE_CELL="⏭ N/A |"; VERIF_CELL="⏭ N/A |"; REVIEW_CELL="⏭ N/A (no PR) |"
1001
+ CLOSE_CELL="✅ Complete | Issue closed (invalid)"
1002
+ DECISIONS_BLOCK="- Closed as invalid after investigation" ;;
1003
+ esac
1004
+ # C6 runs after this post and cannot fail the close, so the planned outcome is recorded here.
1005
+ if [ -n "$WORKTREE_PATH" ] && [ -d "$WORKTREE_PATH" ]; then CLEANUP_STATUS="✅ Removed"; else CLEANUP_STATUS="⏭ Skipped"; fi
1006
+ PARENT_STATUS="${PARENT_STATUS:-⏭ Skipped}"; PARENT_NOTES="${PARENT_NOTES:-No parent tracker}"
1007
+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
704
1008
  ```
705
1009
 
706
- Post the `<!-- FORGE:TRAJECTORY -->` comment as the final pipeline record.
1010
+ ### Step 2: Prior delta computation
707
1011
 
708
- **Prior delta computation** — read cost-prior for this issue's task_type × module before posting (forge#1743):
1012
+ Read cost-prior for this issue's task_type × module before posting (forge#1743):
709
1013
 
710
1014
  ```bash
711
1015
  # Compute actual vs prior cost delta for self-correction of cost priors
@@ -714,23 +1018,19 @@ ACTUAL_TOTAL_USD=""
714
1018
  PRIOR_EST_USD=""
715
1019
  COST_DELTA_NOTE=""
716
1020
 
717
- # Read actual spend from FORGE:BUILDER / FORGE:TRAJECTORY best-effort telemetry
718
- # (same extraction used in work-on.md Phase 7C DECISION_RECORD cost block)
719
- ACTUAL_TOTAL_USD=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
720
- --jq '[.[] | select(.body | contains("FORGE:BUILDER")) | .body] | last // ""' 2>/dev/null \
721
- | grep -oP '(?<=cost_usd: )\S+' | head -1 || echo "")
1021
+ # Read actual spend from FORGE:BUILDER best-effort telemetry (the same cost_usd extraction
1022
+ # is used for the per-stage cost block in Phase C5.5). ERE/sed only — no PCRE.
1023
+ ACTUAL_TOTAL_USD=$(printf '%s\n' "$BUILDER_BODY" \
1024
+ | sed -n 's/.*cost_usd: *\([0-9][0-9]*\(\.[0-9][0-9]*\)\{0,1\}\).*/\1/p' | head -1)
722
1025
 
723
1026
  if [ -n "$ACTUAL_TOTAL_USD" ] && [ -f "$COST_PRIORS_PATH" ]; then
724
- # Derive task_type:module key (same logic as Step 3E.5)
725
- TASK_TYPE=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
726
- --jq '[.[] | select(.body | contains("FORGE:INVESTIGATOR")) | .body] | last // ""' 2>/dev/null \
727
- | grep -oP '(?<=\*\*Task Type\*\*: )\S+' | head -1 | tr '[:upper:]' '[:lower:]' | tr ' ' '-' || echo 'unknown')
728
- PRIMARY_FILE=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
729
- --jq '[.[] | select(.body | contains("FORGE:INVESTIGATOR")) | .body] | last // ""' 2>/dev/null \
730
- | grep -oP '`[^`]+\.(py|mjs|ts|md|sh|yaml|yml)`' | tr -d '`' | head -1 || echo '')
1027
+ # Derive task_type:module key (same logic as the orchestrate cost-prior step) — TASK_TYPE comes from Phase C0
1028
+ TASK_TYPE_KEY=$(printf '%s' "$TASK_TYPE" | tr '[:upper:]' '[:lower:]' | tr -s ' ' '-')
1029
+ PRIMARY_FILE=$(printf '%s\n' "$INVESTIGATOR_BODY" \
1030
+ | grep -oE '`[^`]+\.(py|mjs|ts|md|sh|yaml|yml)`' | tr -d '`' | head -1 || echo '')
731
1031
  MODULE=$(basename "${PRIMARY_FILE:-_unknown}" | sed 's/\.[^.]*$//' | tr '[:upper:]' '[:lower:]')
732
1032
  [ -z "$MODULE" ] && MODULE="_unknown"
733
- PRIOR_KEY="${TASK_TYPE}:${MODULE}"
1033
+ PRIOR_KEY="${TASK_TYPE_KEY}:${MODULE}"
734
1034
 
735
1035
  PRIOR_EST_USD=$(jq -r --arg k "$PRIOR_KEY" '.priors[$k].mean // empty' "$COST_PRIORS_PATH" 2>/dev/null || echo '')
736
1036
 
@@ -747,65 +1047,83 @@ else
747
1047
  fi
748
1048
  ```
749
1049
 
1050
+ ### Step 3: Build the card line and post
1051
+
1052
+ The CARD line carries the same fields as the Phase C4.5c twin, **including `title` and `blockers`**, encoded by the codec (`--b64`):
1053
+
750
1054
  ```bash
751
- gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:TRAJECTORY -->
752
- ## Pipeline Trajectory — #{NUMBER}
1055
+ CARD_LINE=""
1056
+ if [ -n "$CODEC_CLI" ]; then
1057
+ CARD_LINE=$(node "$CODEC_CLI" emit CARD --b64 \
1058
+ --field issue="${NUMBER}" \
1059
+ --field title="${TITLE}" \
1060
+ --field status="${CARD_STATUS}" \
1061
+ --field pipeline="${PIPELINE_LINE}" \
1062
+ --field pr="${PR_NUMBER}" \
1063
+ --field pr_target="${PR_TARGET}" \
1064
+ --field commits="${COMMITS}" \
1065
+ --field additions="${ADDITIONS}" \
1066
+ --field deletions="${DELETIONS}" \
1067
+ --field review="${REVIEW_SUMMARY}" \
1068
+ --field blockers="${BLOCKERS:-0}" \
1069
+ --field elapsed="${ELAPSED_SECS:-0}" 2>/dev/null) || CARD_LINE=""
1070
+ fi
1071
+
1072
+ TRAJ_FILE=$(mktemp)
1073
+ cat > "$TRAJ_FILE" <<TRAJ_EOF
1074
+ ${TRAJECTORY_HEADER}
1075
+ ## Pipeline Trajectory — #${NUMBER}
753
1076
 
754
1077
  | Phase | Result | Notes |
755
1078
  |-------|--------|-------|
756
- | Phase 0: Context Load | ✅ Complete | Feature lane → \`{PR_BASE}\` |
757
- | Phase 1: Investigation | ✅ {VERDICT} ({CONFIDENCE}) | Task type: {TASK_TYPE} |
758
- | Phase 2: Decomposition | ⏭ Skipped | Single-concern change, no decomposition needed |
759
- | Phase 3: Build | ✅ Complete | Branch: \`{BRANCH}\` |
760
- | Phase 3F.5: Validate | ✅ Gate passed | Quality gate: pass |
761
- | Phase 4–5: Review + PR | ✅ Merged | PR #{PR_NUMBER} → \`{PR_BASE}\` |
762
- | Phase 6: Parent Tracker | {PARENT_STATUS} | {PARENT_NOTES} |
763
- | Phase C6: Cleanup | {CLEANUP_STATUS} | Worktree: {WORKTREE_PATH}, Branch: {BRANCH} |
764
- | Phase 7: Close | ✅ Complete | Issue closed |
1079
+ | Phase 0: Context Load | ✅ Complete | ${LANE_NOTE} |
1080
+ | Phase 1: Investigation | ✅ ${VERDICT:-—} (${CONFIDENCE:-—}) | Task type: ${TASK_TYPE} (${COMPLEXITY_BAND}) |
1081
+ | Phase 2: Decomposition | ${PHASE2_ROW} |
1082
+ | Phase 3: Build | ${BUILD_ROW} |
1083
+ | Phase 3G: Quality Gate | ${GATE_CELL} |
1084
+ | Phase 3H: Verification | ${VERIF_CELL} |
1085
+ | Phase 4–5: Review + PR | ${REVIEW_CELL} |
1086
+ | Phase 6: Parent Tracker | ${PARENT_STATUS} | ${PARENT_NOTES} |
1087
+ | Phase C6: Cleanup | ${CLEANUP_STATUS} | Worktree: ${WORKTREE_PATH:-none}, Branch: ${BRANCH:-none} |
1088
+ | Phase 7: Close | ${CLOSE_CELL} |
765
1089
 
766
1090
  **Decisions**:
767
- - Decomposition skipped: {DECOMPOSE_REASON}
768
- - PR merged to: \`{PR_BASE}\` (feature lane, milestone branch)
1091
+ ${DECISIONS_BLOCK}
1092
+
1093
+ **Anomalies**: ${ANOMALIES_TEXT}
769
1094
 
770
1095
  **Cost (economic scheduling)**: ${COST_DELTA_NOTE}
771
1096
 
772
- **Anomalies**: None
773
-
774
- **Pipeline completed**: {TIMESTAMP}
775
-
776
- $(node packages/protocol/src/cli.js emit CARD --b64 \
777
- --field issue={NUMBER} \
778
- --field status={CARD_STATUS} \
779
- --field pipeline="${PIPELINE_LINE}" \
780
- --field pr={PR_NUMBER} \
781
- --field pr_target="${PR_TARGET}" \
782
- --field commits="${COMMITS}" \
783
- --field additions="${ADDITIONS}" \
784
- --field deletions="${DELETIONS}" \
785
- --field review="${REVIEW_SUMMARY}" \
786
- --field elapsed="${ELAPSED_SECS:-0}")"
1097
+ **Pipeline completed**: ${TIMESTAMP}
1098
+
1099
+ ${CARD_LINE}
1100
+ TRAJ_EOF
1101
+
1102
+ DRY_RUN="${DRY_RUN:-false}"
1103
+ TRAJECTORY_URL=""
1104
+ if [ "$DRY_RUN" = "true" ]; then
1105
+ echo "DRY_RUN: would post FORGE:TRAJECTORY comment on #${NUMBER}"
1106
+ else
1107
+ TRAJECTORY_URL=$(gh issue comment {NUMBER} {GH_FLAG} --body-file "$TRAJ_FILE" 2>/dev/null) \
1108
+ || TRAJECTORY_URL=$(gh issue comment {NUMBER} {GH_FLAG} --body-file "$TRAJ_FILE" 2>/dev/null) \
1109
+ || CLOSE_FAILED="C5: failed to post FORGE:TRAJECTORY comment"
1110
+ fi
1111
+ rm -f "$TRAJ_FILE"
787
1112
  ```
788
1113
 
789
- The `<!-- FORGE:CARD: v1 sha:... b64:... -->` line carries the machine-readable summary computed in
790
- Phase C4.5c, encoded as Base64url (design decision 2026-07-08: encoding beats escaping — the
791
- Base64url alphabet cannot contain HTML comment delimiters by construction). It is wrapped in
792
- the inline-value annotation form `<!-- FORGE:CARD: ... -->` so parse() extracts the encoded
793
- payload. Platform consumers (e.g., `/orchestrate`) decode via `node packages/protocol/src/cli.js
794
- parse --type CARD --field status`. This block is **additive**: all existing `FORGE:TRAJECTORY`
795
- consumers select via `contains("FORGE:TRAJECTORY")` and parse the markdown table, so the
796
- embedded CARD line does not affect them.
797
-
798
- **CODEC PATH (forge#1727)**: The `$(node packages/protocol/src/cli.js emit CARD --b64 ...)` call
799
- above replaces the previous `<!-- FORGE:CARD ${CARD_JSON} -->` inline-JSON form. The Base64url
800
- form is safe against all HTML comment injection vectors and includes a sha8 integrity prefix for
801
- truncation detection. Consumers that parsed the old inline-JSON form must migrate to the codec
802
- parse path: `echo '...' | node packages/protocol/src/cli.js parse --type CARD --field <key>`.
1114
+ If `CLOSE_FAILED` is set, STOP: print `CLOSE_RESULT: status: FAILED` with `blocker: "$CLOSE_FAILED"` (do not run C5.1–C6).
1115
+
1116
+ The `**Decisions**:` block MUST stay a bullet list and `**Decisions**:` must precede `**Anomalies**:` — the Phase C5.4 ADR extractor reads the lines between those two markers.
1117
+
1118
+ The `<!-- FORGE:CARD: v1 sha:... b64:... -->` line carries the machine-readable summary (the Phase C4.5c fields plus `title` and `blockers`), encoded as Base64url (design decision 2026-07-08: encoding beats escaping — the Base64url alphabet cannot contain HTML comment delimiters by construction). It is wrapped in the inline-value annotation form `<!-- FORGE:CARD: ... -->` so `parse()` extracts the encoded payload. Platform consumers (e.g. `/orchestrate`) decode via `node "$CODEC_CLI" parse --type CARD [--field <key>]`. This block is **additive**: all existing `FORGE:TRAJECTORY` consumers select via `contains("FORGE:TRAJECTORY")` and parse the markdown table, so the embedded CARD line does not affect them.
1119
+
1120
+ **CODEC PATH (forge#1727)**: the `emit CARD --b64` call replaces the previous `<!-- FORGE:CARD ${CARD_JSON} -->` inline-JSON form. The Base64url form is safe against all HTML comment injection vectors and includes a sha8 integrity prefix for truncation detection. Consumers that parsed the old inline-JSON form must migrate to the codec parse path: `echo '...' | node "$CODEC_CLI" parse --type CARD --field <key>`.
803
1121
 
804
1122
  Where:
805
- - `{PARENT_STATUS}` = `⏭ Skipped` (if no parent) or `✅ Complete` (if parent updated)
806
- - `{PARENT_NOTES}` = `No parent tracker` or `Checked off in #{PARENT_REF}`
807
- - `{CLEANUP_STATUS}` = `✅ Removed` (worktree removed + branch deleted) or `⏭ Skipped` (no path provided or path not found)
808
- - `{TIMESTAMP}` = current date/time in ISO format
1123
+ - `PARENT_STATUS` = `⏭ Skipped` (if no parent) or `✅ Complete` (if parent updated)
1124
+ - `PARENT_NOTES` = `No parent tracker` or `Checked off in #${PARENT_REF}`
1125
+ - `CLEANUP_STATUS` = `✅ Removed` (worktree removed + branch deleted) or `⏭ Skipped` (no path provided or path not found)
1126
+ - `TIMESTAMP` = current date/time in ISO format
809
1127
 
810
1128
  ---
811
1129
 
@@ -819,7 +1137,10 @@ Where:
819
1137
 
820
1138
  ```bash
821
1139
  # Re-index this issue and regenerate cost priors — non-blocking
822
- INDEXER_PATH=$(dirname "$(realpath "$0" 2>/dev/null || echo '.')")/../../scripts/build-knowledge-index.mjs
1140
+ # Resolve from the ForgeDock install root (FORGE_ROOT, from the script-resolution block) — never from $0, which is meaningless inside a skill
1141
+ INDEXER_PATH=""
1142
+ [ -n "${FORGE_ROOT:-}" ] && INDEXER_PATH="$FORGE_ROOT/scripts/build-knowledge-index.mjs"
1143
+ [ -f "$INDEXER_PATH" ] || INDEXER_PATH="$REPO_PATH/scripts/build-knowledge-index.mjs"
823
1144
  if node --version >/dev/null 2>&1 && [ -f "$INDEXER_PATH" ]; then
824
1145
  echo "[cost-prior] Re-indexing issue #${NUMBER} and regenerating cost priors..."
825
1146
  node "$INDEXER_PATH" --issue {NUMBER} --no-mirror 2>&1 | tail -5 \
@@ -869,7 +1190,7 @@ MEMORY_INDEX_ID=$(gh gist list --limit 100 \
869
1190
 
870
1191
  if [ -z "$MEMORY_INDEX_ID" ]; then
871
1192
  # First run — create the index Gist
872
- TMPFILE=$(mktemp --suffix=.md)
1193
+ TMPFILE=$(mktemp)
873
1194
  cat > "$TMPFILE" <<GIST_EOF
874
1195
  # ForgeDock Memory Index — {GH_REPO}
875
1196
  <!-- FORGE:MEMORY_INDEX: {GH_REPO} -->
@@ -897,7 +1218,7 @@ else
897
1218
  ${MEMORY_ENTRY}"
898
1219
  INDEX_FILENAME=$(gh api gists/${MEMORY_INDEX_ID} --jq '.files | keys[0]' 2>/dev/null)
899
1220
  INDEX_FILENAME="${INDEX_FILENAME:-memory_index.md}"
900
- TMPFILE=$(mktemp --suffix=.md)
1221
+ TMPFILE=$(mktemp)
901
1222
  echo "$UPDATED_CONTENT" > "$TMPFILE"
902
1223
  gh gist edit "$MEMORY_INDEX_ID" -f "$INDEX_FILENAME" "$TMPFILE" 2>/dev/null
903
1224
  EDIT_EXIT=$?
@@ -979,10 +1300,12 @@ LEDGER_INDEXED=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
979
1300
 
980
1301
  ### Step 2: Run incremental indexer
981
1302
 
982
- Resolve the indexer script path relative to the repository root:
1303
+ Resolve the indexer script path from the ForgeDock install root (`FORGE_ROOT`), falling back to the repository root:
983
1304
 
984
1305
  ```bash
985
- INDEXER_PATH="${REPO_PATH:-$(git rev-parse --show-toplevel 2>/dev/null)}/scripts/build-knowledge-index.mjs"
1306
+ INDEXER_PATH=""
1307
+ [ -n "${FORGE_ROOT:-}" ] && INDEXER_PATH="$FORGE_ROOT/scripts/build-knowledge-index.mjs"
1308
+ [ -f "$INDEXER_PATH" ] || INDEXER_PATH="$REPO_PATH/scripts/build-knowledge-index.mjs"
986
1309
 
987
1310
  if [ ! -f "$INDEXER_PATH" ]; then
988
1311
  echo "[LEDGER] scripts/build-knowledge-index.mjs not found — skipping Phase C5.3"
@@ -1073,10 +1396,10 @@ TRAJECTORY_BODY=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
1073
1396
  # Extract the Decisions section (lines between **Decisions**: and **Anomalies**:)
1074
1397
  DECISIONS_RAW=$(echo "$TRAJECTORY_BODY" \
1075
1398
  | awk '/^\*\*Decisions\*\*:/{found=1; next} /^\*\*Anomalies\*\*:/{found=0} found{print}' \
1076
- | grep -v '^\s*$' \
1399
+ | grep -v '^[[:space:]]*$' \
1077
1400
  | head -20) # Cap at 20 bullets to prevent runaway parsing
1078
1401
 
1079
- if [ -z "$DECISIONS_RAW" ] || echo "$DECISIONS_RAW" | grep -qi "^- None$\|^None$"; then
1402
+ if [ -z "$DECISIONS_RAW" ] || echo "$DECISIONS_RAW" | grep -qiE '^(- )?None$'; then
1080
1403
  echo "[ADR] No Decisions section found in TRAJECTORY — skipping ADR extraction"
1081
1404
  ADR_FILES_WRITTEN=0
1082
1405
  fi
@@ -1097,7 +1420,7 @@ else
1097
1420
 
1098
1421
  while IFS= read -r bullet; do
1099
1422
  # Strip leading "- " or "* "
1100
- text=$(echo "$bullet" | sed 's/^[-*]\s*//')
1423
+ text=$(echo "$bullet" | sed 's/^[-*][[:space:]]*//')
1101
1424
  [ -z "$text" ] && continue
1102
1425
 
1103
1426
  # Tradeoff shape filter: must have a choice indicator + rationale connector
@@ -1217,83 +1540,138 @@ fi
1217
1540
 
1218
1541
  ## Phase C5.5: Graph Decision Record (MANDATORY when PR exists)
1219
1542
 
1220
- **Skip if**: `{PR_NUMBER}` is empty OR `<!-- FORGE:DECISION_RECORD -->` already posted on the PR.
1543
+ **Skip if**: `{PR_NUMBER}` is empty (investigation-only / decomposed / invalid terminals) OR `<!-- FORGE:DECISION_RECORD -->` already posted on the PR. **Non-blocking** — a failed post is logged and the close continues to C6.
1221
1544
 
1222
- Post a consolidated provenance artifact to the PR that proves the merge was backed by citable evidence. Mirrors the Phase 7C step in `work-on.md` — must stay in sync.
1545
+ **Purpose**: Post a single consolidated provenance artifact to the PR that proves the merge was backed by citable evidence. Enables downstream benchmarking queries (repeated-mistake rate, stale-edge hit rate, review escape rate) by making every pipeline run queryable via `gh api`. This file is the single source for the decision record. <!-- Added: forge#776 -->
1223
1546
 
1224
1547
  **Idempotency check**:
1225
1548
  ```bash
1226
- GDR_EXISTS=$(gh api repos/{GH_REPO}/issues/{PR_NUMBER}/comments \
1549
+ GDR_EXISTS="false"
1550
+ [ -n "$PR_NUMBER" ] && GDR_EXISTS=$(gh api repos/{GH_REPO}/issues/{PR_NUMBER}/comments \
1227
1551
  --jq '[.[] | select(.body | contains("FORGE:DECISION_RECORD"))] | length > 0' 2>/dev/null || echo "false")
1228
1552
  ```
1229
1553
 
1230
1554
  **Extract context edge counts** from FORGE:CONTEXT comment:
1231
1555
  ```bash
1232
- CONTEXT_COMMENT=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
1233
- --jq '.[] | select(.body | contains("FORGE:CONTEXT")) | .body' 2>/dev/null | head -1)
1234
- REVIEW_FINDING_COUNT=$(echo "$CONTEXT_COMMENT" | grep -oP '#\d+' | wc -l | tr -d ' ')
1556
+ CONTEXT_COMMENT=$(last_comment_body "FORGE:CONTEXT")
1557
+ # Count historical review-finding issue references (#NNN patterns in the Context comment)
1558
+ REVIEW_FINDING_COUNT=$(printf '%s\n' "$CONTEXT_COMMENT" | grep -oE '#[0-9]+' | wc -l | tr -d ' ')
1235
1559
  REVIEW_FINDING_COUNT=${REVIEW_FINDING_COUNT:-0}
1236
1560
  ```
1237
1561
 
1238
- **Extract review verdict** from PR review summary:
1562
+ **Extract review verdict and findings count** from the PR review summary:
1239
1563
  ```bash
1240
- REVIEW_SUMMARY=$(gh api repos/{GH_REPO}/issues/{PR_NUMBER}/comments \
1564
+ GDR_REVIEW_BODY=""
1565
+ [ -n "$PR_NUMBER" ] && GDR_REVIEW_BODY=$(gh api repos/{GH_REPO}/issues/{PR_NUMBER}/comments \
1241
1566
  --jq '[.[] | select(.body | contains("FORGE:REVIEWER") or (.body | test("APPROVED:|CHANGES REQUESTED:"; "i")))] | last | .body // ""' 2>/dev/null || echo '')
1242
- REVIEW_VERDICT=$(echo "$REVIEW_SUMMARY" | grep -oP '(?<=Verdict: )(APPROVED|CHANGES REQUESTED)' | head -1 || echo "APPROVED")
1243
- FINDINGS_COUNT=$(echo "$REVIEW_SUMMARY" | grep -oP '\d+(?= findings)' | head -1 || echo "0")
1244
- AGENTS_RUN=$(echo "$REVIEW_SUMMARY" | grep -oP '\d+(?= agents)' | head -1 || echo "0")
1567
+
1568
+ REVIEW_VERDICT=$(printf '%s\n' "$GDR_REVIEW_BODY" | sed -n 's/.*Verdict: \(APPROVED\|CHANGES REQUESTED\).*/\1/p' | head -1)
1569
+ REVIEW_VERDICT="${REVIEW_VERDICT:-APPROVED}"
1570
+ FINDINGS_COUNT=$(printf '%s\n' "$GDR_REVIEW_BODY" | grep -oE '[0-9]+ findings' | grep -oE '[0-9]+' | head -1)
1571
+ FINDINGS_COUNT="${FINDINGS_COUNT:-0}"
1572
+ AGENTS_RUN=$(printf '%s\n' "$GDR_REVIEW_BODY" | grep -oE '[0-9]+ agents' | grep -oE '[0-9]+' | head -1)
1573
+ AGENTS_RUN="${AGENTS_RUN:-0}"
1245
1574
  ```
1246
1575
 
1247
- **Post GDR to PR** (not to issue — PR comment is the permanent artifact):
1576
+ **Capture best-effort cost signal** from session telemetry before posting the GDR. This is best-effort — if the signal is unavailable, the cost block is omitted rather than blocking the pipeline or fabricating a number. Field names align with `bin/runner.mjs` usage accounting from #1295 so downstream tooling shares one schema:
1248
1577
  ```bash
1249
- if [ "$GDR_EXISTS" != "true" ] && [ -n "{PR_NUMBER}" ]; then
1578
+ # Best-effort: read per-stage `cost_usd:` values from the FORGE:INVESTIGATOR / FORGE:BUILDER / FORGE:REVIEWER annotations.
1579
+ # Source: session telemetry when available (e.g. OTEL_LOG_TOOL_DETAILS, Claude Code usage reporting).
1580
+ # If unavailable, COST_BLOCK is empty — the field is omitted from the GDR rather than fabricated.
1581
+ cost_from() { sed -n 's/.*cost_usd: *\([0-9][0-9]*\(\.[0-9][0-9]*\)\{0,1\}\).*/\1/p' | head -1; }
1582
+ COST_INVESTIGATION=$(printf '%s\n' "$INVESTIGATOR_BODY" | cost_from)
1583
+ COST_BUILD=$(printf '%s\n' "$BUILDER_BODY" | cost_from)
1584
+ COST_REVIEW=""
1585
+ [ -n "$PR_NUMBER" ] && COST_REVIEW=$(gh api repos/{GH_REPO}/issues/{PR_NUMBER}/comments \
1586
+ --jq '[.[] | select(.body | contains("FORGE:REVIEWER")) | .body] | last // ""' 2>/dev/null | cost_from)
1587
+
1588
+ # Build the cost block JSON only if at least one stage value is present; otherwise empty
1589
+ if [ -n "$COST_INVESTIGATION" ] || [ -n "$COST_BUILD" ] || [ -n "$COST_REVIEW" ]; then
1590
+ COST_INV_JSON="${COST_INVESTIGATION:-null}"
1591
+ COST_BUILD_JSON="${COST_BUILD:-null}"
1592
+ COST_REVIEW_JSON="${COST_REVIEW:-null}"
1593
+ COST_BLOCK="\"cost\": {
1594
+ \"stages\": {
1595
+ \"investigation\": $COST_INV_JSON,
1596
+ \"build\": $COST_BUILD_JSON,
1597
+ \"review\": $COST_REVIEW_JSON
1598
+ },
1599
+ \"total_usd\": null,
1600
+ \"source\": \"session-telemetry\"
1601
+ },"
1602
+ else
1603
+ COST_BLOCK=""
1604
+ fi
1605
+ ```
1606
+
1607
+ **Post GDR to PR** (not to issue — the PR comment survives as the permanent artifact on the merged diff). `lane`, verdict, confidence, task type, files changed and gate iterations are all derived in Phase C0 — nothing is hard-coded:
1608
+ ```bash
1609
+ DRY_RUN="${DRY_RUN:-false}"
1610
+ if [ "$GDR_EXISTS" != "true" ] && [ -n "$PR_NUMBER" ] && [ "$DRY_RUN" != "true" ]; then
1250
1611
  GDR_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1251
1612
  HEAD_SHA=$(gh pr view {PR_NUMBER} {GH_FLAG} --json headRefOid --jq '.headRefOid' 2>/dev/null || echo "")
1252
1613
  MERGE_COMMIT=$(gh pr view {PR_NUMBER} {GH_FLAG} --json mergeCommit --jq '.mergeCommit.oid // ""' 2>/dev/null || echo "")
1253
1614
 
1254
- gh pr comment {PR_NUMBER} {GH_FLAG} --body "<!-- FORGE:DECISION_RECORD -->
1615
+ GDR_FILE=$(mktemp)
1616
+ cat > "$GDR_FILE" <<GDR_EOF
1617
+ <!-- FORGE:DECISION_RECORD -->
1255
1618
  ## Graph Decision Record — Issue #${NUMBER} / PR #${PR_NUMBER}
1256
1619
 
1257
1620
  \`\`\`json
1258
1621
  {
1259
- \"schema_version\": \"1\",
1260
- \"issue\": ${NUMBER},
1261
- \"pr\": ${PR_NUMBER},
1262
- \"repo\": \"{GH_REPO}\",
1263
- \"lane\": \"feature\",
1264
- \"pr_base\": \"{PR_BASE}\",
1265
- \"branch\": \"{BRANCH}\",
1266
- \"head_sha\": \"${HEAD_SHA}\",
1267
- \"merge_commit\": \"${MERGE_COMMIT}\",
1268
- \"investigation\": {
1269
- \"verdict\": \"{VERDICT}\",
1270
- \"confidence\": \"{CONFIDENCE}\",
1271
- \"task_type\": \"{TASK_TYPE}\"
1622
+ "schema_version": "1",
1623
+ "issue": ${NUMBER},
1624
+ "pr": ${PR_NUMBER},
1625
+ "repo": "${GH_REPO}",
1626
+ "lane": "${LANE}",
1627
+ "pr_base": "${PR_BASE}",
1628
+ "branch": "${BRANCH}",
1629
+ "head_sha": "${HEAD_SHA}",
1630
+ "merge_commit": "${MERGE_COMMIT}",
1631
+ "investigation": {
1632
+ "verdict": "${VERDICT}",
1633
+ "confidence": "${CONFIDENCE}",
1634
+ "task_type": "${TASK_TYPE}"
1272
1635
  },
1273
- \"context\": {
1274
- \"historical_edges_referenced\": ${REVIEW_FINDING_COUNT},
1275
- \"forge_annotations_read\": [\"FORGE:INVESTIGATOR\", \"FORGE:CONTRACT\", \"FORGE:CONTEXT\", \"FORGE:ARCHITECT\", \"FORGE:BUILDER\"]
1636
+ ${COST_BLOCK}
1637
+ "context": {
1638
+ "historical_edges_referenced": ${REVIEW_FINDING_COUNT},
1639
+ "forge_annotations_read": ["FORGE:INVESTIGATOR", "FORGE:CONTRACT", "FORGE:CONTEXT", "FORGE:ARCHITECT", "FORGE:BUILDER"]
1276
1640
  },
1277
- \"build\": {
1278
- \"files_changed\": {FILES_CHANGED},
1279
- \"quality_gate\": \"{pass|fail}\",
1280
- \"quality_gate_iterations\": {GATE_ITERATIONS}
1641
+ "build": {
1642
+ "files_changed": ${FILES_CHANGED_JSON},
1643
+ "quality_gate": "${GATE_PASS_FAIL}",
1644
+ "quality_gate_iterations": ${GATE_ITERATIONS}
1281
1645
  },
1282
- \"review\": {
1283
- \"verdict\": \"${REVIEW_VERDICT:-APPROVED}\",
1284
- \"findings_created\": ${FINDINGS_COUNT},
1285
- \"agents_run\": ${AGENTS_RUN}
1646
+ "review": {
1647
+ "verdict": "${REVIEW_VERDICT}",
1648
+ "findings_created": ${FINDINGS_COUNT},
1649
+ "agents_run": ${AGENTS_RUN}
1286
1650
  },
1287
- \"merge\": {
1288
- \"merged_at\": \"${GDR_TIMESTAMP}\",
1289
- \"justification\": \"Investigation confirmed ({VERDICT}/{CONFIDENCE}), quality gate passed, review ${REVIEW_VERDICT:-approved}\"
1651
+ "merge": {
1652
+ "merged_at": "${GDR_TIMESTAMP}",
1653
+ "justification": "Investigation confirmed (${VERDICT}/${CONFIDENCE}), quality gate ${GATE_PASS_FAIL}, review ${REVIEW_VERDICT}"
1290
1654
  }
1291
1655
  }
1292
- \`\`\`"
1656
+ \`\`\`
1657
+
1658
+ **Queryable**: \`gh api repos/${GH_REPO}/issues/${PR_NUMBER}/comments --jq '[.[] | select(.body | contains("FORGE:DECISION_RECORD"))] | .[0].body'\`
1659
+ GDR_EOF
1660
+ DECISION_RECORD_URL=$(gh pr comment {PR_NUMBER} {GH_FLAG} --body-file "$GDR_FILE" 2>/dev/null || echo "")
1661
+ rm -f "$GDR_FILE"
1293
1662
  fi
1663
+ DECISION_RECORD_URL="${DECISION_RECORD_URL:-}"
1294
1664
  ```
1295
1665
 
1296
- <!-- Added: forge#776 -->
1666
+ **Benchmarking** (reference — used by `/pipeline-health`): query all GDRs for a repo to compute pipeline metrics (repeated-mistake rate, stale-edge hit rate, review escape rate):
1667
+ ```bash
1668
+ # Fetch all merged PRs and extract their GDR JSON blocks for metric computation
1669
+ gh pr list -R {GH_REPO} --state merged --limit 100 --json number \
1670
+ --jq '.[].number' | while read pr; do
1671
+ gh api repos/{GH_REPO}/issues/$pr/comments \
1672
+ --jq '.[] | select(.body | contains("FORGE:DECISION_RECORD")) | .body' 2>/dev/null
1673
+ done
1674
+ ```
1297
1675
 
1298
1676
  ---
1299
1677
 
@@ -1301,7 +1679,7 @@ fi
1301
1679
 
1302
1680
  Remove the git worktree and delete the local feature branch after the PR has merged. This prevents worktree accumulation across pipeline runs.
1303
1681
 
1304
- **Skip if**: `{WORKTREE_PATH}` is not provided OR the path does not exist.
1682
+ **Skip if**: `{WORKTREE_PATH}` is not provided OR the path does not exist OR the close stopped with `status: FAILED` (the worktree is kept so the run can be retried).
1305
1683
 
1306
1684
  ```bash
1307
1685
  # Remove worktree (--force handles detached or uncommitted state)
@@ -1330,35 +1708,28 @@ Set `{CLEANUP_STATUS}` based on outcome:
1330
1708
  - Worktree path provided and existed → `✅ Removed` (worktree removed, branch deleted)
1331
1709
  - Worktree path not provided or path didn't exist → `⏭ Skipped`
1332
1710
 
1333
- Update the trajectory log `{CLEANUP_STATUS}` field accordingly (the trajectory was already posted in C5, so this is recorded in the output summary, not by re-editing the comment).
1711
+ The trajectory (C5) already recorded the planned `CLEANUP_STATUS` (computed from the same path check; the removal commands above are non-fatal, so plan and outcome match). Do not re-edit the comment. C6 is the LAST phase: it must stay last because C1.7 and C5.4 use the repo/worktree. After C6, print the Phase C4 report and C4.5b card if not already shown, then the Output block.
1334
1712
 
1335
1713
  ---
1336
1714
 
1337
1715
  ## Output
1338
1716
 
1339
- Return structured output to the caller:
1717
+ Every exit path ends with exactly one `CLOSE_RESULT:` block as the final reply — it is ALL the router sees. Fill it from the shell state:
1340
1718
 
1341
1719
  ```
1342
1720
  CLOSE_RESULT:
1343
- status: COMPLETE | ALREADY_DONE | PHASE_COMPLETE
1721
+ status: COMPLETE | ALREADY_DONE | PHASE_COMPLETE | FAILED
1344
1722
  issue_state: closed | open
1345
1723
  trajectory_url: {url of FORGE:TRAJECTORY comment}
1346
1724
  decision_record_url: {url of FORGE:DECISION_RECORD comment on PR, or "" if skipped}
1347
1725
  parent_updated: {true|false}
1348
1726
  parent_closed: {true|false}
1727
+ blocker: {"" unless status is FAILED — "<phase>: <reason>"}
1349
1728
  ```
1350
1729
 
1351
- Where `PHASE_COMPLETE` means the current phase was closed but uncompleted phases remain — the issue is left OPEN for work-on.md to re-evaluate on the next invocation (via Universal continuation rule: re-read labels, check terminal state, continue to next phase).
1352
-
1353
- ---
1354
-
1355
- ## Integration Point in work-on.md
1356
-
1357
- This module runs at **Phases 6–7** — after review.md returns `REVIEW_RESULT: status: COMPLETE`:
1358
-
1359
- ```
1360
- 4–5 → Review (by review.md) — PR created, reviewed, merged, issue closed
1361
- 6–7 → [THIS MODULE] Final body update, parent tracker, summary, trajectory
1362
- ```
1730
+ - `COMPLETE` — all applicable phases ran; the pipeline is finished for this issue.
1731
+ - `ALREADY_DONE` — a `FORGE:TRAJECTORY` comment already existed (Phase C0); nothing was re-posted.
1732
+ - `PHASE_COMPLETE` — the current phase was closed but uncompleted phases remain (Phase C2); the issue is left OPEN with `<!-- FORGE:PHASE:COMPLETE -->` and `workflow:investigating`. The caller re-reads labels and continues with the next phase. Returned directly from C2 — C3–C6 do not run.
1733
+ - `FAILED` — a guard, missing arg, or mandatory phase failed (see Failure handling in Inputs); `blocker` says which. The worktree is kept.
1363
1734
 
1364
- This is the terminal phase — after CLOSE_RESULT returns, the pipeline is complete.
1735
+ For `status: COMPLETE`, the Phase C4 report and the Phase C4.5b card are printed to stdout BEFORE the result block so the caller can surface them; the result block is the last thing printed. `issue_state` is the live issue state (`gh issue view --json state`); `parent_updated` / `parent_closed` come from Phase C3 (`false` when C3 was skipped).