forgedock 1.8.1 → 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.
- package/bin/engine/admission.mjs +57 -1
- package/bin/engine/admission.test.mjs +60 -0
- package/commands/analytics.md +3 -1
- package/commands/audit-agents.md +3 -1
- package/commands/audit.md +10 -8
- package/commands/autopilot.md +16 -14
- package/commands/ci-audit.md +4 -2
- package/commands/cleanup.md +1 -1
- package/commands/deploy-pr.md +31 -11
- package/commands/geo-audit.md +3 -1
- package/commands/incident-response.md +3 -1
- package/commands/issue.md +4 -2
- package/commands/milestone.md +8 -6
- package/commands/orchestrate/config.md +2 -2
- package/commands/orchestrate/phase-1-resolve.md +56 -12
- package/commands/orchestrate/phase-3-dependency.md +15 -5
- package/commands/orchestrate/phase-4-execution.md +593 -78
- package/commands/orchestrate/phase-5-cleanup.md +4 -2
- package/commands/orchestrate/phase-6-report.md +7 -3
- package/commands/orchestrate.md +13 -9
- package/commands/pipeline-health/phase-4-proposals.md +6 -4
- package/commands/pipeline-health/phase-5-report.md +4 -2
- package/commands/pipeline-health.md +9 -7
- package/commands/pipeline-resume.md +4 -2
- package/commands/qa-sweep.md +3 -1
- package/commands/quality-gate.md +4 -3
- package/commands/recover-orphans.md +172 -13
- package/commands/review-pr-agents.md +6 -4
- package/commands/review-pr-staging.md +13 -5
- package/commands/review-pr.md +187 -19
- package/commands/rollback.md +3 -1
- package/commands/security-audit.md +3 -1
- package/commands/signal-planner.md +6 -4
- package/commands/sync-ecosystem.md +4 -2
- package/commands/test-gate.md +4 -2
- package/commands/upgrade-deps.md +3 -1
- package/commands/validate.md +3 -1
- package/commands/work-on/build/architect.md +81 -19
- package/commands/work-on/build/context.md +61 -18
- package/commands/work-on/build/implement.md +62 -18
- package/commands/work-on/build/validate.md +240 -60
- package/commands/work-on/build.md +344 -146
- package/commands/work-on/close.md +617 -246
- package/commands/work-on/decompose.md +239 -41
- package/commands/work-on/investigate.md +377 -61
- package/commands/work-on/remediate.md +42 -9
- package/commands/work-on/review.md +240 -29
- package/commands/work-on-monolithic.md +48 -7
- package/commands/work-on.md +180 -2014
- package/docs/CODEX.md +2 -0
- package/docs/CONFIG.md +11 -0
- package/package.json +1 -1
- package/scripts/build-spec-graph.mjs +11 -4
- package/scripts/check-spec-markers.sh +11 -1
- package/scripts/checkbox-sections.test.sh +8 -23
- package/scripts/extract-affected-files.sh +5 -2
- package/scripts/extract-affected-files.test.sh +27 -0
- package/scripts/forge-root.test.sh +176 -0
- package/scripts/is-docs-only.sh +31 -0
- package/scripts/is-docs-only.test.sh +64 -0
- package/scripts/lint-dispatch-prompt.sh +217 -0
- package/scripts/lint-dispatch-prompt.test.sh +307 -0
- package/scripts/validate-spec-graph.sh +3 -2
- package/scripts/verify-phase-trail.sh +282 -0
- package/scripts/verify-phase-trail.test.sh +298 -0
- package/scripts/wait-ci-green.sh +98 -0
- 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
|
|
13
|
-
**Output**: Update project board, close issue, update parent tracker, post trajectory log.
|
|
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}`
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
58
|
-
- If issue is already CLOSED and PR is MERGED →
|
|
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
|
-
|
|
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(
|
|
283
|
+
import(require('node:url').pathToFileURL(process.argv[1]).href)
|
|
83
284
|
.then(m => {
|
|
84
|
-
const decls = m.loadInvariants(
|
|
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(
|
|
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
|
|
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'"
|
|
148
|
-
gh issue edit {NUMBER} {GH_FLAG} --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
|
|
188
|
-
#
|
|
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'"
|
|
195
|
-
gh issue edit {NUMBER} {GH_FLAG} --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'"
|
|
201
|
-
gh issue edit {NUMBER} {GH_FLAG} --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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
**
|
|
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 —
|
|
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
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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**:
|
|
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 -
|
|
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
|
|
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=$(
|
|
529
|
-
gh issue edit
|
|
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
|
|
532
|
-
OPEN_SUBS=$(
|
|
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
|
-
|
|
538
|
-
--comment "All sub-issues complete. Closing parent tracker. Last completed: #{NUMBER} (PR
|
|
539
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
629
|
-
#
|
|
630
|
-
#
|
|
631
|
-
#
|
|
632
|
-
#
|
|
633
|
-
|
|
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
|
|
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 "
|
|
907
|
+
--arg title "$TITLE" \
|
|
678
908
|
--arg status "$CARD_STATUS" \
|
|
679
909
|
--arg pipeline "$PIPELINE_LINE" \
|
|
680
|
-
--arg pr "
|
|
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 ...`
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
1010
|
+
### Step 2: Prior delta computation
|
|
707
1011
|
|
|
708
|
-
|
|
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
|
|
718
|
-
#
|
|
719
|
-
ACTUAL_TOTAL_USD=$(
|
|
720
|
-
|
|
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
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
| grep -
|
|
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="${
|
|
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
|
-
|
|
752
|
-
|
|
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 |
|
|
757
|
-
| Phase 1: Investigation | ✅ {VERDICT} ({CONFIDENCE}) | Task type: {TASK_TYPE} |
|
|
758
|
-
| Phase 2: Decomposition |
|
|
759
|
-
| Phase 3: Build |
|
|
760
|
-
| Phase
|
|
761
|
-
| Phase
|
|
762
|
-
| Phase
|
|
763
|
-
| Phase
|
|
764
|
-
| Phase
|
|
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
|
-
|
|
768
|
-
|
|
1091
|
+
${DECISIONS_BLOCK}
|
|
1092
|
+
|
|
1093
|
+
**Anomalies**: ${ANOMALIES_TEXT}
|
|
769
1094
|
|
|
770
1095
|
**Cost (economic scheduling)**: ${COST_DELTA_NOTE}
|
|
771
1096
|
|
|
772
|
-
**
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
--
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
payload. Platform consumers (e.g
|
|
794
|
-
|
|
795
|
-
|
|
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
|
-
- `
|
|
806
|
-
- `
|
|
807
|
-
- `
|
|
808
|
-
- `
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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="
|
|
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 '
|
|
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 -
|
|
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/^[-*]
|
|
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.
|
|
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
|
|
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=$(
|
|
1233
|
-
|
|
1234
|
-
REVIEW_FINDING_COUNT=$(
|
|
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
|
-
|
|
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
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1641
|
+
"build": {
|
|
1642
|
+
"files_changed": ${FILES_CHANGED_JSON},
|
|
1643
|
+
"quality_gate": "${GATE_PASS_FAIL}",
|
|
1644
|
+
"quality_gate_iterations": ${GATE_ITERATIONS}
|
|
1281
1645
|
},
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1646
|
+
"review": {
|
|
1647
|
+
"verdict": "${REVIEW_VERDICT}",
|
|
1648
|
+
"findings_created": ${FINDINGS_COUNT},
|
|
1649
|
+
"agents_run": ${AGENTS_RUN}
|
|
1286
1650
|
},
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|