forgedock 1.8.0 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/bin/engine/admission.mjs +57 -1
  2. package/bin/engine/admission.test.mjs +60 -0
  3. package/commands/analytics.md +3 -1
  4. package/commands/audit-agents.md +3 -1
  5. package/commands/audit.md +10 -8
  6. package/commands/autopilot.md +16 -14
  7. package/commands/ci-audit.md +4 -2
  8. package/commands/cleanup.md +1 -1
  9. package/commands/deploy-pr.md +31 -11
  10. package/commands/geo-audit.md +3 -1
  11. package/commands/incident-response.md +3 -1
  12. package/commands/issue.md +4 -2
  13. package/commands/milestone.md +8 -6
  14. package/commands/orchestrate/config.md +2 -2
  15. package/commands/orchestrate/phase-1-resolve.md +56 -12
  16. package/commands/orchestrate/phase-3-dependency.md +15 -5
  17. package/commands/orchestrate/phase-4-execution.md +593 -78
  18. package/commands/orchestrate/phase-5-cleanup.md +4 -2
  19. package/commands/orchestrate/phase-6-report.md +7 -3
  20. package/commands/orchestrate.md +13 -9
  21. package/commands/pipeline-health/phase-4-proposals.md +6 -4
  22. package/commands/pipeline-health/phase-5-report.md +4 -2
  23. package/commands/pipeline-health.md +9 -7
  24. package/commands/pipeline-resume.md +4 -2
  25. package/commands/qa-sweep.md +3 -1
  26. package/commands/quality-gate.md +4 -3
  27. package/commands/recover-orphans.md +172 -13
  28. package/commands/review-pr-agents.md +6 -4
  29. package/commands/review-pr-staging.md +13 -5
  30. package/commands/review-pr.md +187 -19
  31. package/commands/rollback.md +3 -1
  32. package/commands/security-audit.md +3 -1
  33. package/commands/signal-planner.md +6 -4
  34. package/commands/sync-ecosystem.md +4 -2
  35. package/commands/test-gate.md +4 -2
  36. package/commands/upgrade-deps.md +4 -2
  37. package/commands/validate.md +3 -1
  38. package/commands/work-on/build/architect.md +81 -19
  39. package/commands/work-on/build/context.md +61 -18
  40. package/commands/work-on/build/implement.md +62 -18
  41. package/commands/work-on/build/validate.md +240 -60
  42. package/commands/work-on/build.md +344 -146
  43. package/commands/work-on/close.md +617 -246
  44. package/commands/work-on/decompose.md +239 -41
  45. package/commands/work-on/investigate.md +377 -61
  46. package/commands/work-on/remediate.md +42 -9
  47. package/commands/work-on/review.md +240 -29
  48. package/commands/work-on-monolithic.md +48 -7
  49. package/commands/work-on.md +180 -2014
  50. package/docs/CODEX.md +2 -0
  51. package/docs/CONFIG.md +11 -0
  52. package/package.json +1 -1
  53. package/scripts/build-spec-graph.mjs +11 -4
  54. package/scripts/check-spec-markers.sh +11 -1
  55. package/scripts/checkbox-sections.test.sh +8 -23
  56. package/scripts/extract-affected-files.sh +5 -2
  57. package/scripts/extract-affected-files.test.sh +27 -0
  58. package/scripts/forge-root.test.sh +176 -0
  59. package/scripts/is-docs-only.sh +31 -0
  60. package/scripts/is-docs-only.test.sh +64 -0
  61. package/scripts/lint-dispatch-prompt.sh +217 -0
  62. package/scripts/lint-dispatch-prompt.test.sh +307 -0
  63. package/scripts/validate-spec-graph.sh +3 -2
  64. package/scripts/verify-phase-trail.sh +282 -0
  65. package/scripts/verify-phase-trail.test.sh +298 -0
  66. package/scripts/wait-ci-green.sh +98 -0
  67. package/scripts/wait-ci-green.test.sh +99 -0
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Investigate a GitHub issue — validate it's real, determine root cause, post findings
3
- argument-hint: "[issue number] [--repo {owner}/{repo}] [--gh-flag \"-R {owner}/{repo}\"]"
3
+ argument-hint: "{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\""
4
+ context: fork
4
5
  ---
5
6
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
6
7
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
@@ -11,7 +12,9 @@ argument-hint: "[issue number] [--repo {owner}/{repo}] [--gh-flag \"-R {owner}/{
11
12
 
12
13
  Standalone investigation phase for the work-on pipeline. Validates whether an issue is real, determines root cause, posts a structured FORGE:INVESTIGATOR comment to GitHub, and updates workflow labels.
13
14
 
14
- **Agent model policy**: `model: "{DEFAULT_MODEL}"` — resolved from forge.yaml `agents.default_model`, else "sonnet" (standard tier). Fallback: `model: "opus"` if rate-limited. Feature gate: pass `effort` in Task/Skill spawns only on Claude Code >= 2.1.154. This file's mechanical bits (1A label set, `FORGE:CHECKPOINT` writes) stay at this tier because they're interleaved with the reasoning-heavy investigation steps in the same `Skill()` invocation — see `work-on.md` section "Model and Effort Tiering — What Actually Applies". <!-- Added: forge#1827 -->
15
+ This skill runs in an **isolated forked context**: it sees only this file and its args. It has no variables from the caller — every other piece of state is re-read from GitHub/git here. It ends by printing exactly one `INVESTIGATE_RESULT:` block (see `## Output`) as its final reply, on **every** exit path (success, ALREADY_DONE, INVALID, BLOCKED).
16
+
17
+ **Agent model policy**: `model: "{DEFAULT_MODEL}"` — resolved from forge.yaml `agents.default_model`, else "sonnet" (standard tier). Fallback: `model: "opus"` if rate-limited. Feature gate: pass `effort` in Task/Skill spawns only on Claude Code >= 2.1.154. This file's mechanical bits (1A label set, 1D label transitions) stay at this tier because they're interleaved with the reasoning-heavy investigation steps in the same forked `Skill()` invocation. <!-- Added: forge#1827 -->
15
18
  **NEVER use plan mode (EnterPlanMode).**
16
19
 
17
20
  <!-- FORGE:SPEC_LOADED — work-on/investigate.md loaded and active. Agent is bound by this spec. -->
@@ -20,15 +23,99 @@ Standalone investigation phase for the work-on pipeline. Validates whether an is
20
23
 
21
24
  ## Inputs
22
25
 
23
- Parse from $ARGUMENTS:
26
+ Parse from $ARGUMENTS (`{NUMBER} --repo {GH_REPO} --gh-flag "{GH_FLAG}"`):
24
27
  - `{NUMBER}` — issue number (required)
25
- - `--repo {GH_REPO}` — GitHub repo (e.g. `{owner}/{repo}` — resolved from `forge.yaml → project`)
28
+ - `--repo {GH_REPO}` — GitHub repo (e.g. `{owner}/{repo}`)
26
29
  - `--gh-flag {GH_FLAG}` — gh CLI repo flag (e.g. `-R {owner}/{repo}`)
27
30
 
28
- If called from `work-on`, these are passed through. If invoked standalone, `--repo` and `--gh-flag` are resolved from `forge.yaml → project`.
31
+ The router passes all three. If `--repo` / `--gh-flag` are absent (standalone use), resolve them from `forge.yaml → project`.
32
+
33
+ **Fail closed**: if `{NUMBER}` is missing/non-numeric, or `{GH_REPO}` cannot be determined, print the result block below and stop — do not guess:
34
+
35
+ ```
36
+ INVESTIGATE_RESULT:
37
+ status: BLOCKED
38
+ verdict: null
39
+ confidence: null
40
+ decompose: null
41
+ comment_url: null
42
+ gist_url: null
43
+ milestone_index_url: null
44
+ blocker: missing required arg: <which one>
45
+ ```
46
+
47
+ **Execution order**: run the Phase 1A resume check FIRST. If it reports ALREADY_DONE, print that result and stop — skip Phases 0.5, 0.6 and everything else. Otherwise run 0.5 → 0.6 → 1A (label) → 1A.5 → 1B → 1C → 1C.5 → 1C.6 → 1D.
29
48
 
30
49
  ---
31
50
 
51
+ ## Script resolution
52
+
53
+ **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`.
54
+
55
+ ```bash
56
+ # Canonical script resolution (keep byte-identical across specs; guarded by scripts/forge-root.test.sh).
57
+ # Shell state does NOT persist between Bash tool calls: include this whole block at the top of
58
+ # every command that uses resolve_script or FORGE_ROOT.
59
+ REPO_PATH="${REPO_PATH:-$(yq '.paths.root // ""' forge.yaml 2>/dev/null)}"
60
+ [ -n "$REPO_PATH" ] && [ "$REPO_PATH" != "null" ] || REPO_PATH="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
61
+ ADAPTIVE_DIR_RAW="${REPO_PATH}/$(yq '.adaptive_scripts.directory // ".forgedock/scripts"' forge.yaml 2>/dev/null || echo '.forgedock/scripts')"
62
+ ADAPTIVE_DIR=$(realpath -m "$ADAPTIVE_DIR_RAW" 2>/dev/null || echo "$ADAPTIVE_DIR_RAW")
63
+ ADAPTIVE_ENABLED=$(yq '.adaptive_scripts.enabled // "true"' forge.yaml 2>/dev/null || echo 'true')
64
+ # Bounds check: reject adaptive_scripts.directory values that escape the repo root.
65
+ # Normalize REPO_PATH the same way ADAPTIVE_DIR is normalized (realpath -m) so a trailing
66
+ # slash in paths.root does not inject a '//' into the glob and trigger a false positive.
67
+ REPO_PATH_NORM=$(realpath -m "$REPO_PATH" 2>/dev/null || echo "$REPO_PATH")
68
+ if [[ "$ADAPTIVE_DIR" != "${REPO_PATH_NORM}/"* ]]; then
69
+ echo "WARNING: adaptive_scripts.directory resolves outside repo root ('$ADAPTIVE_DIR') — adaptive tier disabled" >&2
70
+ ADAPTIVE_ENABLED=false
71
+ fi
72
+ # ForgeDock's own install root (holds scripts/ and commands/), resolved ONCE by the canonical bootstrap
73
+ # below — never the consumer repo: plugin installs set neither FORGE_HOME nor FORGEDOCK_HOME, and a
74
+ # repo-relative fallback would execute (or miss) a same-named script controlled by the consumer repo.
75
+ # Resolution: $FORGEDOCK_HOME (authoritative when set) > $FORGE_HOME > $CLAUDE_PLUGIN_ROOT > the
76
+ # ~/.claude/commands symlink target > the Claude Code plugin cache/marketplace dirs. <!-- forge#3098 -->
77
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
78
+ FORGE_ROOT=""
79
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
80
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
81
+ _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")")"
82
+ # 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)
83
+ _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-)"
84
+ _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
85
+ # '${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.
86
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
87
+ while IFS= read -r _c; do
88
+ 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
89
+ done <<< "$_k"
90
+ fi
91
+ UNIVERSAL_DIR="${FORGE_ROOT:+$FORGE_ROOT/scripts}" # empty => tier 3 skipped, prose tier
92
+ # NOTE: never resolve this via `which` or `find` — universal scripts are
93
+ # install-relative, not installed on $PATH, so a PATH lookup always misses.
94
+ # FORGE_ROOT (above) is the deterministic resolution; there is NO repo-path fallback.
95
+ # Pipeline agents MUST NOT use `find` (unbounded or filesystem-wide) to
96
+ # locate pipeline scripts under any circumstances: if UNIVERSAL_DIR/${operation}.sh
97
+ # does not exist, resolve_script() falls through to Tier 4 (prose) below,
98
+ # which is always safe and available. A missing script is never a reason
99
+ # to search the filesystem. <!-- Added: forge#1984 -->
100
+
101
+ resolve_script() {
102
+ local operation="$1"
103
+ # Tier 2: per-repo adaptive (skip if disabled)
104
+ if [ "$ADAPTIVE_ENABLED" != "false" ] && [ -f "${ADAPTIVE_DIR}/${operation}.sh" ]; then
105
+ echo "adaptive:${ADAPTIVE_DIR}/${operation}.sh"
106
+ return
107
+ fi
108
+ # Tier 3: universal script
109
+ if [ -n "$UNIVERSAL_DIR" ] && [ -f "${UNIVERSAL_DIR}/${operation}.sh" ]; then
110
+ echo "universal:${UNIVERSAL_DIR}/${operation}.sh"
111
+ return
112
+ fi
113
+ # Tier 4: prose fallback
114
+ echo "prose:"
115
+ }
116
+ ```
117
+
118
+
32
119
  ## Phase 0.5: Memory Retrieval — Prior Run Priors <!-- Added: forge#1316 -->
33
120
 
34
121
  **Goal**: Before investigating, retrieve the top-k relevant prior pipeline runs from the per-repo memory index. Inject confirmed priors into the investigation context so the pipeline compounds intelligence across runs.
@@ -257,25 +344,124 @@ Store `RECALL_ISSUE_CITATIONS` in a variable — it is used in Phase 1C to popul
257
344
 
258
345
  ## Phase 1A: Load Issue & Check Resume State
259
346
 
347
+ Re-read all state from GitHub (this fork has no inherited context):
348
+
349
+ ```bash
350
+ gh issue view {NUMBER} {GH_FLAG} --json number,title,body,labels,state
351
+ ```
352
+
353
+ **Resume check** — run this FIRST (before Phase 0.5):
354
+
260
355
  ```bash
261
- gh issue view {NUMBER} {GH_FLAG} --json number,title,body,labels,state,comments
262
- gh api repos/{GH_REPO}/issues/{NUMBER}/comments --jq '.[] | {id: .id, body: .body}'
356
+ COMMENTS_JSON=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate --jq '.[] | {id: .id, url: .html_url, body: .body}' 2>/dev/null | jq -s '.' 2>/dev/null)
357
+ [ -z "$COMMENTS_JSON" ] && COMMENTS_JSON='[]'
358
+
359
+ # Complete = INVESTIGATOR header AND (COMPLETE or INVALID sentinel) in the SAME comment. Use the last such comment.
360
+ DONE_JSON=$(echo "$COMMENTS_JSON" | jq -c '[.[] | select(.body | contains("<!-- FORGE:INVESTIGATOR -->"))
361
+ | select((.body | contains("<!-- INVESTIGATION:COMPLETE -->")) or (.body | contains("<!-- INVESTIGATION:INVALID -->")))] | last // empty')
362
+
363
+ # Partial = INVESTIGATOR header but NEITHER sentinel (interrupted run)
364
+ PARTIAL_IDS=$(echo "$COMMENTS_JSON" | jq -r '.[] | select(.body | contains("<!-- FORGE:INVESTIGATOR -->"))
365
+ | select((.body | contains("<!-- INVESTIGATION:COMPLETE -->") | not) and (.body | contains("<!-- INVESTIGATION:INVALID -->") | not)) | .id')
366
+
367
+ if [ -n "$DONE_JSON" ]; then
368
+ DONE_BODY=$(echo "$DONE_JSON" | jq -r '.body')
369
+ DONE_URL=$(echo "$DONE_JSON" | jq -r '.url')
370
+ R_VERDICT=$(printf '%s\n' "$DONE_BODY" | grep -oE '\*\*Verdict\*\*: *[A-Za-z_-]+' | head -1 | sed -E 's/.*: *//')
371
+ R_CONF=$(printf '%s\n' "$DONE_BODY" | grep -oE '\*\*Confidence\*\*: *[A-Za-z]+' | head -1 | sed -E 's/.*: *//')
372
+ if printf '%s\n' "$DONE_BODY" | sed -n '/^### Decomposition Assessment/,/^###[^#]/p' | grep -qE '^\*\*YES\*\*'; then R_DECOMP=YES; else R_DECOMP=NO; fi
373
+ [ "$R_VERDICT" = "INVALID" ] && R_STATUS=INVALID || R_STATUS=ALREADY_DONE
374
+ R_GIST=$(echo "$COMMENTS_JSON" | jq -r '[.[] | .body | select(contains("FORGE:KNOWLEDGE_GIST:"))] | first // ""' | grep -oE 'https://gist[^ ]+' | head -1)
375
+ R_MS_NUM=$(gh issue view {NUMBER} {GH_FLAG} --json milestone --jq '.milestone.number // empty' 2>/dev/null)
376
+ R_MS_IDX=""
377
+ [ -n "$R_MS_NUM" ] && R_MS_IDX=$(gh api "repos/{GH_REPO}/milestones/${R_MS_NUM}" --jq '.description // ""' 2>/dev/null | grep -oE '<!-- FORGE:MILESTONE_INDEX: https://[^ ]+ -->' | head -1 | sed -E 's/^<!-- FORGE:MILESTONE_INDEX: //; s/ -->$//')
378
+ echo "INVESTIGATE_RESULT:"
379
+ echo " status: $R_STATUS"
380
+ echo " verdict: ${R_VERDICT:-null}"
381
+ echo " confidence: ${R_CONF:-null}"
382
+ echo " decompose: $R_DECOMP"
383
+ echo " comment_url: $DONE_URL"
384
+ echo " gist_url: ${R_GIST:-null}"
385
+ echo " milestone_index_url: ${R_MS_IDX:-null}"
386
+ echo " blocker: null"
387
+ echo "RESUME: investigation already complete — copy the block above verbatim as your final reply and STOP."
388
+ else
389
+ for CID in $PARTIAL_IDS; do
390
+ gh api "repos/{GH_REPO}/issues/comments/${CID}" -X DELETE 2>/dev/null || true # interrupted investigation: delete partial comment and restart
391
+ done
392
+ echo "RESUME: no completed investigation — proceeding with a fresh run."
393
+ fi
263
394
  ```
264
395
 
265
- **Resume logic**:
266
- - If `<!-- FORGE:INVESTIGATOR -->` comment exists AND (`<!-- INVESTIGATION:COMPLETE -->` OR `<!-- INVESTIGATION:INVALID -->`) is present in the SAME comment → investigation already complete, EXIT (return existing verdict to caller). `INVESTIGATION:INVALID` is the terminal sentinel Phase 1C emits for an INVALID verdict (see Phase 1C below) — it is just as much a completion marker as `INVESTIGATION:COMPLETE`, not an "interrupted" state.
267
- - If `<!-- FORGE:INVESTIGATOR -->` comment exists BUT NEITHER `<!-- INVESTIGATION:COMPLETE -->` NOR `<!-- INVESTIGATION:INVALID -->` is present → investigation was interrupted, delete the partial comment and restart:
268
- ```bash
269
- gh api repos/{GH_REPO}/issues/comments/{COMMENT_ID} -X DELETE
270
- ```
271
- - If no investigator comment → proceed with fresh investigation
396
+ **Resume logic** (what the block implements):
397
+ - `<!-- FORGE:INVESTIGATOR -->` comment AND (`<!-- INVESTIGATION:COMPLETE -->` OR `<!-- INVESTIGATION:INVALID -->`) in the SAME comment → investigation already complete. Print the parsed existing verdict as the `INVESTIGATE_RESULT:` block (`status: ALREADY_DONE`, or `status: INVALID` when the existing verdict is INVALID) and **STOP** — do not run any later phase. `INVESTIGATION:INVALID` is the terminal sentinel Phase 1C emits for an INVALID verdict (see Phase 1C) — it is just as much a completion marker as `INVESTIGATION:COMPLETE`.
398
+ - `<!-- FORGE:INVESTIGATOR -->` comment but NEITHER sentinel → investigation was interrupted; the partial comment(s) are deleted and the run restarts.
399
+ - No investigator comment → fresh investigation.
400
+
401
+ **Set label** (only reached when not ALREADY_DONE). Use the tiered transition script; the prose fallback adds `workflow:investigating` and removes every other `workflow:*` state label:
402
+
403
+ ```bash
404
+ RESOLUTION=$(resolve_script 'transition-label'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
405
+ case "$TIER" in
406
+ adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} investigating ;;
407
+ prose)
408
+ gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:investigating" --remove-label "workflow:ready-to-build,workflow:building,workflow:in-review,workflow:awaiting-merge,workflow:merged,workflow:invalid,workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects
409
+ ;;
410
+ esac
411
+ ```
412
+
413
+ ---
414
+
415
+ ### 1A.5: Normalize Issue Body (MANDATORY)
416
+
417
+ Before investigation begins, verify the issue body contains the four mandatory pipeline sections. If any are missing, add placeholder content so the investigator has the correct scaffolding.
418
+
419
+ **Skip if**: All four sections (`## Problem`, `## Affected Files`, `## Expected Behavior`, `## Acceptance Criteria`) are already present.
272
420
 
273
- **Set label**:
274
421
  ```bash
275
- gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:investigating"
276
- gh issue edit {NUMBER} {GH_FLAG} --remove-label "workflow:ready-to-build,workflow:building,workflow:in-review" 2>/dev/null || true
422
+ ISSUE_BODY=$(gh issue view {NUMBER} {GH_FLAG} --json body --jq '.body')
423
+
424
+ MISSING_SECTIONS=""
425
+ echo "$ISSUE_BODY" | grep -q "^## Problem" || MISSING_SECTIONS="$MISSING_SECTIONS PROBLEM"
426
+ echo "$ISSUE_BODY" | grep -q "^## Affected Files" || MISSING_SECTIONS="$MISSING_SECTIONS AFFECTED_FILES"
427
+ echo "$ISSUE_BODY" | grep -q "^## Expected Behavior" || MISSING_SECTIONS="$MISSING_SECTIONS EXPECTED_BEHAVIOR"
428
+ echo "$ISSUE_BODY" | grep -q "^## Acceptance Criteria" || MISSING_SECTIONS="$MISSING_SECTIONS ACCEPTANCE_CRITERIA"
429
+
430
+ if [ -n "$MISSING_SECTIONS" ]; then
431
+ echo "Missing sections:$MISSING_SECTIONS — normalizing issue body before investigation"
432
+
433
+ APPEND_TEXT=""
434
+ echo "$MISSING_SECTIONS" | grep -q "PROBLEM" && APPEND_TEXT="$APPEND_TEXT
435
+ ## Problem
436
+
437
+ Root cause unknown — investigation needed."
438
+
439
+ echo "$MISSING_SECTIONS" | grep -q "AFFECTED_FILES" && APPEND_TEXT="$APPEND_TEXT
440
+ ## Affected Files
441
+
442
+ Files to be identified during investigation."
443
+
444
+ echo "$MISSING_SECTIONS" | grep -q "EXPECTED_BEHAVIOR" && APPEND_TEXT="$APPEND_TEXT
445
+ ## Expected Behavior
446
+
447
+ Expected behavior to be determined during investigation."
448
+
449
+ echo "$MISSING_SECTIONS" | grep -q "ACCEPTANCE_CRITERIA" && APPEND_TEXT="$APPEND_TEXT
450
+ ## Acceptance Criteria
451
+
452
+ - [ ] Fix confirmed during investigation."
453
+
454
+ # Append missing sections to the existing body (never replace — only extend)
455
+ NORMALIZED_BODY="${ISSUE_BODY}${APPEND_TEXT}"
456
+ gh issue edit {NUMBER} {GH_FLAG} --body "$NORMALIZED_BODY" # allowlist:check-command-side-effects
457
+ echo "Issue body normalized — added:$MISSING_SECTIONS"
458
+ else
459
+ echo "Issue body already contains all mandatory sections — skipping normalization"
460
+ fi
277
461
  ```
278
462
 
463
+ **Continue to Phase 1B unconditionally.** Normalization is a compensation step — it never blocks investigation.
464
+
279
465
  ---
280
466
 
281
467
  ## Phase 1B: Investigate
@@ -293,6 +479,14 @@ The target repo is `{GH_REPO}` (resolved from `forge.yaml → project`). The wor
293
479
  - Infrastructure: `.github/workflows/`, `docker-compose*.yml`, `infra/`
294
480
  - Config: `forge.yaml`, `.env.example`, any `config/` directory
295
481
 
482
+ Read `forge.yaml → review.tech_stack` and `forge.yaml → review.key_paths` (if present) to identify which files are most relevant for the affected domain. If `review.key_paths` lists domain-to-file mappings, use that table directly. If the `review` section is absent, use the issue labels, title keywords, and the affected files listed in the issue body to determine the domain. Start with the files the issue explicitly names, then expand to callers and related modules.
483
+
484
+ **Workflow pipeline issues** (repo is a ForgeDock installation):
485
+ - Key files: `commands/work-on.md`, `commands/review-pr.md`, `commands/quality-gate.md`, `commands/orchestrate.md`, `forge.yaml`, `bin/forgedock.mjs`
486
+
487
+ **INFRA domain known footguns** (read before writing any `.github/workflows/*.yml` changes):
488
+ - **appleboy/ssh-action Go template preprocessing**: Any `{{` in a `script:` block is interpreted as a Go template directive **before the script reaches SSH**. This means `docker ps --format '{{.Names}}'` and `docker inspect --format '{{index .RepoTags 0}}'` will crash the action with exit 1. Both function calls (`{{index .X Y}}`) AND field accessors (`{{.Names}}`, `{{.Status}}`) fail on the action's empty data context. Shell error handlers (`|| fallback`, `set -e`, `2>/dev/null`) are bypassed because the failure is client-side. Always use `docker inspect IMAGE | jq -r '.[0].RepoTags[0]'` and `docker ps --format json | jq -r '.Names'` patterns in `appleboy/ssh-action` scripts. (Ref: forge#226 — 6-day silent deploy failure masked by `continue-on-error: true`)
489
+
296
490
  If the issue specifies a **Code branch** (`**Code branch**: \`{branch}\``), check out that branch — the affected files may not be on the default branch.
297
491
 
298
492
  ### Code Index Query (run BEFORE any grep exploration)
@@ -322,7 +516,22 @@ bash {REPO_PATH}/scripts/code-index.sh query --domain {DOMAIN_LABEL} --repo-path
322
516
 
323
517
  1. **Check the right branch** — read from the branch specified in the issue body (`**Code branch**: \`{branch}\``) if present
324
518
  2. **Read domain files** — start with the key files for the affected domain (use index query results from Step 0E as the file list; fall back to directory inspection if index is absent)
519
+ 2.5. **Existing system search (conditional, MUST)**: If the issue describes a gap in a functional capability — content not being distributed, notifications not sending, jobs not running, data not being synced — MUST search for an existing automated system before proposing a new one. The issue body may name a specific tool or path (e.g., `reddit-bot/`, `marketing/`) — do NOT anchor on that path alone. Expand the search to all service layers:
520
+ ```bash
521
+ # Check all service layers for the capability (adapt paths to your project structure)
522
+ grep -rn "{capability_keyword}" {REPO_PATH}/services/ --include="*.py" -l | head -20
523
+ # Look for scheduled jobs, automated runners, existing integrations
524
+ grep -rn "scheduler\|celery\|cron\|nightly\|periodic" {REPO_PATH}/services/ --include="*.py" -l | head -10
525
+ ```
526
+ If an existing system is found that already handles the capability: the fix MUST route through the existing system (fix its config, env var, or gate) — NOT create a new parallel tool. Document the existing system in the investigation report and make it the centerpiece of the recommendation. This check is especially critical when the issue references a standalone tool directory (`reddit-bot/`, `scripts/`, `tools/`) — those directories often duplicate functionality that a service already owns. (Ref: forge#279 — investigator anchored on `reddit-bot/` from issue body, never checked `services/herald/app/scheduler/`, built parallel PRAW integration alongside Herald's existing automated crosspost scheduler)
325
527
  3. **Verify claims** — does the code actually have the problem described?
528
+ 3.5. **Type Invariant Verification (MANDATORY)**: Before declaring that a field, key, or parameter has a specific type (e.g. "content is always a dict", "status is always an int"), search for ALL code paths that write to that field across ALL services:
529
+ ```bash
530
+ grep -rn '"field_name"\s*:' services/ # Python dict key assignments
531
+ grep -rn 'result\["field_name"\]\s*=' services/ # Direct assignments
532
+ grep -rn '\.field_name\s*=' services/ # Attribute assignments
533
+ ```
534
+ If the field is written with different types in different code paths (e.g. dict in the standard path, string in the auth-gated path), document ALL variants. The fix must handle every variant — not just the one on the primary investigated code path. A type guard like `or {}` only protects against falsy values; a non-empty string is truthy and bypasses it.
326
535
  4. **Git blame** — trace when/why the relevant code was written. Run bounded, local commands (no network round-trip):
327
536
  ```bash
328
537
  # Introducing commit for each affected file (first commit that added it)
@@ -333,9 +542,10 @@ bash {REPO_PATH}/scripts/code-index.sh query --domain {DOMAIN_LABEL} --repo-path
333
542
  git blame -L {start},{end} -- {affected_file}
334
543
  ```
335
544
  Record the introducing commit and last-touch commit for each primary affected file — this feeds the mandatory **History findings** field in Phase 1C.
545
+ 4.5. **Rogue commit pre-state comparison (conditional)**: If the issue body references a specific commit as rogue, bad, or unintended (e.g., "rogue commit `abc1234`", "bad commit", "this was never intended"), MUST run `git show {commit}^:{file}` to see the file before that commit. Compare the pre-commit state against the current file. Any block present in the current file but absent in the pre-commit state was introduced by that commit chain and is a candidate for full reversion — not just partial editing. Report the delta (pre vs. current) in the investigation report. Do NOT assume surrounding code near a named import/bug is correct simply because the issue only named a specific sub-problem. (Ref: forge#278 — investigator confirmed the broken import but never ran `git show 18a3a2cf3^:batch.py`; the surrounding 50-line feature gate was also rogue and was preserved by the fix PR, causing a P1 access regression for all non-Scale users)
336
546
  5. **Domain context discovery** (narrow scope only, 1–5 files):
337
547
  ```bash
338
- git log --oneline --all -30 -- {affected_files} | grep -oP '#\d+' | sort -u
548
+ git log --oneline --all -30 -- {affected_files} | grep -oE '#[0-9]+' | sort -u
339
549
  gh issue list -R {GH_REPO} --state closed --limit 8 --search "{function_name}"
340
550
  ```
341
551
  Keep only file/function-level overlap. Max 5 related issues. Everything is a hint to verify, not a fact.
@@ -349,6 +559,17 @@ bash {REPO_PATH}/scripts/code-index.sh query --domain {DOMAIN_LABEL} --repo-path
349
559
  Any hit here is a candidate prior fix or reintroduced defect — read the commit body (`git show {hash}`) to confirm before citing it. Feed confirmed hits into the History findings field and let them inform the verdict (e.g. a defect being reintroduced raises severity).
350
560
  6. **Determine root cause** — what's actually broken or missing?
351
561
  7. **Identify affected files** — full list of files that need changes
562
+ 7.5. **Sibling Pattern Sweep** *(conditional — when the bug is a condition, gated function call, or field presence check)*: After identifying the affected files, grep for the same pattern in sibling files within the same directory. The issue spec may name only the file where the error was first observed — but the same commit or PR that introduced the bug often applied it uniformly across related handlers.
563
+ ```bash
564
+ # Identify the broken condition or gated function call from the issue
565
+ # Then search sibling files in the same router/service directory
566
+ AFFECTED_DIR=$(dirname {PRIMARY_AFFECTED_FILE})
567
+ grep -rn "{broken_pattern}" "$AFFECTED_DIR" --include="*.py" | grep -v "{PRIMARY_AFFECTED_FILE}"
568
+ ```
569
+ **If identical patterns are found in files NOT listed in the issue spec**, output a scope-gap warning:
570
+ > **Scope-Gap Warning**: The issue spec lists `{PRIMARY_FILE}` but the same pattern exists in `{SIBLING_FILE}:{LINE}`. These were likely introduced together. Recommend widening scope to fix all callers in this PR, or creating follow-up issues for the other files before proceeding.
571
+
572
+ Do NOT silently exclude sibling matches. The appropriate output when sibling files have the same bug is to flag them explicitly — even if the issue spec's silence appears intentional. The fix-approach validation step (step 8) will confirm whether to widen scope or create follow-ups. <!-- Added: forge#383 -->
352
573
  8. **Fix-approach validation** — if the issue proposes a fix, don't adopt it as spec. Trace through the target system's middleware, auth, routing, config. Cross-domain: if fix in domain A interacts with domain B, read domain B's files too.
353
574
 
354
575
  ---
@@ -458,7 +679,7 @@ ACCEPTANCE_CHECK: id=ac-4 type=command target="grep -qE '(>= ?2|2\+)' commands/o
458
679
 
459
680
  **Check types**:
460
681
  - `exists` — assert a file or directory exists (`target` = path, `matcher` = ignored)
461
- - `contains` — assert a file contains a string or regex (`target` = file path, `matcher` = string/regex)
682
+ - `contains` — assert a file contains a string or regex (`target` = file path, `matcher` = string/regex). **Prefer a literal string** for code: the matcher is applied as an extended regex first, so `$`, `(`, `[`, `.`, `*`, `+`, `?`, `|` must be escaped if you mean them literally (e.g. `return \"\$HELD\"`). The acceptance gate falls back to a literal match, but a correct matcher avoids a needless repair round. For a `command` check that looks for code, use `grep -qF '<literal>' <file>`, not `grep -qE`.
462
683
  - `command` — run a shell command and assert exit 0 (`target` = shell command, `matcher` = `exit_0`)
463
684
  - `behavior` — assert a runtime/observable behavior via shell command (`target` = shell command, `matcher` = expected output string or regex)
464
685
 
@@ -489,12 +710,15 @@ own process. GitHub App installation identities are `Bot` users and cannot use t
489
710
 
490
711
  ```bash
491
712
  if [ -z "${FORGE_GIST_CAPABLE+x}" ]; then
713
+ # Fail closed: only a positively identified user account can use the Gists API. A GitHub App
714
+ # installation token cannot read /user at all (HTTP 403, empty type), so "not Bot" is NOT
715
+ # evidence of capability; an unavailable probe disables Gists rather than letting every
716
+ # worker fail on its first Gist write.
492
717
  GIST_AUTH_TYPE=$(gh api user --jq '.type' 2>/dev/null || true)
493
- if [ "$GIST_AUTH_TYPE" = "Bot" ]; then
494
- FORGE_GIST_CAPABLE=false
495
- else
496
- # Preserve the existing behavior when the identity probe is unavailable or is a PAT user.
718
+ if [ "$GIST_AUTH_TYPE" = "User" ]; then
497
719
  FORGE_GIST_CAPABLE=true
720
+ else
721
+ FORGE_GIST_CAPABLE=false
498
722
  fi
499
723
  export FORGE_GIST_CAPABLE
500
724
  fi
@@ -550,10 +774,10 @@ GIST_FILENAME="${REPO_SHORT}_${NUMBER}_${SLUG}.md"
550
774
  Extract verdict, task type, and confidence from the investigation body, then compose the Gist:
551
775
 
552
776
  ```bash
553
- VERDICT=$(echo "$INVESTIGATION_BODY" | grep -oP '(?<=\*\*Verdict\*\*: )\w+' | head -1)
554
- TASK_TYPE=$(echo "$INVESTIGATION_BODY" | grep -oP '(?<=\*\*Task Type\*\*: ).+' | head -1)
555
- CONFIDENCE=$(echo "$INVESTIGATION_BODY" | grep -oP '(?<=\*\*Confidence\*\*: )\w+' | head -1)
556
- SEVERITY=$(echo "$INVESTIGATION_BODY" | grep -oP '(?<=\*\*Severity\*\*: )\w+' | head -1)
777
+ VERDICT=$(echo "$INVESTIGATION_BODY" | grep -oE '\*\*Verdict\*\*: [A-Za-z_-]+' | head -1 | sed -E 's/^\*\*Verdict\*\*: //')
778
+ TASK_TYPE=$(echo "$INVESTIGATION_BODY" | grep -oE '\*\*Task Type\*\*: .+' | head -1 | sed -E 's/^\*\*Task Type\*\*: //')
779
+ CONFIDENCE=$(echo "$INVESTIGATION_BODY" | grep -oE '\*\*Confidence\*\*: [A-Za-z_-]+' | head -1 | sed -E 's/^\*\*Confidence\*\*: //')
780
+ SEVERITY=$(echo "$INVESTIGATION_BODY" | grep -oE '\*\*Severity\*\*: [A-Za-z_-]+' | head -1 | sed -E 's/^\*\*Severity\*\*: //')
557
781
  TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
558
782
 
559
783
  GIST_CONTENT=$(cat <<GIST_EOF
@@ -637,16 +861,16 @@ fi
637
861
 
638
862
  ```bash
639
863
  MILESTONE_DESC=$(gh api repos/{GH_REPO}/milestones/${MILESTONE_NUM} --jq '.description // ""')
640
- EXISTING_INDEX_URL=$(echo "$MILESTONE_DESC" | grep -oP '(?<=<!-- FORGE:MILESTONE_INDEX: )https://[^ ]+(?= -->)' | head -1)
864
+ EXISTING_INDEX_URL=$(echo "$MILESTONE_DESC" | grep -oE '<!-- FORGE:MILESTONE_INDEX: https://[^ ]+ -->' | head -1 | sed -E 's/^<!-- FORGE:MILESTONE_INDEX: //; s/ -->$//')
641
865
  ```
642
866
 
643
867
  ### Step 3: Build index entry for this issue
644
868
 
645
869
  ```bash
646
870
  ISSUE_TITLE=$(gh issue view {NUMBER} {GH_FLAG} --json title --jq '.title')
647
- VERDICT=$(echo "$INVESTIGATION_BODY" | grep -oP '(?<=\*\*Verdict\*\*: )\w+' | head -1)
648
- SEVERITY=$(echo "$INVESTIGATION_BODY" | grep -oP '(?<=\*\*Severity\*\*: )\w+' | head -1)
649
- TASK_TYPE=$(echo "$INVESTIGATION_BODY" | grep -oP '(?<=\*\*Task Type\*\*: ).+' | head -1)
871
+ VERDICT=$(echo "$INVESTIGATION_BODY" | grep -oE '\*\*Verdict\*\*: [A-Za-z_-]+' | head -1 | sed -E 's/^\*\*Verdict\*\*: //')
872
+ SEVERITY=$(echo "$INVESTIGATION_BODY" | grep -oE '\*\*Severity\*\*: [A-Za-z_-]+' | head -1 | sed -E 's/^\*\*Severity\*\*: //')
873
+ TASK_TYPE=$(echo "$INVESTIGATION_BODY" | grep -oE '\*\*Task Type\*\*: .+' | head -1 | sed -E 's/^\*\*Task Type\*\*: //')
650
874
  RECOMMENDATION=$(echo "$INVESTIGATION_BODY" | sed -n '/^### Recommendation/,/^### /p' | head -5 | tail -n +2 | tr '\n' ' ' | cut -c1-120)
651
875
 
652
876
  # GIST_URL comes from Phase 1C.5 (may be empty if Gist creation failed)
@@ -697,7 +921,7 @@ fi
697
921
 
698
922
  ```bash
699
923
  if [ -n "$EXISTING_INDEX_URL" ]; then
700
- INDEX_GIST_ID=$(echo "$EXISTING_INDEX_URL" | grep -oP '[a-f0-9]{20,}' | tail -1)
924
+ INDEX_GIST_ID=$(echo "$EXISTING_INDEX_URL" | grep -oE '[a-f0-9]{20,}' | tail -1)
701
925
 
702
926
  if [ -z "$INDEX_GIST_ID" ]; then
703
927
  echo "WARNING: Could not extract Gist ID from index URL — skipping update"
@@ -733,7 +957,7 @@ ${INDEX_ENTRY}"
733
957
 
734
958
  # Update the Gist
735
959
  # gh gist edit does not support stdin via '-'; use a temp file instead
736
- TMPFILE=$(mktemp --suffix=.md)
960
+ TMPFILE=$(mktemp "${TMPDIR:-/tmp}/forge-index.XXXXXX")
737
961
  echo "$UPDATED_INDEX" > "$TMPFILE"
738
962
  gh gist edit "$INDEX_GIST_ID" -f "$INDEX_FILENAME" "$TMPFILE" 2>/dev/null
739
963
  EDIT_EXIT=$?
@@ -822,58 +1046,150 @@ fi
822
1046
 
823
1047
  ---
824
1048
 
825
- **CONFIRMED or PARTIAL with decompose: NO**:
1049
+ ### 1D.1: Correction Capture (MANDATORY — run after 1D.0, BEFORE the workflow label update) <!-- Added: forge#667 -->
1050
+
1051
+ Before routing, scan all non-agent comments for correction signals from the repository owner. Correction signals are owner comments that contain phrases like "no, use", "actually use", "use X instead", "not X, use Y", or "wrong branch". If found, write the correction to `forge.yaml → learned:` and emit a `FORGE:LEARNED` annotation.
1052
+
1053
+ **Scan for correction signals**:
826
1054
  ```bash
827
- gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:ready-to-build" --remove-label "workflow:investigating"
1055
+ # Get repo owner login for filtering.
1056
+ # Tiered resolution — necessary because project.owner is the GitHub org/user NAME,
1057
+ # but comment .user.login is always a personal account login. For org-owned repos
1058
+ # these are structurally different (e.g. org="RapierCraftStudios", commenter="mrdubey"),
1059
+ # so using project.owner directly silently disables correction capture for all org repos.
1060
+ #
1061
+ # Resolution order:
1062
+ # 1. project.owner_login (explicit override — required for org repos where owner ≠ personal login)
1063
+ # 2. gh api repos/{GH_REPO} --jq '.owner.login' (auto-resolves correctly for personal repos)
1064
+ # 3. project.owner (backward-compat fallback — still broken for org repos, but avoids hard failure)
1065
+ REPO_OWNER=$(yq '.project.owner_login // ""' forge.yaml 2>/dev/null || echo '')
1066
+ if [ -z "$REPO_OWNER" ]; then
1067
+ REPO_OWNER=$(gh api repos/{GH_REPO} --jq '.owner.login' 2>/dev/null || echo '')
1068
+ fi
1069
+ if [ -z "$REPO_OWNER" ]; then
1070
+ REPO_OWNER=$(yq '.project.owner' forge.yaml 2>/dev/null || echo '')
1071
+ fi
1072
+
1073
+ # Fetch all comments, filter to owner-only, look for correction signals
1074
+ CORRECTIONS=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
1075
+ | jq -r --arg owner "$REPO_OWNER" \
1076
+ '.[] | select(.user.login == $owner) | select(
1077
+ (.body | test("no,? use|actually use|use .+ instead|not .+, use|wrong branch"; "i"))
1078
+ ) | .body' 2>/dev/null || echo '')
828
1079
  ```
829
1080
 
830
- Write machine-readable phase checkpoint (MUST execute immediately after label update, before returning):
1081
+ **If correction signals found** — extract and write each correction:
1082
+
831
1083
  ```bash
832
- CHECKPOINT_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
833
- gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:CHECKPOINT -->
834
- \`\`\`json
835
- {\"phase\": \"INVESTIGATION\", \"status\": \"COMPLETE\", \"next_phase\": \"BUILD\", \"timestamp\": \"${CHECKPOINT_TIMESTAMP}\"}
836
- \`\`\`"
1084
+ # Example: extract branch target correction "use develop not staging"
1085
+ # Adjust regex to the correction pattern detected
1086
+
1087
+ if [ -n "$CORRECTIONS" ]; then
1088
+ echo "Correction signals detected — writing to forge.yaml → learned:"
1089
+ echo "$CORRECTIONS"
1090
+
1091
+ # Write to forge.yaml using yq in-place merge (idempotent — yq merge overwrites existing keys)
1092
+ # Always use env variable injection to avoid YAML injection from comment content
1093
+ # Example for branch target correction:
1094
+ # BRANCH_VALUE="develop"
1095
+ # yq eval '.learned.branch_targets.staging = env(BRANCH_VALUE)' -i forge.yaml
1096
+
1097
+ # After writing, emit FORGE:LEARNED annotation
1098
+ LEARNED_KEYS="branch_targets.staging" # replace with actual extracted keys
1099
+ CAPTURED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1100
+
1101
+ # Update captured_at and captured_by metadata
1102
+ CAPTURED_AT_VAL="$CAPTURED_AT" yq eval '.learned.captured_at = env(CAPTURED_AT_VAL)' -i forge.yaml
1103
+ CAPTURED_BY_VAL="work-on/{NUMBER}" yq eval '.learned.captured_by = env(CAPTURED_BY_VAL)' -i forge.yaml
1104
+
1105
+ LEARNED_BODY="<!-- FORGE:LEARNED -->
1106
+ ## Learned Pattern Captured
1107
+
1108
+ **Source**: Owner correction in comment on issue #{NUMBER}
1109
+ **Captured at**: $CAPTURED_AT
1110
+ **Keys written**: \`$LEARNED_KEYS\`
1111
+
1112
+ The following project-specific pattern was detected from owner feedback and written to \`forge.yaml → learned:\`. Future sessions will use this override automatically.
1113
+
1114
+ \`\`\`yaml
1115
+ # Written to forge.yaml
1116
+ learned:
1117
+ # {key}: {value}
1118
+ \`\`\`
1119
+
1120
+ **Idempotency**: yq merge-write — re-running will not duplicate entries."
1121
+ gh issue comment {NUMBER} {GH_FLAG} --body "$LEARNED_BODY" # allowlist:check-command-side-effects
1122
+
1123
+ echo "FORGE:LEARNED annotation posted."
1124
+ fi
837
1125
  ```
838
1126
 
839
- Return verdict to caller (work-on routing loop proceeds to build).
1127
+ **Idempotency guarantee**: Use `yq eval '.learned.key = env(VAR)' -i forge.yaml` — yq overwrites existing keys rather than appending. Re-running the capture step on the same comment produces the same forge.yaml state.
1128
+
1129
+ ---
1130
+
1131
+ ### 1D.2: Update Labels & Return Verdict
1132
+
1133
+ Resolve the posted investigation comment URL once (used in the result block):
840
1134
 
841
- **CONFIRMED or PARTIAL with decompose: YES**:
842
1135
  ```bash
843
- gh issue edit {NUMBER} {GH_FLAG} --remove-label "workflow:investigating"
1136
+ COMMENT_URL=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate --jq '[.[] | select(.body | contains("<!-- FORGE:INVESTIGATOR -->")) | .html_url] | last' 2>/dev/null | tail -1)
844
1137
  ```
845
- Do NOT add `workflow:ready-to-build` — the routing loop will invoke `work-on:decompose` based on the `decompose: YES` return value.
846
1138
 
847
- Write machine-readable phase checkpoint (MUST execute immediately after label update, before returning):
1139
+ **CONFIRMED or PARTIAL with decompose: NO** — transition to `ready-to-build` (this phase owns that label):
848
1140
  ```bash
849
- CHECKPOINT_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
850
- gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:CHECKPOINT -->
851
- \`\`\`json
852
- {\"phase\": \"INVESTIGATION\", \"status\": \"COMPLETE\", \"next_phase\": \"DECOMPOSE\", \"timestamp\": \"${CHECKPOINT_TIMESTAMP}\"}
853
- \`\`\`"
1141
+ RESOLUTION=$(resolve_script 'transition-label'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
1142
+ case "$TIER" in
1143
+ adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} ready-to-build ;;
1144
+ prose)
1145
+ gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:ready-to-build" --remove-label "workflow:investigating,workflow:building,workflow:in-review,workflow:awaiting-merge,workflow:merged,workflow:invalid,workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects
1146
+ ;;
1147
+ esac
854
1148
  ```
855
1149
 
856
- Return verdict to caller (work-on routing loop proceeds to decompose).
1150
+ No `FORGE:CHECKPOINT` is written — the label already disambiguates the resume point (forge#1826). Print `INVESTIGATE_RESULT:` with `status: COMPLETE`, `decompose: NO` and STOP.
857
1151
 
858
- **INVALID**:
1152
+ **CONFIRMED or PARTIAL with decompose: YES** — this phase does NOT set `workflow:decomposed` (the decompose phase owns it) and does NOT add `workflow:ready-to-build`. Only remove the investigating label:
859
1153
  ```bash
860
- gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:invalid" --remove-label "workflow:investigating"
861
- gh issue close {NUMBER} {GH_FLAG} --comment "Closing as invalid: {reason from investigation}"
1154
+ gh issue edit {NUMBER} {GH_FLAG} --remove-label "workflow:investigating" 2>/dev/null || true # allowlist:check-command-side-effects
862
1155
  ```
863
- Return INVALID to caller (work-on stops). No checkpoint written — INVALID is terminal.
1156
+
1157
+ No `FORGE:CHECKPOINT` is written. Print `INVESTIGATE_RESULT:` with `status: COMPLETE`, `decompose: YES` and STOP.
1158
+
1159
+ **INVALID** — transition to `invalid` and close the issue (terminal):
1160
+ ```bash
1161
+ RESOLUTION=$(resolve_script 'transition-label'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
1162
+ case "$TIER" in
1163
+ adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} invalid ;;
1164
+ prose)
1165
+ gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:invalid" --remove-label "workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:awaiting-merge,workflow:merged,workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects
1166
+ ;;
1167
+ esac
1168
+ gh issue close {NUMBER} {GH_FLAG} --comment "Closing as invalid: {reason from investigation}" # allowlist:check-command-side-effects
1169
+ ```
1170
+
1171
+ No checkpoint written — INVALID is terminal. Print `INVESTIGATE_RESULT:` with `status: INVALID` and STOP.
864
1172
 
865
1173
  ---
866
1174
 
867
1175
  ## Output
868
1176
 
869
- The subcommand writes its results to GitHub (FORGE:INVESTIGATOR comment). Output this structured block — the routing loop in `work-on.md` will read this result, re-evaluate state, and continue to the next phase. This subcommand is complete; control returns to the router's loop iteration.
1177
+ The subcommand writes its results to GitHub (FORGE:INVESTIGATOR comment, labels). Its **final reply** must be exactly this one block — it is all the caller sees. Print it on **every** exit path (COMPLETE, ALREADY_DONE, INVALID, BLOCKED). This subcommand is complete after printing; do not continue to any later phase.
870
1178
 
871
1179
  ```
872
1180
  INVESTIGATE_RESULT:
873
- verdict: {CONFIRMED|PARTIAL|INVALID}
874
- confidence: {HIGH|MEDIUM|LOW}
875
- decompose: {YES|NO}
876
- comment_url: {url of posted comment}
1181
+ status: {COMPLETE|ALREADY_DONE|INVALID|BLOCKED}
1182
+ verdict: {CONFIRMED|PARTIAL|INVALID|null}
1183
+ confidence: {HIGH|MEDIUM|LOW|null}
1184
+ decompose: {YES|NO|null}
1185
+ comment_url: {url of posted (or pre-existing) comment, or null}
877
1186
  gist_url: {url of knowledge gist, or null if creation failed/skipped}
878
1187
  milestone_index_url: {url of milestone index gist, or null if no milestone/creation failed}
1188
+ blocker: {one-line reason when status is BLOCKED, else null}
879
1189
  ```
1190
+
1191
+ **Status mapping**:
1192
+ - `COMPLETE` — fresh investigation finished with verdict CONFIRMED or PARTIAL (`decompose` YES or NO).
1193
+ - `ALREADY_DONE` — resume check found a completed investigation with a non-INVALID verdict; the block carries the parsed existing verdict (Phase 1A).
1194
+ - `INVALID` — verdict INVALID (fresh run, or an existing `INVESTIGATION:INVALID` comment found on resume); issue closed with `workflow:invalid`.
1195
+ - `BLOCKED` — a required arg is missing, the issue cannot be read, or investigation cannot proceed; set `blocker` and leave the other fields `null` where unknown.