forgedock 1.8.1 → 1.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/bin/engine/admission.mjs +57 -1
  2. package/bin/engine/admission.test.mjs +60 -0
  3. package/commands/analytics.md +3 -1
  4. package/commands/audit-agents.md +3 -1
  5. package/commands/audit.md +10 -8
  6. package/commands/autopilot.md +16 -14
  7. package/commands/ci-audit.md +4 -2
  8. package/commands/cleanup.md +1 -1
  9. package/commands/deploy-pr.md +31 -11
  10. package/commands/design-bench.md +1 -0
  11. package/commands/design-render-critique-loop.md +1 -0
  12. package/commands/design.md +1 -0
  13. package/commands/forge-stats.md +1 -0
  14. package/commands/geo-audit.md +3 -1
  15. package/commands/incident-response.md +3 -1
  16. package/commands/issue.md +4 -2
  17. package/commands/milestone.md +8 -6
  18. package/commands/orchestrate/config.md +3 -2
  19. package/commands/orchestrate/phase-1-resolve.md +57 -12
  20. package/commands/orchestrate/phase-2-triage.md +1 -0
  21. package/commands/orchestrate/phase-2.5-synthesis.md +1 -0
  22. package/commands/orchestrate/phase-3-dependency.md +17 -5
  23. package/commands/orchestrate/phase-4-execution.md +625 -78
  24. package/commands/orchestrate/phase-5-cleanup.md +5 -2
  25. package/commands/orchestrate/phase-6-report.md +8 -3
  26. package/commands/orchestrate/safety.md +1 -0
  27. package/commands/orchestrate.md +13 -9
  28. package/commands/pipeline-health/config.md +1 -0
  29. package/commands/pipeline-health/phase-1-context.md +1 -0
  30. package/commands/pipeline-health/phase-2-metrics.md +1 -0
  31. package/commands/pipeline-health/phase-3-analyze.md +1 -0
  32. package/commands/pipeline-health/phase-4-proposals.md +7 -4
  33. package/commands/pipeline-health/phase-5-report.md +5 -2
  34. package/commands/pipeline-health/phase-6-summary.md +1 -0
  35. package/commands/pipeline-health.md +9 -7
  36. package/commands/pipeline-resume.md +4 -2
  37. package/commands/qa-sweep.md +3 -1
  38. package/commands/quality-gate.md +4 -3
  39. package/commands/recover-orphans.md +219 -13
  40. package/commands/review-pr-agents/api.md +1 -0
  41. package/commands/review-pr-agents/auth.md +1 -0
  42. package/commands/review-pr-agents/billing.md +1 -0
  43. package/commands/review-pr-agents/concurrency.md +1 -0
  44. package/commands/review-pr-agents/database.md +1 -0
  45. package/commands/review-pr-agents/frontend.md +1 -0
  46. package/commands/review-pr-agents/infra.md +1 -0
  47. package/commands/review-pr-agents/protocols.md +1 -0
  48. package/commands/review-pr-agents/scraper.md +1 -0
  49. package/commands/review-pr-agents/security.md +1 -0
  50. package/commands/review-pr-agents/spec-cli.md +1 -0
  51. package/commands/review-pr-agents.md +10 -4
  52. package/commands/review-pr-staging.md +14 -5
  53. package/commands/review-pr.md +187 -19
  54. package/commands/rollback.md +3 -1
  55. package/commands/security-audit.md +3 -1
  56. package/commands/signal-planner.md +6 -4
  57. package/commands/sync-ecosystem.md +4 -2
  58. package/commands/test-gate.md +4 -2
  59. package/commands/upgrade-deps.md +3 -1
  60. package/commands/validate.md +3 -1
  61. package/commands/work-on/build/architect.md +82 -19
  62. package/commands/work-on/build/context.md +62 -18
  63. package/commands/work-on/build/implement.md +63 -18
  64. package/commands/work-on/build/validate.md +241 -60
  65. package/commands/work-on/build.md +345 -146
  66. package/commands/work-on/close.md +618 -246
  67. package/commands/work-on/decompose.md +240 -41
  68. package/commands/work-on/investigate.md +378 -61
  69. package/commands/work-on/remediate.md +43 -9
  70. package/commands/work-on/review.md +241 -29
  71. package/commands/work-on.md +180 -2014
  72. package/docs/CODEX.md +2 -0
  73. package/docs/CONFIG.md +11 -0
  74. package/package.json +1 -1
  75. package/scripts/build-spec-graph.mjs +11 -4
  76. package/scripts/check-spec-markers.sh +21 -1
  77. package/scripts/checkbox-sections.test.sh +8 -23
  78. package/scripts/extract-affected-files.sh +5 -2
  79. package/scripts/extract-affected-files.test.sh +27 -0
  80. package/scripts/forge-root.test.sh +176 -0
  81. package/scripts/is-docs-only.sh +31 -0
  82. package/scripts/is-docs-only.test.sh +64 -0
  83. package/scripts/lint-dispatch-prompt.sh +217 -0
  84. package/scripts/lint-dispatch-prompt.test.sh +307 -0
  85. package/scripts/validate-spec-graph.sh +3 -2
  86. package/scripts/verify-phase-trail.sh +282 -0
  87. package/scripts/verify-phase-trail.test.sh +298 -0
  88. package/scripts/wait-ci-green.sh +98 -0
  89. package/scripts/wait-ci-green.test.sh +99 -0
  90. package/commands/work-on-monolithic.md +0 -326
@@ -1,6 +1,8 @@
1
1
  ---
2
+ user-invocable: false
2
3
  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}\"]"
4
+ argument-hint: "{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\""
5
+ context: fork
4
6
  ---
5
7
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
6
8
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
@@ -11,7 +13,9 @@ argument-hint: "[issue number] [--repo {owner}/{repo}] [--gh-flag \"-R {owner}/{
11
13
 
12
14
  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
15
 
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 -->
16
+ 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).
17
+
18
+ **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
19
  **NEVER use plan mode (EnterPlanMode).**
16
20
 
17
21
  <!-- FORGE:SPEC_LOADED — work-on/investigate.md loaded and active. Agent is bound by this spec. -->
@@ -20,15 +24,99 @@ Standalone investigation phase for the work-on pipeline. Validates whether an is
20
24
 
21
25
  ## Inputs
22
26
 
23
- Parse from $ARGUMENTS:
27
+ Parse from $ARGUMENTS (`{NUMBER} --repo {GH_REPO} --gh-flag "{GH_FLAG}"`):
24
28
  - `{NUMBER}` — issue number (required)
25
- - `--repo {GH_REPO}` — GitHub repo (e.g. `{owner}/{repo}` — resolved from `forge.yaml → project`)
29
+ - `--repo {GH_REPO}` — GitHub repo (e.g. `{owner}/{repo}`)
26
30
  - `--gh-flag {GH_FLAG}` — gh CLI repo flag (e.g. `-R {owner}/{repo}`)
27
31
 
28
- If called from `work-on`, these are passed through. If invoked standalone, `--repo` and `--gh-flag` are resolved from `forge.yaml → project`.
32
+ The router passes all three. If `--repo` / `--gh-flag` are absent (standalone use), resolve them from `forge.yaml → project`.
33
+
34
+ **Fail closed**: if `{NUMBER}` is missing/non-numeric, or `{GH_REPO}` cannot be determined, print the result block below and stop — do not guess:
35
+
36
+ ```
37
+ INVESTIGATE_RESULT:
38
+ status: BLOCKED
39
+ verdict: null
40
+ confidence: null
41
+ decompose: null
42
+ comment_url: null
43
+ gist_url: null
44
+ milestone_index_url: null
45
+ blocker: missing required arg: <which one>
46
+ ```
47
+
48
+ **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
49
 
30
50
  ---
31
51
 
52
+ ## Script resolution
53
+
54
+ **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`.
55
+
56
+ ```bash
57
+ # Canonical script resolution (keep byte-identical across specs; guarded by scripts/forge-root.test.sh).
58
+ # Shell state does NOT persist between Bash tool calls: include this whole block at the top of
59
+ # every command that uses resolve_script or FORGE_ROOT.
60
+ REPO_PATH="${REPO_PATH:-$(yq '.paths.root // ""' forge.yaml 2>/dev/null)}"
61
+ [ -n "$REPO_PATH" ] && [ "$REPO_PATH" != "null" ] || REPO_PATH="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
62
+ ADAPTIVE_DIR_RAW="${REPO_PATH}/$(yq '.adaptive_scripts.directory // ".forgedock/scripts"' forge.yaml 2>/dev/null || echo '.forgedock/scripts')"
63
+ ADAPTIVE_DIR=$(realpath -m "$ADAPTIVE_DIR_RAW" 2>/dev/null || echo "$ADAPTIVE_DIR_RAW")
64
+ ADAPTIVE_ENABLED=$(yq '.adaptive_scripts.enabled // "true"' forge.yaml 2>/dev/null || echo 'true')
65
+ # Bounds check: reject adaptive_scripts.directory values that escape the repo root.
66
+ # Normalize REPO_PATH the same way ADAPTIVE_DIR is normalized (realpath -m) so a trailing
67
+ # slash in paths.root does not inject a '//' into the glob and trigger a false positive.
68
+ REPO_PATH_NORM=$(realpath -m "$REPO_PATH" 2>/dev/null || echo "$REPO_PATH")
69
+ if [[ "$ADAPTIVE_DIR" != "${REPO_PATH_NORM}/"* ]]; then
70
+ echo "WARNING: adaptive_scripts.directory resolves outside repo root ('$ADAPTIVE_DIR') — adaptive tier disabled" >&2
71
+ ADAPTIVE_ENABLED=false
72
+ fi
73
+ # ForgeDock's own install root (holds scripts/ and commands/), resolved ONCE by the canonical bootstrap
74
+ # below — never the consumer repo: plugin installs set neither FORGE_HOME nor FORGEDOCK_HOME, and a
75
+ # repo-relative fallback would execute (or miss) a same-named script controlled by the consumer repo.
76
+ # Resolution: $FORGEDOCK_HOME (authoritative when set) > $FORGE_HOME > $CLAUDE_PLUGIN_ROOT > the
77
+ # ~/.claude/commands symlink target > the Claude Code plugin cache/marketplace dirs. <!-- forge#3098 -->
78
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
79
+ FORGE_ROOT=""
80
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
81
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
82
+ _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")")"
83
+ # 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)
84
+ _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-)"
85
+ _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
86
+ # '${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.
87
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
88
+ while IFS= read -r _c; do
89
+ 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
90
+ done <<< "$_k"
91
+ fi
92
+ UNIVERSAL_DIR="${FORGE_ROOT:+$FORGE_ROOT/scripts}" # empty => tier 3 skipped, prose tier
93
+ # NOTE: never resolve this via `which` or `find` — universal scripts are
94
+ # install-relative, not installed on $PATH, so a PATH lookup always misses.
95
+ # FORGE_ROOT (above) is the deterministic resolution; there is NO repo-path fallback.
96
+ # Pipeline agents MUST NOT use `find` (unbounded or filesystem-wide) to
97
+ # locate pipeline scripts under any circumstances: if UNIVERSAL_DIR/${operation}.sh
98
+ # does not exist, resolve_script() falls through to Tier 4 (prose) below,
99
+ # which is always safe and available. A missing script is never a reason
100
+ # to search the filesystem. <!-- Added: forge#1984 -->
101
+
102
+ resolve_script() {
103
+ local operation="$1"
104
+ # Tier 2: per-repo adaptive (skip if disabled)
105
+ if [ "$ADAPTIVE_ENABLED" != "false" ] && [ -f "${ADAPTIVE_DIR}/${operation}.sh" ]; then
106
+ echo "adaptive:${ADAPTIVE_DIR}/${operation}.sh"
107
+ return
108
+ fi
109
+ # Tier 3: universal script
110
+ if [ -n "$UNIVERSAL_DIR" ] && [ -f "${UNIVERSAL_DIR}/${operation}.sh" ]; then
111
+ echo "universal:${UNIVERSAL_DIR}/${operation}.sh"
112
+ return
113
+ fi
114
+ # Tier 4: prose fallback
115
+ echo "prose:"
116
+ }
117
+ ```
118
+
119
+
32
120
  ## Phase 0.5: Memory Retrieval — Prior Run Priors <!-- Added: forge#1316 -->
33
121
 
34
122
  **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 +345,124 @@ Store `RECALL_ISSUE_CITATIONS` in a variable — it is used in Phase 1C to popul
257
345
 
258
346
  ## Phase 1A: Load Issue & Check Resume State
259
347
 
348
+ Re-read all state from GitHub (this fork has no inherited context):
349
+
350
+ ```bash
351
+ gh issue view {NUMBER} {GH_FLAG} --json number,title,body,labels,state
352
+ ```
353
+
354
+ **Resume check** — run this FIRST (before Phase 0.5):
355
+
260
356
  ```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}'
357
+ 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)
358
+ [ -z "$COMMENTS_JSON" ] && COMMENTS_JSON='[]'
359
+
360
+ # Complete = INVESTIGATOR header AND (COMPLETE or INVALID sentinel) in the SAME comment. Use the last such comment.
361
+ DONE_JSON=$(echo "$COMMENTS_JSON" | jq -c '[.[] | select(.body | contains("<!-- FORGE:INVESTIGATOR -->"))
362
+ | select((.body | contains("<!-- INVESTIGATION:COMPLETE -->")) or (.body | contains("<!-- INVESTIGATION:INVALID -->")))] | last // empty')
363
+
364
+ # Partial = INVESTIGATOR header but NEITHER sentinel (interrupted run)
365
+ PARTIAL_IDS=$(echo "$COMMENTS_JSON" | jq -r '.[] | select(.body | contains("<!-- FORGE:INVESTIGATOR -->"))
366
+ | select((.body | contains("<!-- INVESTIGATION:COMPLETE -->") | not) and (.body | contains("<!-- INVESTIGATION:INVALID -->") | not)) | .id')
367
+
368
+ if [ -n "$DONE_JSON" ]; then
369
+ DONE_BODY=$(echo "$DONE_JSON" | jq -r '.body')
370
+ DONE_URL=$(echo "$DONE_JSON" | jq -r '.url')
371
+ R_VERDICT=$(printf '%s\n' "$DONE_BODY" | grep -oE '\*\*Verdict\*\*: *[A-Za-z_-]+' | head -1 | sed -E 's/.*: *//')
372
+ R_CONF=$(printf '%s\n' "$DONE_BODY" | grep -oE '\*\*Confidence\*\*: *[A-Za-z]+' | head -1 | sed -E 's/.*: *//')
373
+ if printf '%s\n' "$DONE_BODY" | sed -n '/^### Decomposition Assessment/,/^###[^#]/p' | grep -qE '^\*\*YES\*\*'; then R_DECOMP=YES; else R_DECOMP=NO; fi
374
+ [ "$R_VERDICT" = "INVALID" ] && R_STATUS=INVALID || R_STATUS=ALREADY_DONE
375
+ R_GIST=$(echo "$COMMENTS_JSON" | jq -r '[.[] | .body | select(contains("FORGE:KNOWLEDGE_GIST:"))] | first // ""' | grep -oE 'https://gist[^ ]+' | head -1)
376
+ R_MS_NUM=$(gh issue view {NUMBER} {GH_FLAG} --json milestone --jq '.milestone.number // empty' 2>/dev/null)
377
+ R_MS_IDX=""
378
+ [ -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/ -->$//')
379
+ echo "INVESTIGATE_RESULT:"
380
+ echo " status: $R_STATUS"
381
+ echo " verdict: ${R_VERDICT:-null}"
382
+ echo " confidence: ${R_CONF:-null}"
383
+ echo " decompose: $R_DECOMP"
384
+ echo " comment_url: $DONE_URL"
385
+ echo " gist_url: ${R_GIST:-null}"
386
+ echo " milestone_index_url: ${R_MS_IDX:-null}"
387
+ echo " blocker: null"
388
+ echo "RESUME: investigation already complete — copy the block above verbatim as your final reply and STOP."
389
+ else
390
+ for CID in $PARTIAL_IDS; do
391
+ gh api "repos/{GH_REPO}/issues/comments/${CID}" -X DELETE 2>/dev/null || true # interrupted investigation: delete partial comment and restart
392
+ done
393
+ echo "RESUME: no completed investigation — proceeding with a fresh run."
394
+ fi
263
395
  ```
264
396
 
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
397
+ **Resume logic** (what the block implements):
398
+ - `<!-- 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`.
399
+ - `<!-- FORGE:INVESTIGATOR -->` comment but NEITHER sentinel → investigation was interrupted; the partial comment(s) are deleted and the run restarts.
400
+ - No investigator comment → fresh investigation.
401
+
402
+ **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:
403
+
404
+ ```bash
405
+ RESOLUTION=$(resolve_script 'transition-label'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
406
+ case "$TIER" in
407
+ adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} investigating ;;
408
+ prose)
409
+ 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
410
+ ;;
411
+ esac
412
+ ```
413
+
414
+ ---
415
+
416
+ ### 1A.5: Normalize Issue Body (MANDATORY)
417
+
418
+ 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.
419
+
420
+ **Skip if**: All four sections (`## Problem`, `## Affected Files`, `## Expected Behavior`, `## Acceptance Criteria`) are already present.
272
421
 
273
- **Set label**:
274
422
  ```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
423
+ ISSUE_BODY=$(gh issue view {NUMBER} {GH_FLAG} --json body --jq '.body')
424
+
425
+ MISSING_SECTIONS=""
426
+ echo "$ISSUE_BODY" | grep -q "^## Problem" || MISSING_SECTIONS="$MISSING_SECTIONS PROBLEM"
427
+ echo "$ISSUE_BODY" | grep -q "^## Affected Files" || MISSING_SECTIONS="$MISSING_SECTIONS AFFECTED_FILES"
428
+ echo "$ISSUE_BODY" | grep -q "^## Expected Behavior" || MISSING_SECTIONS="$MISSING_SECTIONS EXPECTED_BEHAVIOR"
429
+ echo "$ISSUE_BODY" | grep -q "^## Acceptance Criteria" || MISSING_SECTIONS="$MISSING_SECTIONS ACCEPTANCE_CRITERIA"
430
+
431
+ if [ -n "$MISSING_SECTIONS" ]; then
432
+ echo "Missing sections:$MISSING_SECTIONS — normalizing issue body before investigation"
433
+
434
+ APPEND_TEXT=""
435
+ echo "$MISSING_SECTIONS" | grep -q "PROBLEM" && APPEND_TEXT="$APPEND_TEXT
436
+ ## Problem
437
+
438
+ Root cause unknown — investigation needed."
439
+
440
+ echo "$MISSING_SECTIONS" | grep -q "AFFECTED_FILES" && APPEND_TEXT="$APPEND_TEXT
441
+ ## Affected Files
442
+
443
+ Files to be identified during investigation."
444
+
445
+ echo "$MISSING_SECTIONS" | grep -q "EXPECTED_BEHAVIOR" && APPEND_TEXT="$APPEND_TEXT
446
+ ## Expected Behavior
447
+
448
+ Expected behavior to be determined during investigation."
449
+
450
+ echo "$MISSING_SECTIONS" | grep -q "ACCEPTANCE_CRITERIA" && APPEND_TEXT="$APPEND_TEXT
451
+ ## Acceptance Criteria
452
+
453
+ - [ ] Fix confirmed during investigation."
454
+
455
+ # Append missing sections to the existing body (never replace — only extend)
456
+ NORMALIZED_BODY="${ISSUE_BODY}${APPEND_TEXT}"
457
+ gh issue edit {NUMBER} {GH_FLAG} --body "$NORMALIZED_BODY" # allowlist:check-command-side-effects
458
+ echo "Issue body normalized — added:$MISSING_SECTIONS"
459
+ else
460
+ echo "Issue body already contains all mandatory sections — skipping normalization"
461
+ fi
277
462
  ```
278
463
 
464
+ **Continue to Phase 1B unconditionally.** Normalization is a compensation step — it never blocks investigation.
465
+
279
466
  ---
280
467
 
281
468
  ## Phase 1B: Investigate
@@ -293,6 +480,14 @@ The target repo is `{GH_REPO}` (resolved from `forge.yaml → project`). The wor
293
480
  - Infrastructure: `.github/workflows/`, `docker-compose*.yml`, `infra/`
294
481
  - Config: `forge.yaml`, `.env.example`, any `config/` directory
295
482
 
483
+ 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.
484
+
485
+ **Workflow pipeline issues** (repo is a ForgeDock installation):
486
+ - Key files: `commands/work-on.md`, `commands/review-pr.md`, `commands/quality-gate.md`, `commands/orchestrate.md`, `forge.yaml`, `bin/forgedock.mjs`
487
+
488
+ **INFRA domain known footguns** (read before writing any `.github/workflows/*.yml` changes):
489
+ - **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`)
490
+
296
491
  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
492
 
298
493
  ### Code Index Query (run BEFORE any grep exploration)
@@ -322,7 +517,22 @@ bash {REPO_PATH}/scripts/code-index.sh query --domain {DOMAIN_LABEL} --repo-path
322
517
 
323
518
  1. **Check the right branch** — read from the branch specified in the issue body (`**Code branch**: \`{branch}\``) if present
324
519
  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)
520
+ 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:
521
+ ```bash
522
+ # Check all service layers for the capability (adapt paths to your project structure)
523
+ grep -rn "{capability_keyword}" {REPO_PATH}/services/ --include="*.py" -l | head -20
524
+ # Look for scheduled jobs, automated runners, existing integrations
525
+ grep -rn "scheduler\|celery\|cron\|nightly\|periodic" {REPO_PATH}/services/ --include="*.py" -l | head -10
526
+ ```
527
+ 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
528
  3. **Verify claims** — does the code actually have the problem described?
529
+ 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:
530
+ ```bash
531
+ grep -rn '"field_name"\s*:' services/ # Python dict key assignments
532
+ grep -rn 'result\["field_name"\]\s*=' services/ # Direct assignments
533
+ grep -rn '\.field_name\s*=' services/ # Attribute assignments
534
+ ```
535
+ 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
536
  4. **Git blame** — trace when/why the relevant code was written. Run bounded, local commands (no network round-trip):
327
537
  ```bash
328
538
  # Introducing commit for each affected file (first commit that added it)
@@ -333,9 +543,10 @@ bash {REPO_PATH}/scripts/code-index.sh query --domain {DOMAIN_LABEL} --repo-path
333
543
  git blame -L {start},{end} -- {affected_file}
334
544
  ```
335
545
  Record the introducing commit and last-touch commit for each primary affected file — this feeds the mandatory **History findings** field in Phase 1C.
546
+ 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
547
  5. **Domain context discovery** (narrow scope only, 1–5 files):
337
548
  ```bash
338
- git log --oneline --all -30 -- {affected_files} | grep -oP '#\d+' | sort -u
549
+ git log --oneline --all -30 -- {affected_files} | grep -oE '#[0-9]+' | sort -u
339
550
  gh issue list -R {GH_REPO} --state closed --limit 8 --search "{function_name}"
340
551
  ```
341
552
  Keep only file/function-level overlap. Max 5 related issues. Everything is a hint to verify, not a fact.
@@ -349,6 +560,17 @@ bash {REPO_PATH}/scripts/code-index.sh query --domain {DOMAIN_LABEL} --repo-path
349
560
  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
561
  6. **Determine root cause** — what's actually broken or missing?
351
562
  7. **Identify affected files** — full list of files that need changes
563
+ 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.
564
+ ```bash
565
+ # Identify the broken condition or gated function call from the issue
566
+ # Then search sibling files in the same router/service directory
567
+ AFFECTED_DIR=$(dirname {PRIMARY_AFFECTED_FILE})
568
+ grep -rn "{broken_pattern}" "$AFFECTED_DIR" --include="*.py" | grep -v "{PRIMARY_AFFECTED_FILE}"
569
+ ```
570
+ **If identical patterns are found in files NOT listed in the issue spec**, output a scope-gap warning:
571
+ > **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.
572
+
573
+ 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
574
  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
575
 
354
576
  ---
@@ -458,7 +680,7 @@ ACCEPTANCE_CHECK: id=ac-4 type=command target="grep -qE '(>= ?2|2\+)' commands/o
458
680
 
459
681
  **Check types**:
460
682
  - `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)
683
+ - `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
684
  - `command` — run a shell command and assert exit 0 (`target` = shell command, `matcher` = `exit_0`)
463
685
  - `behavior` — assert a runtime/observable behavior via shell command (`target` = shell command, `matcher` = expected output string or regex)
464
686
 
@@ -489,12 +711,15 @@ own process. GitHub App installation identities are `Bot` users and cannot use t
489
711
 
490
712
  ```bash
491
713
  if [ -z "${FORGE_GIST_CAPABLE+x}" ]; then
714
+ # Fail closed: only a positively identified user account can use the Gists API. A GitHub App
715
+ # installation token cannot read /user at all (HTTP 403, empty type), so "not Bot" is NOT
716
+ # evidence of capability; an unavailable probe disables Gists rather than letting every
717
+ # worker fail on its first Gist write.
492
718
  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.
719
+ if [ "$GIST_AUTH_TYPE" = "User" ]; then
497
720
  FORGE_GIST_CAPABLE=true
721
+ else
722
+ FORGE_GIST_CAPABLE=false
498
723
  fi
499
724
  export FORGE_GIST_CAPABLE
500
725
  fi
@@ -550,10 +775,10 @@ GIST_FILENAME="${REPO_SHORT}_${NUMBER}_${SLUG}.md"
550
775
  Extract verdict, task type, and confidence from the investigation body, then compose the Gist:
551
776
 
552
777
  ```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)
778
+ VERDICT=$(echo "$INVESTIGATION_BODY" | grep -oE '\*\*Verdict\*\*: [A-Za-z_-]+' | head -1 | sed -E 's/^\*\*Verdict\*\*: //')
779
+ TASK_TYPE=$(echo "$INVESTIGATION_BODY" | grep -oE '\*\*Task Type\*\*: .+' | head -1 | sed -E 's/^\*\*Task Type\*\*: //')
780
+ CONFIDENCE=$(echo "$INVESTIGATION_BODY" | grep -oE '\*\*Confidence\*\*: [A-Za-z_-]+' | head -1 | sed -E 's/^\*\*Confidence\*\*: //')
781
+ SEVERITY=$(echo "$INVESTIGATION_BODY" | grep -oE '\*\*Severity\*\*: [A-Za-z_-]+' | head -1 | sed -E 's/^\*\*Severity\*\*: //')
557
782
  TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
558
783
 
559
784
  GIST_CONTENT=$(cat <<GIST_EOF
@@ -637,16 +862,16 @@ fi
637
862
 
638
863
  ```bash
639
864
  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)
865
+ EXISTING_INDEX_URL=$(echo "$MILESTONE_DESC" | grep -oE '<!-- FORGE:MILESTONE_INDEX: https://[^ ]+ -->' | head -1 | sed -E 's/^<!-- FORGE:MILESTONE_INDEX: //; s/ -->$//')
641
866
  ```
642
867
 
643
868
  ### Step 3: Build index entry for this issue
644
869
 
645
870
  ```bash
646
871
  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)
872
+ VERDICT=$(echo "$INVESTIGATION_BODY" | grep -oE '\*\*Verdict\*\*: [A-Za-z_-]+' | head -1 | sed -E 's/^\*\*Verdict\*\*: //')
873
+ SEVERITY=$(echo "$INVESTIGATION_BODY" | grep -oE '\*\*Severity\*\*: [A-Za-z_-]+' | head -1 | sed -E 's/^\*\*Severity\*\*: //')
874
+ TASK_TYPE=$(echo "$INVESTIGATION_BODY" | grep -oE '\*\*Task Type\*\*: .+' | head -1 | sed -E 's/^\*\*Task Type\*\*: //')
650
875
  RECOMMENDATION=$(echo "$INVESTIGATION_BODY" | sed -n '/^### Recommendation/,/^### /p' | head -5 | tail -n +2 | tr '\n' ' ' | cut -c1-120)
651
876
 
652
877
  # GIST_URL comes from Phase 1C.5 (may be empty if Gist creation failed)
@@ -697,7 +922,7 @@ fi
697
922
 
698
923
  ```bash
699
924
  if [ -n "$EXISTING_INDEX_URL" ]; then
700
- INDEX_GIST_ID=$(echo "$EXISTING_INDEX_URL" | grep -oP '[a-f0-9]{20,}' | tail -1)
925
+ INDEX_GIST_ID=$(echo "$EXISTING_INDEX_URL" | grep -oE '[a-f0-9]{20,}' | tail -1)
701
926
 
702
927
  if [ -z "$INDEX_GIST_ID" ]; then
703
928
  echo "WARNING: Could not extract Gist ID from index URL — skipping update"
@@ -733,7 +958,7 @@ ${INDEX_ENTRY}"
733
958
 
734
959
  # Update the Gist
735
960
  # gh gist edit does not support stdin via '-'; use a temp file instead
736
- TMPFILE=$(mktemp --suffix=.md)
961
+ TMPFILE=$(mktemp "${TMPDIR:-/tmp}/forge-index.XXXXXX")
737
962
  echo "$UPDATED_INDEX" > "$TMPFILE"
738
963
  gh gist edit "$INDEX_GIST_ID" -f "$INDEX_FILENAME" "$TMPFILE" 2>/dev/null
739
964
  EDIT_EXIT=$?
@@ -822,58 +1047,150 @@ fi
822
1047
 
823
1048
  ---
824
1049
 
825
- **CONFIRMED or PARTIAL with decompose: NO**:
1050
+ ### 1D.1: Correction Capture (MANDATORY — run after 1D.0, BEFORE the workflow label update) <!-- Added: forge#667 -->
1051
+
1052
+ 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.
1053
+
1054
+ **Scan for correction signals**:
826
1055
  ```bash
827
- gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:ready-to-build" --remove-label "workflow:investigating"
1056
+ # Get repo owner login for filtering.
1057
+ # Tiered resolution — necessary because project.owner is the GitHub org/user NAME,
1058
+ # but comment .user.login is always a personal account login. For org-owned repos
1059
+ # these are structurally different (e.g. org="RapierCraftStudios", commenter="mrdubey"),
1060
+ # so using project.owner directly silently disables correction capture for all org repos.
1061
+ #
1062
+ # Resolution order:
1063
+ # 1. project.owner_login (explicit override — required for org repos where owner ≠ personal login)
1064
+ # 2. gh api repos/{GH_REPO} --jq '.owner.login' (auto-resolves correctly for personal repos)
1065
+ # 3. project.owner (backward-compat fallback — still broken for org repos, but avoids hard failure)
1066
+ REPO_OWNER=$(yq '.project.owner_login // ""' forge.yaml 2>/dev/null || echo '')
1067
+ if [ -z "$REPO_OWNER" ]; then
1068
+ REPO_OWNER=$(gh api repos/{GH_REPO} --jq '.owner.login' 2>/dev/null || echo '')
1069
+ fi
1070
+ if [ -z "$REPO_OWNER" ]; then
1071
+ REPO_OWNER=$(yq '.project.owner' forge.yaml 2>/dev/null || echo '')
1072
+ fi
1073
+
1074
+ # Fetch all comments, filter to owner-only, look for correction signals
1075
+ CORRECTIONS=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
1076
+ | jq -r --arg owner "$REPO_OWNER" \
1077
+ '.[] | select(.user.login == $owner) | select(
1078
+ (.body | test("no,? use|actually use|use .+ instead|not .+, use|wrong branch"; "i"))
1079
+ ) | .body' 2>/dev/null || echo '')
828
1080
  ```
829
1081
 
830
- Write machine-readable phase checkpoint (MUST execute immediately after label update, before returning):
1082
+ **If correction signals found** — extract and write each correction:
1083
+
831
1084
  ```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
- \`\`\`"
1085
+ # Example: extract branch target correction "use develop not staging"
1086
+ # Adjust regex to the correction pattern detected
1087
+
1088
+ if [ -n "$CORRECTIONS" ]; then
1089
+ echo "Correction signals detected — writing to forge.yaml → learned:"
1090
+ echo "$CORRECTIONS"
1091
+
1092
+ # Write to forge.yaml using yq in-place merge (idempotent — yq merge overwrites existing keys)
1093
+ # Always use env variable injection to avoid YAML injection from comment content
1094
+ # Example for branch target correction:
1095
+ # BRANCH_VALUE="develop"
1096
+ # yq eval '.learned.branch_targets.staging = env(BRANCH_VALUE)' -i forge.yaml
1097
+
1098
+ # After writing, emit FORGE:LEARNED annotation
1099
+ LEARNED_KEYS="branch_targets.staging" # replace with actual extracted keys
1100
+ CAPTURED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1101
+
1102
+ # Update captured_at and captured_by metadata
1103
+ CAPTURED_AT_VAL="$CAPTURED_AT" yq eval '.learned.captured_at = env(CAPTURED_AT_VAL)' -i forge.yaml
1104
+ CAPTURED_BY_VAL="work-on/{NUMBER}" yq eval '.learned.captured_by = env(CAPTURED_BY_VAL)' -i forge.yaml
1105
+
1106
+ LEARNED_BODY="<!-- FORGE:LEARNED -->
1107
+ ## Learned Pattern Captured
1108
+
1109
+ **Source**: Owner correction in comment on issue #{NUMBER}
1110
+ **Captured at**: $CAPTURED_AT
1111
+ **Keys written**: \`$LEARNED_KEYS\`
1112
+
1113
+ The following project-specific pattern was detected from owner feedback and written to \`forge.yaml → learned:\`. Future sessions will use this override automatically.
1114
+
1115
+ \`\`\`yaml
1116
+ # Written to forge.yaml
1117
+ learned:
1118
+ # {key}: {value}
1119
+ \`\`\`
1120
+
1121
+ **Idempotency**: yq merge-write — re-running will not duplicate entries."
1122
+ gh issue comment {NUMBER} {GH_FLAG} --body "$LEARNED_BODY" # allowlist:check-command-side-effects
1123
+
1124
+ echo "FORGE:LEARNED annotation posted."
1125
+ fi
837
1126
  ```
838
1127
 
839
- Return verdict to caller (work-on routing loop proceeds to build).
1128
+ **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.
1129
+
1130
+ ---
1131
+
1132
+ ### 1D.2: Update Labels & Return Verdict
1133
+
1134
+ Resolve the posted investigation comment URL once (used in the result block):
840
1135
 
841
- **CONFIRMED or PARTIAL with decompose: YES**:
842
1136
  ```bash
843
- gh issue edit {NUMBER} {GH_FLAG} --remove-label "workflow:investigating"
1137
+ 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
1138
  ```
845
- Do NOT add `workflow:ready-to-build` — the routing loop will invoke `work-on:decompose` based on the `decompose: YES` return value.
846
1139
 
847
- Write machine-readable phase checkpoint (MUST execute immediately after label update, before returning):
1140
+ **CONFIRMED or PARTIAL with decompose: NO** — transition to `ready-to-build` (this phase owns that label):
848
1141
  ```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
- \`\`\`"
1142
+ RESOLUTION=$(resolve_script 'transition-label'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
1143
+ case "$TIER" in
1144
+ adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} ready-to-build ;;
1145
+ prose)
1146
+ 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
1147
+ ;;
1148
+ esac
854
1149
  ```
855
1150
 
856
- Return verdict to caller (work-on routing loop proceeds to decompose).
1151
+ 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
1152
 
858
- **INVALID**:
1153
+ **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
1154
  ```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}"
1155
+ gh issue edit {NUMBER} {GH_FLAG} --remove-label "workflow:investigating" 2>/dev/null || true # allowlist:check-command-side-effects
862
1156
  ```
863
- Return INVALID to caller (work-on stops). No checkpoint written — INVALID is terminal.
1157
+
1158
+ No `FORGE:CHECKPOINT` is written. Print `INVESTIGATE_RESULT:` with `status: COMPLETE`, `decompose: YES` and STOP.
1159
+
1160
+ **INVALID** — transition to `invalid` and close the issue (terminal):
1161
+ ```bash
1162
+ RESOLUTION=$(resolve_script 'transition-label'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
1163
+ case "$TIER" in
1164
+ adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} invalid ;;
1165
+ prose)
1166
+ 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
1167
+ ;;
1168
+ esac
1169
+ gh issue close {NUMBER} {GH_FLAG} --comment "Closing as invalid: {reason from investigation}" # allowlist:check-command-side-effects
1170
+ ```
1171
+
1172
+ No checkpoint written — INVALID is terminal. Print `INVESTIGATE_RESULT:` with `status: INVALID` and STOP.
864
1173
 
865
1174
  ---
866
1175
 
867
1176
  ## Output
868
1177
 
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.
1178
+ 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
1179
 
871
1180
  ```
872
1181
  INVESTIGATE_RESULT:
873
- verdict: {CONFIRMED|PARTIAL|INVALID}
874
- confidence: {HIGH|MEDIUM|LOW}
875
- decompose: {YES|NO}
876
- comment_url: {url of posted comment}
1182
+ status: {COMPLETE|ALREADY_DONE|INVALID|BLOCKED}
1183
+ verdict: {CONFIRMED|PARTIAL|INVALID|null}
1184
+ confidence: {HIGH|MEDIUM|LOW|null}
1185
+ decompose: {YES|NO|null}
1186
+ comment_url: {url of posted (or pre-existing) comment, or null}
877
1187
  gist_url: {url of knowledge gist, or null if creation failed/skipped}
878
1188
  milestone_index_url: {url of milestone index gist, or null if no milestone/creation failed}
1189
+ blocker: {one-line reason when status is BLOCKED, else null}
879
1190
  ```
1191
+
1192
+ **Status mapping**:
1193
+ - `COMPLETE` — fresh investigation finished with verdict CONFIRMED or PARTIAL (`decompose` YES or NO).
1194
+ - `ALREADY_DONE` — resume check found a completed investigation with a non-INVALID verdict; the block carries the parsed existing verdict (Phase 1A).
1195
+ - `INVALID` — verdict INVALID (fresh run, or an existing `INVESTIGATION:INVALID` comment found on resume); issue closed with `workflow:invalid`.
1196
+ - `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.