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.
- 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/design-bench.md +1 -0
- package/commands/design-render-critique-loop.md +1 -0
- package/commands/design.md +1 -0
- package/commands/forge-stats.md +1 -0
- 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 +3 -2
- package/commands/orchestrate/phase-1-resolve.md +57 -12
- package/commands/orchestrate/phase-2-triage.md +1 -0
- package/commands/orchestrate/phase-2.5-synthesis.md +1 -0
- package/commands/orchestrate/phase-3-dependency.md +17 -5
- package/commands/orchestrate/phase-4-execution.md +625 -78
- package/commands/orchestrate/phase-5-cleanup.md +5 -2
- package/commands/orchestrate/phase-6-report.md +8 -3
- package/commands/orchestrate/safety.md +1 -0
- package/commands/orchestrate.md +13 -9
- package/commands/pipeline-health/config.md +1 -0
- package/commands/pipeline-health/phase-1-context.md +1 -0
- package/commands/pipeline-health/phase-2-metrics.md +1 -0
- package/commands/pipeline-health/phase-3-analyze.md +1 -0
- package/commands/pipeline-health/phase-4-proposals.md +7 -4
- package/commands/pipeline-health/phase-5-report.md +5 -2
- package/commands/pipeline-health/phase-6-summary.md +1 -0
- 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 +219 -13
- package/commands/review-pr-agents/api.md +1 -0
- package/commands/review-pr-agents/auth.md +1 -0
- package/commands/review-pr-agents/billing.md +1 -0
- package/commands/review-pr-agents/concurrency.md +1 -0
- package/commands/review-pr-agents/database.md +1 -0
- package/commands/review-pr-agents/frontend.md +1 -0
- package/commands/review-pr-agents/infra.md +1 -0
- package/commands/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-agents/scraper.md +1 -0
- package/commands/review-pr-agents/security.md +1 -0
- package/commands/review-pr-agents/spec-cli.md +1 -0
- package/commands/review-pr-agents.md +10 -4
- package/commands/review-pr-staging.md +14 -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 +82 -19
- package/commands/work-on/build/context.md +62 -18
- package/commands/work-on/build/implement.md +63 -18
- package/commands/work-on/build/validate.md +241 -60
- package/commands/work-on/build.md +345 -146
- package/commands/work-on/close.md +618 -246
- package/commands/work-on/decompose.md +240 -41
- package/commands/work-on/investigate.md +378 -61
- package/commands/work-on/remediate.md +43 -9
- package/commands/work-on/review.md +241 -29
- 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 +21 -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
- 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
|
|
13
|
-
**Output**: Update project board, close issue, update parent tracker, post trajectory log.
|
|
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}`
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
58
|
-
- If issue is already CLOSED and PR is MERGED →
|
|
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
|
-
|
|
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(
|
|
284
|
+
import(require('node:url').pathToFileURL(process.argv[1]).href)
|
|
83
285
|
.then(m => {
|
|
84
|
-
const decls = m.loadInvariants(
|
|
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(
|
|
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
|
|
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'"
|
|
148
|
-
gh issue edit {NUMBER} {GH_FLAG} --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
|
|
188
|
-
#
|
|
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'"
|
|
195
|
-
gh issue edit {NUMBER} {GH_FLAG} --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'"
|
|
201
|
-
gh issue edit {NUMBER} {GH_FLAG} --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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
**
|
|
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 —
|
|
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
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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**:
|
|
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 -
|
|
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
|
|
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=$(
|
|
529
|
-
gh issue edit
|
|
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
|
|
532
|
-
OPEN_SUBS=$(
|
|
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
|
-
|
|
538
|
-
--comment "All sub-issues complete. Closing parent tracker. Last completed: #{NUMBER} (PR
|
|
539
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
629
|
-
#
|
|
630
|
-
#
|
|
631
|
-
#
|
|
632
|
-
#
|
|
633
|
-
|
|
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
|
|
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 "
|
|
908
|
+
--arg title "$TITLE" \
|
|
678
909
|
--arg status "$CARD_STATUS" \
|
|
679
910
|
--arg pipeline "$PIPELINE_LINE" \
|
|
680
|
-
--arg pr "
|
|
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 ...`
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
1011
|
+
### Step 2: Prior delta computation
|
|
707
1012
|
|
|
708
|
-
|
|
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
|
|
718
|
-
#
|
|
719
|
-
ACTUAL_TOTAL_USD=$(
|
|
720
|
-
|
|
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
|
|
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 '')
|
|
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="${
|
|
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
|
-
|
|
752
|
-
|
|
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 |
|
|
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
|
|
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
|
-
|
|
768
|
-
|
|
1092
|
+
${DECISIONS_BLOCK}
|
|
1093
|
+
|
|
1094
|
+
**Anomalies**: ${ANOMALIES_TEXT}
|
|
769
1095
|
|
|
770
1096
|
**Cost (economic scheduling)**: ${COST_DELTA_NOTE}
|
|
771
1097
|
|
|
772
|
-
**
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
--
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
-
|
|
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>`.
|
|
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
|
-
- `
|
|
806
|
-
- `
|
|
807
|
-
- `
|
|
808
|
-
- `
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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="
|
|
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 '
|
|
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 -
|
|
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/^[-*]
|
|
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.
|
|
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
|
|
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=$(
|
|
1233
|
-
|
|
1234
|
-
REVIEW_FINDING_COUNT=$(
|
|
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
|
-
|
|
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
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1642
|
+
"build": {
|
|
1643
|
+
"files_changed": ${FILES_CHANGED_JSON},
|
|
1644
|
+
"quality_gate": "${GATE_PASS_FAIL}",
|
|
1645
|
+
"quality_gate_iterations": ${GATE_ITERATIONS}
|
|
1281
1646
|
},
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1647
|
+
"review": {
|
|
1648
|
+
"verdict": "${REVIEW_VERDICT}",
|
|
1649
|
+
"findings_created": ${FINDINGS_COUNT},
|
|
1650
|
+
"agents_run": ${AGENTS_RUN}
|
|
1286
1651
|
},
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|