forgedock 1.8.1 → 1.8.3

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