forgedock 1.1.7 → 1.1.8

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 (37) hide show
  1. package/bin/engine/invariants.mjs +361 -0
  2. package/bin/hooks/pre-tool-use.mjs +182 -0
  3. package/bin/labels.json +10 -0
  4. package/bin/recall.mjs +540 -0
  5. package/commands/autopilot.md +957 -76
  6. package/commands/cleanup.md +52 -3
  7. package/commands/optimize.md +585 -0
  8. package/commands/orchestrate/phase-1-resolve.md +35 -25
  9. package/commands/orchestrate/phase-3-dependency.md +465 -34
  10. package/commands/orchestrate/phase-4-execution.md +447 -28
  11. package/commands/orchestrate/phase-6-report.md +198 -9
  12. package/commands/pipeline-health/phase-2-metrics.md +305 -1
  13. package/commands/pipeline-health/phase-4-proposals.md +127 -0
  14. package/commands/quality-gate.md +417 -0
  15. package/commands/review-pr.md +391 -13
  16. package/commands/spec-doctor.md +429 -0
  17. package/commands/validate.md +52 -0
  18. package/commands/work-on/build/architect.md +77 -0
  19. package/commands/work-on/build/context.md +357 -22
  20. package/commands/work-on/build/implement.md +2 -0
  21. package/commands/work-on/build/validate.md +28 -0
  22. package/commands/work-on/build.md +44 -0
  23. package/commands/work-on/close.md +621 -8
  24. package/commands/work-on/investigate.md +221 -0
  25. package/commands/work-on.md +15 -14
  26. package/docs/FORGE-PROTOCOL.md +101 -0
  27. package/docs/spec/forge-protocol-v1.md +97 -5
  28. package/docs/spec/label-state-machine.md +6 -1
  29. package/package.json +2 -2
  30. package/packages/protocol/src/cli.js +374 -84
  31. package/packages/protocol/src/types.js +75 -1
  32. package/scripts/build-knowledge-index.mjs +1761 -0
  33. package/scripts/calibration.mjs +996 -0
  34. package/scripts/danger-zones.mjs +782 -0
  35. package/scripts/doctor-pipeline-state.sh +88 -2
  36. package/scripts/select-fix-targets.sh +234 -0
  37. package/scripts/transition-label.sh +157 -6
@@ -15,13 +15,16 @@ install: extras
15
15
  - `{SERVER_SSH}` ← `services.server_ssh` (optional) — SSH target for production server health checks
16
16
  - `{OPS_INBOX_PATH}` ← `services.ops_inbox_path` (optional) — path on production server to ops work-item files
17
17
  - `{BILLING_ENABLED}` ← `billing.enabled` (optional, default `false`) — set to `true` to enable Stripe data in Analytics Snapshot
18
+ - `{RECON_SOURCES}` ← `autopilot.recon_sources` (optional, default `["ci","backlog"]`) — list of collectors to run in Phase 1. Built-in tags: `ci` (Phase 1B), `backlog` (Phase 1C), `analytics` (Phase 1D). Omit the key to run `ci` and `backlog` only.
18
19
 
19
20
  **NEVER use plan mode (EnterPlanMode).**
20
21
  **NEVER use the Agent tool** — autopilot dispatches all work via `Skill(...)` calls only. The Agent tool bypasses the Skill pipeline's label state machine, investigation comments, and structured review — leaving no audit trail.
21
22
 
22
23
  <!-- FORGE:SPEC_LOADED — autopilot.md loaded and active. Agent is bound by this spec. -->
23
24
 
24
- You are a fully autonomous deploy loop for this project. Your job is to **detect the current pipeline state, work through all open issues from highest to lowest priority, and deploy everything — without stopping for user confirmation**. Invoking `/autopilot` is the authorization to run to completion.
25
+ You are a fully autonomous deploy loop for this project. Your job is to **detect the current pipeline state, work through all open issues, and deploy everything — without stopping for user confirmation**. Invoking `/autopilot` is the authorization to run to completion.
26
+
27
+ **Issue selection and priority**: Phase 2 (Fast Lane Loop) dispatches all eligible open issues **concurrently** via the durable engine — ordering is irrelevant at dispatch time because all issues start simultaneously. For scenarios requiring sequential, priority-ordered dispatch (P0 before P1 before P2, unlabeled excluded), use `scripts/select-fix-targets.sh` as the entry point. This script will be the substitution point when #1743 (economic scheduling) implements value/cost-based ordering. <!-- Added: forge#1752 -->
25
28
 
26
29
  **This command overrides the standard "never merge to main" rule.** `/autopilot` IS the authorized deploy system. It ships staging→main and milestone→staging→main as part of normal operation.
27
30
 
@@ -38,16 +41,19 @@ You are a fully autonomous deploy loop for this project. Your job is to **detect
38
41
  | (none) | Full autonomous loop — state detection → recon → fast lane → milestone → report |
39
42
  | `--dry-run` | Run all phases but do NOT create issues, merge PRs, or modify state — report only |
40
43
  | `--recon-only` | Phase 0 (state detection) and Phase 1 (recon) only — no loop execution |
44
+ | `--fix` | Enable autonomous fix dispatch for this run (requires `autopilot.headless: true` in forge.yaml; refused otherwise) |
41
45
 
42
46
  Parse `$ARGUMENTS` and set:
43
47
  ```bash
44
48
  DRY_RUN=false
45
49
  RECON_ONLY=false
50
+ AUTOPILOT_FIX=false
46
51
 
47
52
  for arg in $ARGUMENTS; do
48
53
  case "$arg" in
49
54
  --dry-run) DRY_RUN=true ;;
50
55
  --recon-only) RECON_ONLY=true ;;
56
+ --fix) AUTOPILOT_FIX=true ;;
51
57
  esac
52
58
  done
53
59
  ```
@@ -77,7 +83,96 @@ SERVER_SSH=$(yq '.services.server_ssh // ""' "$CONFIG_FILE" 2>/dev/null || echo
77
83
  OPS_INBOX_PATH=$(yq '.services.ops_inbox_path // ""' "$CONFIG_FILE" 2>/dev/null || echo '')
78
84
  BILLING_ENABLED=$(yq '.billing.enabled // false' "$CONFIG_FILE" 2>/dev/null || echo 'false')
79
85
 
80
- echo "Autopilot: repo=$GH_REPO staging=$STAGING_BRANCH default=$DEFAULT_BRANCH dry_run=$DRY_RUN recon_only=$RECON_ONLY"
86
+ # Hygiene counters — populated by Phase 1B.5 (/cleanup) and Phase 1A (/recover-orphans)
87
+ CLEANUP_LABELS_FIXED=0
88
+
89
+ # Autopilot ops issue — rolling issue where FORGE:AUTOPILOT_CYCLE annotations accumulate.
90
+ # Created automatically on first run if absent. Label is configurable; defaults to autopilot-ops.
91
+ AUTOPILOT_OPS_LABEL=$(yq '.autopilot.ops_issue_label // "autopilot-ops"' "$CONFIG_FILE" 2>/dev/null || echo 'autopilot-ops')
92
+
93
+ # Declared autonomy policy (forge.yaml → autopilot.*).
94
+ # All fields default to the conservative/safe value when absent so existing configs are unaffected.
95
+ AUTOPILOT_HEADLESS=$(yq '.autopilot.headless // false' "$CONFIG_FILE" 2>/dev/null || echo 'false')
96
+ AUTOPILOT_APPROVE_P0=$(yq '.autopilot.approve.p0 // "needs-human"' "$CONFIG_FILE" 2>/dev/null || echo 'needs-human')
97
+ AUTOPILOT_APPROVE_P1=$(yq '.autopilot.approve.p1 // "needs-human"' "$CONFIG_FILE" 2>/dev/null || echo 'needs-human')
98
+ AUTOPILOT_APPROVE_P2=$(yq '.autopilot.approve.p2 // "needs-human"' "$CONFIG_FILE" 2>/dev/null || echo 'needs-human')
99
+ AUTOPILOT_APPROVE_P3=$(yq '.autopilot.approve.p3 // "needs-human"' "$CONFIG_FILE" 2>/dev/null || echo 'needs-human')
100
+ BUDGET_PER_CYCLE_FIXES=$(yq '.autopilot.budget.per_cycle_fixes // "null"' "$CONFIG_FILE" 2>/dev/null || echo 'null')
101
+ # yq may emit a float (e.g. "5.0"); truncate to an integer so `[ "$FIX_COUNT" -ge ... ]`
102
+ # does not error. Leaves the "null"/empty guard value untouched. Single assignment, so
103
+ # both budget-ceiling checks (fast lane + milestone lane) read the truncated value.
104
+ case "$BUDGET_PER_CYCLE_FIXES" in
105
+ ''|null) : ;;
106
+ *) BUDGET_PER_CYCLE_FIXES=$(echo "$BUDGET_PER_CYCLE_FIXES" | cut -d. -f1) ;;
107
+ esac
108
+ RECON_SOURCES=$(yq '.autopilot.recon_sources // ["ci","backlog"] | join(",")' "$CONFIG_FILE" 2>/dev/null || echo 'ci,backlog')
109
+
110
+ # FIX_COUNT tracks how many issues have been dispatched as autonomous fixes this cycle.
111
+ # Declared here (outside all loops) so it persists across Phase 2 and Phase 3 iterations.
112
+ FIX_COUNT=0
113
+
114
+ # Policy decision accumulators — populated during dispatch; written to FORGE:AUTOPILOT_CYCLE.
115
+ POLICY_APPROVED_ISSUES=() # issue numbers dispatched automatically by policy
116
+ POLICY_GATED_ISSUES=() # issue numbers whose plan was posted for async approval
117
+ POLICY_DEFERRED_ISSUES=() # issue numbers deferred due to budget ceiling
118
+
119
+ # Headless guard: if --fix requested but headless: false (default), refuse and exit.
120
+ # This guard runs after all config is read so the refusal message can include the fix target.
121
+ if [ "$AUTOPILOT_FIX" = "true" ] && [ "$AUTOPILOT_HEADLESS" != "true" ]; then
122
+ echo ""
123
+ echo "ERROR: --fix requested but autopilot.headless is not set to true in forge.yaml."
124
+ echo ""
125
+ echo "Autonomous fix dispatch requires an explicit opt-in:"
126
+ echo " autopilot:"
127
+ echo " headless: true"
128
+ echo " approve:"
129
+ echo " p2: auto # or whichever priorities you trust for autonomous fixing"
130
+ echo ""
131
+ echo "Without this declaration, /autopilot performs recon and reporting only — no fixes."
132
+ echo "Add the autopilot.headless: true block to forge.yaml to enable unattended fixing."
133
+ exit 1
134
+ fi
135
+
136
+ # Resolve or create the ops issue (idempotent — checks before creating)
137
+ OPS_ISSUE_NUMBER=$(gh issue list $GH_FLAG \
138
+ --state open \
139
+ --label "$AUTOPILOT_OPS_LABEL" \
140
+ --limit 1 \
141
+ --json number \
142
+ --jq '.[0].number // empty' 2>/dev/null || echo '')
143
+
144
+ if [ -z "$OPS_ISSUE_NUMBER" ]; then
145
+ echo "Autopilot: ops issue not found — creating one with label '$AUTOPILOT_OPS_LABEL'"
146
+ if [ "$DRY_RUN" = "false" ]; then
147
+ # Ensure the label exists before creating the issue
148
+ gh label create "$AUTOPILOT_OPS_LABEL" \
149
+ --description "Autopilot ops issue — rolling FORGE:AUTOPILOT_CYCLE annotations. Managed by /autopilot." \
150
+ --color "0075CA" \
151
+ $GH_FLAG 2>/dev/null || true
152
+ OPS_ISSUE_NUMBER=$(gh issue create $GH_FLAG \
153
+ --title "ops: autopilot cycle log" \
154
+ --label "$AUTOPILOT_OPS_LABEL" \
155
+ --body "$(cat <<'OPS_EOF'
156
+ ## Autopilot Ops Issue
157
+
158
+ This issue is the rolling log for `/autopilot` cycle annotations. Each cycle appends a `<!-- FORGE:AUTOPILOT_CYCLE -->` comment with its metrics and phase-completion markers. Cycle N+1 reads the latest comment for baseline deltas and resume state.
159
+
160
+ **Do not close this issue manually.** It is managed by `/autopilot`.
161
+ OPS_EOF
162
+ )" \
163
+ --json number \
164
+ --jq '.number' 2>/dev/null || echo '')
165
+ echo "Autopilot: ops issue created: #$OPS_ISSUE_NUMBER"
166
+ else
167
+ echo "[DRY-RUN] Would create ops issue with label '$AUTOPILOT_OPS_LABEL'"
168
+ OPS_ISSUE_NUMBER="0"
169
+ fi
170
+ else
171
+ echo "Autopilot: ops issue found: #$OPS_ISSUE_NUMBER"
172
+ fi
173
+
174
+ echo "Autopilot: repo=$GH_REPO staging=$STAGING_BRANCH default=$DEFAULT_BRANCH dry_run=$DRY_RUN recon_only=$RECON_ONLY fix=$AUTOPILOT_FIX ops_issue=#$OPS_ISSUE_NUMBER"
175
+ echo "Autonomy policy: headless=$AUTOPILOT_HEADLESS approve={p0:$AUTOPILOT_APPROVE_P0,p1:$AUTOPILOT_APPROVE_P1,p2:$AUTOPILOT_APPROVE_P2,p3:$AUTOPILOT_APPROVE_P3} budget.per_cycle_fixes=$BUDGET_PER_CYCLE_FIXES"
81
176
  ```
82
177
 
83
178
  ---
@@ -172,6 +267,104 @@ echo "STATE: $OPEN_FAST_LANE open unmilestoned issue(s) for fast lane"
172
267
  - If `STAGING_PR_NUMBER` is set → that deploy is in progress; `/deploy-pr` will detect and resume it
173
268
  - Otherwise → proceed through phases in order
174
269
 
270
+ ### 0G: Read prior cycle baseline (for delta computation)
271
+
272
+ Read the latest `FORGE:AUTOPILOT_CYCLE` annotation from the ops issue. Extract baseline metrics so Phase 4 can compute deltas showing what changed since the last cycle.
273
+
274
+ ```bash
275
+ # Read the latest AUTOPILOT_CYCLE annotation from the ops issue
276
+ PREV_CYCLE_COMMENT=""
277
+ PREV_CYCLE_ID=""
278
+ PREV_CYCLE_BASELINE=""
279
+ PREV_CYCLE_OPEN_ISSUES=""
280
+ PREV_CYCLE_CI_FAILURES=""
281
+ PREV_CYCLE_PENDING_FINDINGS=""
282
+
283
+ if [ -n "$OPS_ISSUE_NUMBER" ] && [ "$OPS_ISSUE_NUMBER" != "0" ]; then
284
+ PREV_CYCLE_COMMENT=$(gh api repos/${GH_REPO}/issues/${OPS_ISSUE_NUMBER}/comments \
285
+ --jq '[.[] | select(.body | contains("FORGE:AUTOPILOT_CYCLE"))] | last | .body // ""' \
286
+ 2>/dev/null || echo '')
287
+
288
+ if [ -n "$PREV_CYCLE_COMMENT" ]; then
289
+ # Check it's a complete (not interrupted) cycle
290
+ PREV_COMPLETE=$(echo "$PREV_CYCLE_COMMENT" | grep -c "FORGE:AUTOPILOT_CYCLE:COMPLETE" || echo "0")
291
+
292
+ if [ "${PREV_COMPLETE:-0}" -gt 0 ]; then
293
+ PREV_CYCLE_ID=$(echo "$PREV_CYCLE_COMMENT" \
294
+ | grep -oP '(?<=\*\*cycle_id\*\*: )[^\n]+' | head -1 | tr -d '[:space:]' || echo '')
295
+ PREV_CYCLE_BASELINE=$(echo "$PREV_CYCLE_COMMENT" \
296
+ | grep -oP '(?<=\*\*baseline\*\*: ).+' | head -1 || echo '{}')
297
+ # Extract numeric metrics from the baseline JSON string
298
+ PREV_CYCLE_OPEN_ISSUES=$(echo "$PREV_CYCLE_BASELINE" \
299
+ | jq -r '.open_issues // empty' 2>/dev/null || echo '')
300
+ PREV_CYCLE_CI_FAILURES=$(echo "$PREV_CYCLE_BASELINE" \
301
+ | jq -r '.ci_failures // empty' 2>/dev/null || echo '')
302
+ PREV_CYCLE_PENDING_FINDINGS=$(echo "$PREV_CYCLE_BASELINE" \
303
+ | jq -r '.pending_findings // empty' 2>/dev/null || echo '')
304
+ echo "Prior cycle: $PREV_CYCLE_ID — open_issues=$PREV_CYCLE_OPEN_ISSUES ci_failures=$PREV_CYCLE_CI_FAILURES findings=$PREV_CYCLE_PENDING_FINDINGS"
305
+ else
306
+ echo "Prior cycle annotation found but not complete — will check for resume in Phase 0H"
307
+ fi
308
+ else
309
+ echo "No prior FORGE:AUTOPILOT_CYCLE annotation found — this is the first cycle"
310
+ fi
311
+ fi
312
+ ```
313
+
314
+ ### 0H: Resume detection (skip committed phases on restart)
315
+
316
+ If the latest cycle annotation on the ops issue is **incomplete** (lacks `FORGE:AUTOPILOT_CYCLE:COMPLETE`), this cycle was interrupted. Read `phase_markers` to determine which phases already committed and skip them — avoiding duplicate issue creation, duplicate deploys, and redundant work.
317
+
318
+ ```bash
319
+ RESUME_FROM_PHASE=""
320
+ SKIP_RECON=false
321
+ SKIP_FAST_LANE=false
322
+ SKIP_MILESTONE=false
323
+
324
+ if [ -n "$PREV_CYCLE_COMMENT" ]; then
325
+ PREV_COMPLETE=$(echo "$PREV_CYCLE_COMMENT" | grep -c "FORGE:AUTOPILOT_CYCLE:COMPLETE" || echo "0")
326
+
327
+ if [ "${PREV_COMPLETE:-0}" -eq 0 ]; then
328
+ # Incomplete cycle — extract committed phase_markers
329
+ PREV_PHASE_MARKERS=$(echo "$PREV_CYCLE_COMMENT" \
330
+ | grep -oP '(?<=\*\*phase_markers\*\*: )[^\n]+' | head -1 | tr -d '[:space:]' || echo '')
331
+
332
+ echo "RESUME: Incomplete prior cycle detected. Committed phases: ${PREV_PHASE_MARKERS:-none}"
333
+
334
+ # Set skip flags for phases already committed
335
+ echo "$PREV_PHASE_MARKERS" | grep -q "recon" && SKIP_RECON=true && echo "RESUME: Skipping Phase 1 (recon already committed)"
336
+ echo "$PREV_PHASE_MARKERS" | grep -q "fast-lane" && SKIP_FAST_LANE=true && echo "RESUME: Skipping Phase 2 (fast-lane already committed)"
337
+ echo "$PREV_PHASE_MARKERS" | grep -q "milestone" && SKIP_MILESTONE=true && echo "RESUME: Skipping Phase 3 (milestone already committed)"
338
+
339
+ # Inherit the cycle_id from the interrupted cycle so the resume writes to the same annotation
340
+ RESUME_CYCLE_ID=$(echo "$PREV_CYCLE_COMMENT" \
341
+ | grep -oP '(?<=\*\*cycle_id\*\*: )[^\n]+' | head -1 | tr -d '[:space:]' || echo '')
342
+ [ -n "$RESUME_CYCLE_ID" ] && echo "RESUME: Continuing as cycle $RESUME_CYCLE_ID"
343
+ fi
344
+ fi
345
+
346
+ # Generate a new cycle_id if not resuming an interrupted cycle
347
+ if [ -z "$RESUME_CYCLE_ID" ]; then
348
+ # Count completed cycles today to produce a unique counter
349
+ TODAY=$(date -u +%Y%m%d)
350
+ if [ -n "$OPS_ISSUE_NUMBER" ] && [ "$OPS_ISSUE_NUMBER" != "0" ]; then
351
+ TODAY_CYCLE_COUNT=$(gh api repos/${GH_REPO}/issues/${OPS_ISSUE_NUMBER}/comments \
352
+ --jq "[.[] | select(.body | contains(\"FORGE:AUTOPILOT_CYCLE\") and contains(\"FORGE:AUTOPILOT_CYCLE:COMPLETE\") and contains(\"$TODAY\"))] | length" \
353
+ 2>/dev/null || echo '0')
354
+ else
355
+ TODAY_CYCLE_COUNT=0
356
+ fi
357
+ CYCLE_COUNTER=$((TODAY_CYCLE_COUNT + 1))
358
+ CYCLE_ID="${TODAY}-${CYCLE_COUNTER}"
359
+ echo "New cycle: $CYCLE_ID"
360
+ else
361
+ CYCLE_ID="$RESUME_CYCLE_ID"
362
+ fi
363
+
364
+ # Initialize phase_markers (will be extended as phases complete)
365
+ PHASE_MARKERS_COMMITTED="${PREV_PHASE_MARKERS:-}"
366
+ ```
367
+
175
368
  ---
176
369
 
177
370
  ## Phase 1: Recon
@@ -180,6 +373,20 @@ echo "STATE: $OPEN_FAST_LANE open unmilestoned issue(s) for fast lane"
180
373
 
181
374
  If `RECON_ONLY=true`, print the recon report and **stop after this phase**.
182
375
 
376
+ **Resume guard**: If `SKIP_RECON=true` (set in Phase 0H — recon was already committed in the interrupted cycle), skip Phase 1 entirely and proceed to Phase 2.
377
+
378
+ ```bash
379
+ if [ "$SKIP_RECON" = "true" ]; then
380
+ echo "RESUME: Phase 1 (recon) already committed in cycle $CYCLE_ID — skipping"
381
+ RECON_ISSUES=()
382
+ RECENT_FAILURES=0
383
+ RECENT_RUNS=0
384
+ RECURRING=""
385
+ else
386
+ ```
387
+
388
+ *(Close the skip block at the end of Phase 1, after the RECON_ONLY stop point.)*
389
+
183
390
  ### 1A: Recover orphaned pipeline state (MANDATORY — run before recon)
184
391
 
185
392
  Orphaned issues (stuck in workflow:building or workflow:in-review without an active agent) cause loop contamination. Recover them first so the loop sees accurate open issue counts.
@@ -203,70 +410,136 @@ fi
203
410
 
204
411
  ### 1B: CI/CD Health
205
412
 
413
+ Runs when `ci` is in `RECON_SOURCES` (default: always). Skip with `autopilot.recon_sources: ["backlog"]` in forge.yaml.
414
+
206
415
  ```bash
207
- DATE_1D_AGO=$(python3 -c "from datetime import datetime, timedelta, timezone; print((datetime.now(timezone.utc) - timedelta(days=1)).strftime('%Y-%m-%dT%H:%M:%SZ'))")
416
+ # Collector gate — skip if 'ci' not declared in RECON_SOURCES.
417
+ # Exact comma-delimited match so 'ci' does not false-match 'ci-extended' (grep -qw
418
+ # treats '-' as a word boundary and would match the hyphenated tag).
419
+ if echo "$RECON_SOURCES" | grep -qE '(^|,)ci(,|$)'; then
420
+ # Node.js is guaranteed by the npm installer; python3 is not (Windows alias issue)
421
+ DATE_1D_AGO=$(node -e "console.log(new Date(Date.now()-86400000).toISOString())")
422
+
423
+ # 3-minute timeout per collector — a hung gh call costs one report line, not the cycle
424
+ TIMEOUT_CMD=$(command -v timeout >/dev/null 2>&1 && echo "timeout 180" || echo "")
425
+
426
+ RECENT_FAILURES=$(${TIMEOUT_CMD} gh run list $GH_FLAG --limit 30 --json conclusion,createdAt,workflowName \
427
+ --jq "[.[] | select(.conclusion == \"failure\" and .createdAt > \"$DATE_1D_AGO\")] | length" \
428
+ 2>/dev/null || echo "0")
429
+
430
+ RECENT_RUNS=$(${TIMEOUT_CMD} gh run list $GH_FLAG --limit 30 --json conclusion,createdAt \
431
+ --jq "[.[] | select(.createdAt > \"$DATE_1D_AGO\")] | length" \
432
+ 2>/dev/null || echo "0")
433
+
434
+ echo "CI (24h): $RECENT_FAILURES/$RECENT_RUNS failures"
435
+
436
+ # Recurring failures (same workflow failing multiple times)
437
+ RECURRING=$(${TIMEOUT_CMD} gh run list $GH_FLAG --limit 30 --json conclusion,workflowName \
438
+ --jq '[.[] | select(.conclusion == "failure")] | group_by(.workflowName) | .[] | select(length > 1) | "\(length)x \(.[0].workflowName)"' \
439
+ 2>/dev/null || echo '')
440
+ [ -n "$RECURRING" ] && echo "Recurring CI failures: $RECURRING"
441
+ else
442
+ echo "CI collector (1B): SKIPPED — 'ci' not in autopilot.recon_sources"
443
+ RECENT_FAILURES=0
444
+ RECENT_RUNS=0
445
+ RECURRING=""
446
+ fi
447
+ ```
208
448
 
209
- RECENT_FAILURES=$(gh run list $GH_FLAG --limit 30 --json conclusion,createdAt,workflowName \
210
- --jq "[.[] | select(.conclusion == \"failure\" and .createdAt > \"$DATE_1D_AGO\")] | length" \
211
- 2>/dev/null || echo "0")
449
+ ### 1B.5: Label Hygiene Delegation
212
450
 
213
- RECENT_RUNS=$(gh run list $GH_FLAG --limit 30 --json conclusion,createdAt \
214
- --jq "[.[] | select(.createdAt > \"$DATE_1D_AGO\")] | length" \
215
- 2>/dev/null || echo "0")
451
+ Sweep stale workflow labels from closed issues. Delegates to `/cleanup labels` — the command that verifies state before acting and maintains an audit trail. Autopilot never performs label mutations directly.
216
452
 
217
- echo "CI (24h): $RECENT_FAILURES/$RECENT_RUNS failures"
453
+ ```bash
454
+ CLEANUP_AVAILABLE=$(ls ~/.claude/commands/cleanup.md 2>/dev/null && echo "true" || echo "false")
218
455
 
219
- # Recurring failures (same workflow failing multiple times)
220
- RECURRING=$(gh run list $GH_FLAG --limit 30 --json conclusion,workflowName \
221
- --jq '[.[] | select(.conclusion == "failure")] | group_by(.workflowName) | .[] | select(length > 1) | "\(length)x \(.[0].workflowName)"' \
222
- 2>/dev/null || echo '')
223
- [ -n "$RECURRING" ] && echo "Recurring CI failures: $RECURRING"
456
+ if [ "$DRY_RUN" = "false" ]; then
457
+ if [ "$CLEANUP_AVAILABLE" = "true" ]; then
458
+ echo "Running label hygiene sweep via /cleanup..."
459
+ Skill("cleanup", args="labels")
460
+ CLEANUP_LABELS_FIXED=1 # /cleanup ran; detailed counts are in its own output
461
+ else
462
+ echo "INFO: /cleanup not installed (extras tier) — skipping label hygiene sweep. Install with: npx forgedock install --extras"
463
+ fi
464
+ else
465
+ echo "[DRY-RUN] Would invoke: Skill(cleanup, labels)"
466
+ fi
224
467
  ```
225
468
 
226
469
  ### 1C: Issue Backlog Health
227
470
 
471
+ Runs when `backlog` is in `RECON_SOURCES` (default: always). Skip with `autopilot.recon_sources: ["ci"]` in forge.yaml.
472
+
228
473
  ```bash
229
- DATE_14D_AGO=$(python3 -c "from datetime import datetime, timedelta, timezone; print((datetime.now(timezone.utc) - timedelta(days=14)).strftime('%Y-%m-%dT%H:%M:%SZ'))")
230
-
231
- # Count by priority
232
- P0_COUNT=$(gh issue list $GH_FLAG --state open --limit 200 --json labels \
233
- --jq '[.[] | select(.labels | map(.name) | any(. == "P0"))] | length' 2>/dev/null || echo "0")
234
- P1_COUNT=$(gh issue list $GH_FLAG --state open --limit 200 --json labels \
235
- --jq '[.[] | select(.labels | map(.name) | any(. == "P1"))] | length' 2>/dev/null || echo "0")
236
- P2_COUNT=$(gh issue list $GH_FLAG --state open --limit 200 --json labels \
237
- --jq '[.[] | select(.labels | map(.name) | any(. == "P2"))] | length' 2>/dev/null || echo "0")
238
-
239
- # Stale issues (open >14d, no workflow label, no milestone)
240
- STALE_COUNT=$(gh issue list $GH_FLAG --state open --limit 200 --json number,labels,createdAt,milestone \
241
- --jq "[.[] | select(
242
- (.labels | map(.name) | any(startswith(\"workflow:\")) | not) and
243
- (.createdAt < \"$DATE_14D_AGO\") and
244
- .milestone == null
245
- )] | length" 2>/dev/null || echo "0")
246
-
247
- echo "Backlog: P0=$P0_COUNT P1=$P1_COUNT P2=$P2_COUNT stale=$STALE_COUNT"
474
+ # Collector gate — skip if 'backlog' not declared in RECON_SOURCES.
475
+ # Exact comma-delimited match (see 1B) — avoids false-matching hyphenated tags.
476
+ if echo "$RECON_SOURCES" | grep -qE '(^|,)backlog(,|$)'; then
477
+ # Node.js is guaranteed by the npm installer; python3 is not (Windows alias issue)
478
+ DATE_14D_AGO=$(node -e "console.log(new Date(Date.now()-14*86400000).toISOString())")
479
+
480
+ # Reuse TIMEOUT_CMD from 1B if set; detect if this is the first collector run
481
+ TIMEOUT_CMD=${TIMEOUT_CMD:-$(command -v timeout >/dev/null 2>&1 && echo "timeout 180" || echo "")}
482
+
483
+ # Count by priority
484
+ P0_COUNT=$(${TIMEOUT_CMD} gh issue list $GH_FLAG --state open --limit 200 --json labels \
485
+ --jq '[.[] | select(.labels | map(.name) | any(. == "P0"))] | length' 2>/dev/null || echo "0")
486
+ P1_COUNT=$(${TIMEOUT_CMD} gh issue list $GH_FLAG --state open --limit 200 --json labels \
487
+ --jq '[.[] | select(.labels | map(.name) | any(. == "P1"))] | length' 2>/dev/null || echo "0")
488
+ P2_COUNT=$(${TIMEOUT_CMD} gh issue list $GH_FLAG --state open --limit 200 --json labels \
489
+ --jq '[.[] | select(.labels | map(.name) | any(. == "P2"))] | length' 2>/dev/null || echo "0")
490
+
491
+ # Stale issues (open >14d, no workflow label, no milestone)
492
+ STALE_COUNT=$(${TIMEOUT_CMD} gh issue list $GH_FLAG --state open --limit 200 --json number,labels,createdAt,milestone \
493
+ --jq "[.[] | select(
494
+ (.labels | map(.name) | any(startswith(\"workflow:\")) | not) and
495
+ (.createdAt < \"$DATE_14D_AGO\") and
496
+ .milestone == null
497
+ )] | length" 2>/dev/null || echo "0")
498
+
499
+ echo "Backlog: P0=$P0_COUNT P1=$P1_COUNT P2=$P2_COUNT stale=$STALE_COUNT"
500
+ else
501
+ echo "Backlog collector (1C): SKIPPED — 'backlog' not in autopilot.recon_sources"
502
+ P0_COUNT=0; P1_COUNT=0; P2_COUNT=0; STALE_COUNT=0
503
+ fi
248
504
  ```
249
505
 
250
- ### 1D: Analytics Pulse (optional — forge.yaml-gated)
506
+ ### 1D: Analytics Pulse (optional — forge.yaml-gated, recon_sources-gated)
507
+
508
+ Runs only when ALL of: (a) `analytics` is in `RECON_SOURCES`, (b) `CREDENTIALS_FILE` is set and exists, (c) the relevant MCP server is reachable. Enable with `autopilot.recon_sources: ["ci","backlog","analytics"]` in forge.yaml.
251
509
 
252
510
  ```bash
253
- # Only run if CREDENTIALS_FILE is configured and the file exists
254
- if [ -n "$CREDENTIALS_FILE" ] && [ -f "$CREDENTIALS_FILE" ]; then
255
- echo "Analytics pulse: credentials at $CREDENTIALS_FILE — checking GSC..."
256
- # GSC: last 7 days via mcp__gsc__search_analytics (if MCP available)
257
- # If MCP unavailable, log and skip — do not block
258
- ANALYTICS_AVAILABLE=true
511
+ ANALYTICS_AVAILABLE=false
512
+
513
+ # Collector gate — skip if 'analytics' not declared in RECON_SOURCES.
514
+ # Exact comma-delimited match (see 1B) — avoids false-matching hyphenated tags.
515
+ if ! echo "$RECON_SOURCES" | grep -qE '(^|,)analytics(,|$)'; then
516
+ echo "Analytics collector (1D): SKIPPED — 'analytics' not in autopilot.recon_sources"
517
+ elif [ -z "$CREDENTIALS_FILE" ] || [ ! -f "$CREDENTIALS_FILE" ]; then
518
+ echo "Analytics collector (1D): SKIPPED — paths.credentials.file not set in forge.yaml or file absent"
259
519
  else
260
- echo "Analytics pulse: SKIPPED — paths.credentials.file not set in forge.yaml or file absent"
261
- ANALYTICS_AVAILABLE=false
520
+ echo "Analytics pulse: credentials at $CREDENTIALS_FILE — checking MCP availability..."
521
+ # MCP capability detection: verify the GSC MCP server is reachable before invoking.
522
+ # If the server is absent, the tool call would produce an agent error rather than a graceful skip.
523
+ # Detection strategy: attempt a lightweight probe (list tools or a no-op call) and gate on exit code.
524
+ # When the MCP server is present, proceed; when absent, log and skip without blocking the cycle.
525
+ # Dual-mode by design: an LLM agent interprets `mcp__gsc__search_analytics` as an MCP tool call
526
+ # (exit code = availability); a POSIX shell interprets it as an unknown command → 'command not
527
+ # found' → exit 1 → the else branch. Either interpretation yields a graceful skip when GSC is absent.
528
+ if mcp__gsc__search_analytics --list-only 2>/dev/null; then
529
+ ANALYTICS_AVAILABLE=true
530
+ echo "Analytics pulse: GSC MCP available — proceeding with 7-day search analytics query"
531
+ # Full GSC analytics query runs here when MCP is present
532
+ else
533
+ echo "Analytics pulse: SKIPPED — GSC MCP server not reachable (mcp__gsc__search_analytics unavailable)"
534
+ fi
262
535
  fi
263
536
 
264
- # Stripe balance: only if billing.enabled is true AND credentials are present
537
+ # Stripe balance: only if billing.enabled is true AND analytics collector ran successfully
265
538
  if [ "$BILLING_ENABLED" = "true" ] && [ "$ANALYTICS_AVAILABLE" = "true" ]; then
266
539
  echo "Billing analytics: checking Stripe balance..."
267
540
  # mcp__stripe__retrieve_balance — skip gracefully if MCP unavailable
268
541
  else
269
- echo "Billing analytics: SKIPPED — billing.enabled is false (or credentials absent)"
542
+ echo "Billing analytics: SKIPPED — billing.enabled is false, credentials absent, or analytics collector skipped"
270
543
  fi
271
544
  ```
272
545
 
@@ -274,19 +547,39 @@ fi
274
547
 
275
548
  For each finding (recurring CI failures, critical stale issue patterns):
276
549
 
277
- **Deduplication check first** — search existing open issues before creating:
550
+ **Deduplication check first** — deterministic script, then LLM fallback (canonical path from `issue.md §2D`):
278
551
  ```bash
279
- EXISTING_ISSUES=$(gh issue list $GH_FLAG --state open --limit 200 --json number,title \
280
- --jq '.[] | "\(.number) \(.title)"' 2>/dev/null)
552
+ # Authoritative deterministic check — uses token overlap algorithm (see scripts/issue-dedup.sh)
553
+ DEDUP_RESULT=$(scripts/issue-dedup.sh "{finding_title}" "$GH_FLAG" 2>&1)
554
+ DEDUP_EXIT=$?
555
+
556
+ if [ "$DEDUP_EXIT" -eq 1 ]; then
557
+ echo "Near-duplicate detected: $DEDUP_RESULT"
558
+ echo "Existing issue found — skipping creation for this finding."
559
+ # STOP here for this finding — do not fall through to gh issue create
560
+ elif [ "$DEDUP_EXIT" -eq 2 ]; then
561
+ echo "Dedup check usage error: $DEDUP_RESULT"
562
+ echo "Do NOT proceed to issue creation — fix the invocation and retry."
563
+ # STOP here — do not fall through to gh issue create
564
+ fi
565
+
566
+ # If script exits 0 (no match), also run LLM-side semantic search as a secondary pass
567
+ if [ "$DEDUP_EXIT" -eq 0 ]; then
568
+ gh issue list $GH_FLAG --state open --limit 20 --search "{key_terms_from_finding}" \
569
+ --json number,title,labels \
570
+ --jq '.[] | "#\(.number) [\(.labels | map(.name) | join(","))] \(.title)"' 2>/dev/null || true
571
+ # LLM: if the secondary pass finds a semantic duplicate, treat it as DEDUP_EXIT=1 and skip
572
+ fi
281
573
  ```
282
574
 
283
- For new findings that have no existing open duplicate:
575
+ For new findings that have no existing open duplicate (DEDUP_EXIT=0 and no LLM match):
284
576
  ```bash
285
- if [ "$DRY_RUN" = "false" ]; then
286
- gh issue create $GH_FLAG \
287
- --title "fix: {finding_description}" \
288
- --label "P2,bug" \
289
- --body "$(cat <<'ISSUE_EOF'
577
+ if [ "$DRY_RUN" = "false" ] && [ "${DEDUP_EXIT:-0}" -eq 0 ]; then
578
+ # Write body to a temp file to avoid heredoc shell-expansion and injection issues
579
+ BODY_TMPFILE=$(mktemp /tmp/autopilot-issue-body-XXXXXX.md)
580
+ trap 'rm -f "$BODY_TMPFILE"' EXIT
581
+
582
+ cat > "$BODY_TMPFILE" <<'ISSUE_BODY_EOF'
290
583
  ## Problem
291
584
 
292
585
  {Description of the finding with specific data points.}
@@ -297,7 +590,7 @@ if [ "$DRY_RUN" = "false" ]; then
297
590
 
298
591
  ## Affected Files
299
592
 
300
- Files that need changes:
593
+ Files that need changes (ordered by dependency):
301
594
  1. `{filepath}` — {what needs to change}
302
595
 
303
596
  ## Acceptance Criteria
@@ -306,14 +599,57 @@ Files that need changes:
306
599
 
307
600
  ## Context
308
601
 
309
- Found by \`/autopilot\` cycle on $(date -u +%Y-%m-%dT%H:%M:%SZ).
602
+ Found by `/autopilot` cycle on {CYCLE_TIMESTAMP}.
310
603
 
311
604
  ## Evidence
312
605
 
313
606
  {Concrete data — log lines, failure counts, metrics}
314
- ISSUE_EOF
315
- )"
316
- else
607
+ ISSUE_BODY_EOF
608
+
609
+ # Substitute the cycle timestamp (not inside heredoc to avoid expansion-in-template issues)
610
+ sed -i "s|{CYCLE_TIMESTAMP}|$(date -u +%Y-%m-%dT%H:%M:%SZ)|g" "$BODY_TMPFILE"
611
+
612
+ NEW_NUMBER=$(gh issue create $GH_FLAG \
613
+ --title "fix: {finding_description}" \
614
+ --label "P2,bug" \
615
+ --body-file "$BODY_TMPFILE" \
616
+ --json number --jq '.number' 2>/dev/null)
617
+
618
+ rm -f "$BODY_TMPFILE"
619
+ trap - EXIT
620
+
621
+ # §4C.5: Body validation/repair — ensure mandatory pipeline sections are present
622
+ # (canonical pattern from issue.md §4C.5 — never fails/blocks, only repairs)
623
+ if [ -n "$NEW_NUMBER" ]; then
624
+ CREATED_BODY=$(gh issue view "$NEW_NUMBER" $GH_FLAG --json body --jq '.body' 2>/dev/null || echo '')
625
+ MISSING_SECTIONS=""
626
+ echo "$CREATED_BODY" | grep -q "^## Problem" || MISSING_SECTIONS="$MISSING_SECTIONS PROBLEM"
627
+ echo "$CREATED_BODY" | grep -q "^## Affected Files" || MISSING_SECTIONS="$MISSING_SECTIONS AFFECTED_FILES"
628
+ echo "$CREATED_BODY" | grep -q "^## Acceptance Criteria" || MISSING_SECTIONS="$MISSING_SECTIONS ACCEPTANCE_CRITERIA"
629
+
630
+ if [ -n "$MISSING_SECTIONS" ]; then
631
+ echo "WARNING: Issue #$NEW_NUMBER body is missing sections:$MISSING_SECTIONS — adding placeholders"
632
+ APPEND_TEXT=""
633
+ echo "$MISSING_SECTIONS" | grep -q "PROBLEM" && APPEND_TEXT="$APPEND_TEXT
634
+ ## Problem
635
+
636
+ Root cause unknown — investigation needed."
637
+ echo "$MISSING_SECTIONS" | grep -q "AFFECTED_FILES" && APPEND_TEXT="$APPEND_TEXT
638
+ ## Affected Files
639
+
640
+ Files to be identified during investigation."
641
+ echo "$MISSING_SECTIONS" | grep -q "ACCEPTANCE_CRITERIA" && APPEND_TEXT="$APPEND_TEXT
642
+ ## Acceptance Criteria
643
+
644
+ - [ ] Fix confirmed during investigation."
645
+ REPAIRED_BODY="${CREATED_BODY}${APPEND_TEXT}"
646
+ gh issue edit "$NEW_NUMBER" $GH_FLAG --body "$REPAIRED_BODY"
647
+ echo "Body repaired — added:$MISSING_SECTIONS"
648
+ else
649
+ echo "Body validation passed for #$NEW_NUMBER — all mandatory sections present"
650
+ fi
651
+ fi
652
+ elif [ "$DRY_RUN" = "true" ]; then
317
653
  echo "[DRY-RUN] Would create issue: fix: {finding_description}"
318
654
  fi
319
655
  ```
@@ -339,12 +675,21 @@ If `RECON_ONLY=true`, print recon report and **stop here**:
339
675
  - Stale (>14d, no workflow): $STALE_COUNT
340
676
 
341
677
  ### Actions Taken
342
- - Orphans recovered: $INFLIGHT_ISSUES
678
+ - Orphans recovered (/recover-orphans): $INFLIGHT_ISSUES
679
+ - Label hygiene (/cleanup labels): $([ "${CLEANUP_LABELS_FIXED:-0}" -eq 1 ] && echo "ran" || echo "skipped (not installed or dry-run)")
343
680
  - Issues created from recon: ${#RECON_ISSUES[@]}
344
681
 
345
682
  Run without --recon-only to execute the full autonomous loop.
346
683
  ```
347
684
 
685
+ ```bash
686
+ fi # end SKIP_RECON guard
687
+
688
+ # Phase 1 complete — record phase marker
689
+ PHASE_MARKERS_COMMITTED="${PHASE_MARKERS_COMMITTED:+$PHASE_MARKERS_COMMITTED,}recon"
690
+ echo "Phase 1 complete. Committed phases: $PHASE_MARKERS_COMMITTED"
691
+ ```
692
+
348
693
  ---
349
694
 
350
695
  ## Phase 2: Fast Lane Loop
@@ -353,11 +698,19 @@ Run without --recon-only to execute the full autonomous loop.
353
698
 
354
699
  **Overrides "never merge to main"**: This phase deploys staging→main after each iteration. `/autopilot` is the authorized deploy system.
355
700
 
701
+ **Resume guard**: If `SKIP_FAST_LANE=true` (set in Phase 0H — fast lane was already committed in the interrupted cycle), skip Phase 2 entirely and proceed to Phase 3.
702
+
356
703
  ```bash
357
704
  FAST_LANE_ITERATIONS=0
358
705
  MAX_FAST_LANE_ITERATIONS=20 # safety cap — prevents infinite loop on stuck state
359
706
  FAST_LANE_DEPLOYS=0
360
707
  FAST_LANE_FINDINGS_BOUNCED=0
708
+ DISPATCHED_ISSUES=() # accumulates every issue number dispatched this cycle (for Phase 4 report)
709
+ STALLED_ISSUES=() # issues that did not reach a terminal state after dispatch
710
+
711
+ if [ "$SKIP_FAST_LANE" = "true" ]; then
712
+ echo "RESUME: Phase 2 (fast-lane) already committed in cycle $CYCLE_ID — skipping"
713
+ else
361
714
 
362
715
  echo "=== Fast Lane Loop ==="
363
716
 
@@ -398,8 +751,11 @@ while true; do
398
751
 
399
752
  if [ "$DRY_RUN" = "false" ]; then
400
753
  if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
401
- # Engine-first dispatch: enumerate issues and run each through the durable engine.
402
- # Issues are dispatched concurrently — the engine holds per-issue leases.
754
+ # Engine-first dispatch: enumerate ALL eligible fast-lane issues and run each
755
+ # through the durable engine concurrently (background workers + wait).
756
+ # Dispatch order is irrelevant here — all issues start simultaneously and the
757
+ # engine's per-issue leases prevent conflicts. For sequential priority-ordered
758
+ # dispatch, use scripts/select-fix-targets.sh instead. <!-- forge#1752 -->
403
759
  FAST_LANE_ISSUE_NUMS=$(gh issue list $GH_FLAG \
404
760
  --state open \
405
761
  --limit 200 \
@@ -411,16 +767,156 @@ while true; do
411
767
  2>/dev/null || echo '')
412
768
 
413
769
  for ISSUE_NUM in $FAST_LANE_ISSUE_NUMS; do
770
+ # Autonomy policy gate — evaluate approve map by issue priority.
771
+ # Only applies when --fix is set; without --fix all issues flow normally.
772
+ if [ "$AUTOPILOT_FIX" = "true" ]; then
773
+ # Read the priority label for this issue (format: priority:P0, priority:P1, etc.)
774
+ ISSUE_PRIORITY_LABEL=$(gh issue view "$ISSUE_NUM" $GH_FLAG \
775
+ --json labels --jq '.labels | map(.name) | map(select(startswith("priority:"))) | .[0] // ""' \
776
+ 2>/dev/null || echo '')
777
+ # Normalize: "priority:P2" → "p2"; unlabeled → "p2" (treat as P2 for policy default)
778
+ PRIORITY_KEY=$(echo "$ISSUE_PRIORITY_LABEL" | sed 's/priority://;s/P/p/' | tr '[:upper:]' '[:lower:]')
779
+ [ -z "$PRIORITY_KEY" ] && PRIORITY_KEY="p2"
780
+
781
+ # Resolve approval action from the map
782
+ case "$PRIORITY_KEY" in
783
+ p0) APPROVE_ACTION="$AUTOPILOT_APPROVE_P0" ;;
784
+ p1) APPROVE_ACTION="$AUTOPILOT_APPROVE_P1" ;;
785
+ p2) APPROVE_ACTION="$AUTOPILOT_APPROVE_P2" ;;
786
+ p3) APPROVE_ACTION="$AUTOPILOT_APPROVE_P3" ;;
787
+ *) APPROVE_ACTION="needs-human" ;; # unknown priority → gate
788
+ esac
789
+
790
+ # Budget ceiling check (only for auto-approved issues)
791
+ if [ "$APPROVE_ACTION" = "auto" ]; then
792
+ if [ "$BUDGET_PER_CYCLE_FIXES" != "null" ] && [ -n "$BUDGET_PER_CYCLE_FIXES" ]; then
793
+ if [ "$FIX_COUNT" -ge "$BUDGET_PER_CYCLE_FIXES" ]; then
794
+ echo " #$ISSUE_NUM deferred (budget ceiling: $FIX_COUNT/$BUDGET_PER_CYCLE_FIXES fixes dispatched)"
795
+ POLICY_DEFERRED_ISSUES+=("$ISSUE_NUM")
796
+ continue
797
+ fi
798
+ fi
799
+ fi
800
+
801
+ if [ "$APPROVE_ACTION" = "auto" ]; then
802
+ echo " #$ISSUE_NUM approved-by-policy ($PRIORITY_KEY → auto) — dispatching"
803
+ POLICY_APPROVED_ISSUES+=("$ISSUE_NUM")
804
+ FIX_COUNT=$((FIX_COUNT + 1))
805
+ else
806
+ # needs-human: post the fix plan on the ops issue for async approval; never dispatch
807
+ echo " #$ISSUE_NUM gated-by-policy ($PRIORITY_KEY → needs-human) — posting plan to ops issue #$OPS_ISSUE_NUMBER"
808
+ POLICY_GATED_ISSUES+=("$ISSUE_NUM")
809
+ ISSUE_TITLE=$(gh issue view "$ISSUE_NUM" $GH_FLAG --json title --jq '.title' 2>/dev/null || echo "#$ISSUE_NUM")
810
+ if [ -n "$OPS_ISSUE_NUMBER" ] && [ "$OPS_ISSUE_NUMBER" != "0" ]; then
811
+ gh issue comment "$OPS_ISSUE_NUMBER" $GH_FLAG --body "<!-- FORGE:AUTOPILOT_GATE -->
812
+ ## Gated Fix — Requires Human Approval
813
+
814
+ **Issue**: #${ISSUE_NUM} — ${ISSUE_TITLE}
815
+ **Priority**: \`${ISSUE_PRIORITY_LABEL:-unlabeled}\`
816
+ **Policy**: \`autopilot.approve.${PRIORITY_KEY} = needs-human\`
817
+ **Action needed**: Review #${ISSUE_NUM} and either approve for autonomous dispatch or resolve manually.
818
+
819
+ To approve: remove \`needs-human\` label from #${ISSUE_NUM} and re-run \`/autopilot --fix\`, OR change \`autopilot.approve.${PRIORITY_KEY}\` to \`auto\` in forge.yaml.
820
+
821
+ <!-- FORGE:AUTOPILOT_GATE:COMPLETE -->" 2>/dev/null || true
822
+ fi
823
+ continue # skip dispatch for gated issues
824
+ fi
825
+ fi
826
+
414
827
  echo "Dispatching #$ISSUE_NUM via forgedock run-issue --lane staging"
828
+ DISPATCHED_ISSUES+=("$ISSUE_NUM")
415
829
  forgedock run-issue "$ISSUE_NUM" --lane staging &
416
830
  done
417
831
 
418
832
  # Wait for all engine workers to complete before proceeding to deploy step.
419
833
  wait
420
834
  echo "Engine dispatch complete for fast-lane iteration $FAST_LANE_ITERATIONS"
835
+
836
+ # Terminal-state verification sweep (MANDATORY after every dispatch batch).
837
+ # Query actual GitHub label state for each dispatched issue — the engine's
838
+ # exit code only reflects process completion, not pipeline terminal state.
839
+ # An issue whose PR merged but whose close phase was interrupted will show
840
+ # workflow:in-review (non-terminal) here and be flagged for targeted recovery.
841
+ echo "Verifying terminal state for $( echo "$FAST_LANE_ISSUE_NUMS" | wc -w | tr -d ' ') dispatched issue(s)..."
842
+ for ISSUE_NUM in $FAST_LANE_ISSUE_NUMS; do
843
+ ISSUE_LABELS=$(gh issue view "$ISSUE_NUM" $GH_FLAG \
844
+ --json labels,state \
845
+ --jq '(.labels | map(.name) | join(",")) + "|" + .state' \
846
+ 2>/dev/null || echo "unknown|unknown")
847
+ ISSUE_LABEL_STR="${ISSUE_LABELS%%|*}"
848
+ ISSUE_STATE="${ISSUE_LABELS##*|}"
849
+
850
+ if echo "$ISSUE_LABEL_STR" | grep -qE 'workflow:merged|workflow:invalid|needs-human' || [ "$ISSUE_STATE" = "CLOSED" ]; then
851
+ echo " #$ISSUE_NUM ✓ terminal ($ISSUE_LABEL_STR)"
852
+ elif echo "$ISSUE_LABEL_STR" | grep -q 'workflow:in-review'; then
853
+ # Known recoverable stall class: PR merged but close phase did not run.
854
+ # Verify PR state before invoking recover-orphans.
855
+ # Search by issue number — ForgeDock branch slugs include the issue number,
856
+ # so a title-only --head slug lookup never matches (returned dead-empty).
857
+ MERGED_PR=$(gh pr list $GH_FLAG --search "closes #$ISSUE_NUM" \
858
+ --state merged --limit 1 --json number --jq '.[0].number' 2>/dev/null || echo '')
859
+ if [ -n "$MERGED_PR" ] || gh api repos/$GH_REPO/issues/$ISSUE_NUM/events \
860
+ --jq '[.[] | select(.event == "labeled" and .label.name == "workflow:merged")] | length > 0' \
861
+ 2>/dev/null | grep -q true; then
862
+ echo " #$ISSUE_NUM ⟳ stalled at workflow:in-review (PR merged) — resuming via recover-orphans"
863
+ if [ "$RECOVER_ORPHANS_AVAILABLE" = "true" ]; then
864
+ Skill("recover-orphans", args="--issue $ISSUE_NUM")
865
+ else
866
+ echo " WARNING: recover-orphans not installed — cannot auto-resume #$ISSUE_NUM"
867
+ STALLED_ISSUES+=("$ISSUE_NUM")
868
+ fi
869
+ else
870
+ echo " #$ISSUE_NUM ⚠ non-terminal ($ISSUE_LABEL_STR) — will retry next iteration"
871
+ STALLED_ISSUES+=("$ISSUE_NUM")
872
+ fi
873
+ else
874
+ echo " #$ISSUE_NUM ⚠ non-terminal ($ISSUE_LABEL_STR) — will retry next iteration"
875
+ STALLED_ISSUES+=("$ISSUE_NUM")
876
+ fi
877
+ done
421
878
  else
422
879
  echo "INFO: Using agent dispatch mode (forgedock CLI not in PATH — run \`npm install -g forgedock\` for engine-mode dispatch)"
880
+ # Safety Rule 2 (needs-human guarantee): The engine-first path above filters
881
+ # needs-human issues via an explicit jq selector. On this fallback path,
882
+ # /orchestrate enforces the same guarantee at its own phase entry — it skips
883
+ # any issue labeled needs-human before dispatching /work-on. Both paths
884
+ # implement Rule 2; the mechanism differs (jq filter vs. downstream check).
885
+
886
+ # Snapshot the fast-lane issue list before orchestrate runs so we can verify
887
+ # terminal state afterward. orchestrate enumerates internally, so we cannot
888
+ # rely on FAST_LANE_ISSUE_NUMS (which was not set on this path).
889
+ PRE_DISPATCH_ISSUES=$(gh issue list $GH_FLAG \
890
+ --state open \
891
+ --limit 200 \
892
+ --json number,milestone,labels \
893
+ --jq '[.[] | select(
894
+ .milestone == null and
895
+ (.labels | map(.name) | any(. == "workflow:merged" or . == "workflow:invalid" or . == "workflow:decomposed" or . == "needs-human") | not)
896
+ )] | .[].number' \
897
+ 2>/dev/null || echo '')
898
+ for N in $PRE_DISPATCH_ISSUES; do DISPATCHED_ISSUES+=("$N"); done
899
+
423
900
  Skill("orchestrate", args="fast-lane")
901
+
902
+ # Terminal-state verification after orchestrate (fallback path).
903
+ # Re-query GitHub state for each pre-dispatch issue.
904
+ echo "Verifying terminal state for $( echo "$PRE_DISPATCH_ISSUES" | wc -w | tr -d ' ') dispatched issue(s) (orchestrate path)..."
905
+ for ISSUE_NUM in $PRE_DISPATCH_ISSUES; do
906
+ ISSUE_LABELS=$(gh issue view "$ISSUE_NUM" $GH_FLAG \
907
+ --json labels,state \
908
+ --jq '(.labels | map(.name) | join(",")) + "|" + .state' \
909
+ 2>/dev/null || echo "unknown|unknown")
910
+ ISSUE_LABEL_STR="${ISSUE_LABELS%%|*}"
911
+ ISSUE_STATE="${ISSUE_LABELS##*|}"
912
+
913
+ if echo "$ISSUE_LABEL_STR" | grep -qE 'workflow:merged|workflow:invalid|needs-human' || [ "$ISSUE_STATE" = "CLOSED" ]; then
914
+ echo " #$ISSUE_NUM ✓ terminal ($ISSUE_LABEL_STR)"
915
+ else
916
+ echo " #$ISSUE_NUM ⚠ non-terminal ($ISSUE_LABEL_STR)"
917
+ STALLED_ISSUES+=("$ISSUE_NUM")
918
+ fi
919
+ done
424
920
  fi
425
921
  else
426
922
  if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
@@ -469,6 +965,12 @@ while true; do
469
965
  done
470
966
 
471
967
  echo "Fast lane complete: $FAST_LANE_ITERATIONS iteration(s), $FAST_LANE_DEPLOYS deploy(s)"
968
+
969
+ fi # end SKIP_FAST_LANE guard
970
+
971
+ # Phase 2 complete — record phase marker
972
+ PHASE_MARKERS_COMMITTED="${PHASE_MARKERS_COMMITTED:+$PHASE_MARKERS_COMMITTED,}fast-lane"
973
+ echo "Phase 2 complete. Committed phases: $PHASE_MARKERS_COMMITTED"
472
974
  ```
473
975
 
474
976
  ---
@@ -479,10 +981,16 @@ echo "Fast lane complete: $FAST_LANE_ITERATIONS iteration(s), $FAST_LANE_DEPLOYS
479
981
 
480
982
  **Each milestone cycle**: orchestrate all open issues in the milestone → ship milestone branch to staging → deploy staging to main.
481
983
 
984
+ **Resume guard**: If `SKIP_MILESTONE=true` (set in Phase 0H — milestone loop was already committed in the interrupted cycle), skip Phase 3 entirely and proceed to Phase 4.
985
+
482
986
  ```bash
483
987
  MILESTONE_ITERATIONS=0
484
988
  MILESTONE_DEPLOYS=0
485
989
 
990
+ if [ "$SKIP_MILESTONE" = "true" ]; then
991
+ echo "RESUME: Phase 3 (milestone) already committed in cycle $CYCLE_ID — skipping"
992
+ else
993
+
486
994
  echo "=== Milestone Loop ==="
487
995
 
488
996
  # Get all open milestones with completion percentages
@@ -503,7 +1011,9 @@ echo "Found $MILESTONE_COUNT milestone(s)"
503
1011
  echo "$MILESTONES" | jq -r '.[] | "\(.completion_pct)% — \(.title) (\(.open_issues) open, \(.closed_issues) closed)"'
504
1012
 
505
1013
  # Process each milestone — sorted by completion% descending, skip 0%
506
- echo "$MILESTONES" | jq -c '.[]' | while IFS= read -r milestone; do
1014
+ # Process substitution (not a pipe) so DISPATCHED_ISSUES+=() appends in the loop
1015
+ # body run in THIS shell, not a subshell, and survive for the Phase 4 report.
1016
+ while IFS= read -r milestone; do
507
1017
  MS_TITLE=$(echo "$milestone" | jq -r '.title')
508
1018
  MS_SLUG=$(echo "$milestone" | jq -r '.slug')
509
1019
  MS_OPEN=$(echo "$milestone" | jq -r '.open_issues')
@@ -537,15 +1047,129 @@ echo "$MILESTONES" | jq -c '.[]' | while IFS= read -r milestone; do
537
1047
  2>/dev/null || echo '')
538
1048
 
539
1049
  for ISSUE_NUM in $MS_ISSUE_NUMS; do
1050
+ # Autonomy policy gate — same approve map as Phase 2 fast lane.
1051
+ # Only applies when --fix is set; without --fix all issues flow normally.
1052
+ if [ "$AUTOPILOT_FIX" = "true" ]; then
1053
+ ISSUE_PRIORITY_LABEL=$(gh issue view "$ISSUE_NUM" $GH_FLAG \
1054
+ --json labels --jq '.labels | map(.name) | map(select(startswith("priority:"))) | .[0] // ""' \
1055
+ 2>/dev/null || echo '')
1056
+ PRIORITY_KEY=$(echo "$ISSUE_PRIORITY_LABEL" | sed 's/priority://;s/P/p/' | tr '[:upper:]' '[:lower:]')
1057
+ [ -z "$PRIORITY_KEY" ] && PRIORITY_KEY="p2"
1058
+
1059
+ case "$PRIORITY_KEY" in
1060
+ p0) APPROVE_ACTION="$AUTOPILOT_APPROVE_P0" ;;
1061
+ p1) APPROVE_ACTION="$AUTOPILOT_APPROVE_P1" ;;
1062
+ p2) APPROVE_ACTION="$AUTOPILOT_APPROVE_P2" ;;
1063
+ p3) APPROVE_ACTION="$AUTOPILOT_APPROVE_P3" ;;
1064
+ *) APPROVE_ACTION="needs-human" ;;
1065
+ esac
1066
+
1067
+ if [ "$APPROVE_ACTION" = "auto" ]; then
1068
+ if [ "$BUDGET_PER_CYCLE_FIXES" != "null" ] && [ -n "$BUDGET_PER_CYCLE_FIXES" ]; then
1069
+ if [ "$FIX_COUNT" -ge "$BUDGET_PER_CYCLE_FIXES" ]; then
1070
+ echo " #$ISSUE_NUM deferred (budget ceiling: $FIX_COUNT/$BUDGET_PER_CYCLE_FIXES fixes dispatched)"
1071
+ POLICY_DEFERRED_ISSUES+=("$ISSUE_NUM")
1072
+ continue
1073
+ fi
1074
+ fi
1075
+ fi
1076
+
1077
+ if [ "$APPROVE_ACTION" = "auto" ]; then
1078
+ echo " #$ISSUE_NUM approved-by-policy ($PRIORITY_KEY → auto) — dispatching"
1079
+ POLICY_APPROVED_ISSUES+=("$ISSUE_NUM")
1080
+ FIX_COUNT=$((FIX_COUNT + 1))
1081
+ else
1082
+ echo " #$ISSUE_NUM gated-by-policy ($PRIORITY_KEY → needs-human) — posting plan to ops issue #$OPS_ISSUE_NUMBER"
1083
+ POLICY_GATED_ISSUES+=("$ISSUE_NUM")
1084
+ ISSUE_TITLE=$(gh issue view "$ISSUE_NUM" $GH_FLAG --json title --jq '.title' 2>/dev/null || echo "#$ISSUE_NUM")
1085
+ if [ -n "$OPS_ISSUE_NUMBER" ] && [ "$OPS_ISSUE_NUMBER" != "0" ]; then
1086
+ gh issue comment "$OPS_ISSUE_NUMBER" $GH_FLAG --body "<!-- FORGE:AUTOPILOT_GATE -->
1087
+ ## Gated Fix — Requires Human Approval
1088
+
1089
+ **Issue**: #${ISSUE_NUM} — ${ISSUE_TITLE}
1090
+ **Priority**: \`${ISSUE_PRIORITY_LABEL:-unlabeled}\`
1091
+ **Policy**: \`autopilot.approve.${PRIORITY_KEY} = needs-human\`
1092
+ **Action needed**: Review #${ISSUE_NUM} and either approve for autonomous dispatch or resolve manually.
1093
+
1094
+ To approve: remove \`needs-human\` label from #${ISSUE_NUM} and re-run \`/autopilot --fix\`, OR change \`autopilot.approve.${PRIORITY_KEY}\` to \`auto\` in forge.yaml.
1095
+
1096
+ <!-- FORGE:AUTOPILOT_GATE:COMPLETE -->" 2>/dev/null || true
1097
+ fi
1098
+ continue # skip dispatch for gated issues
1099
+ fi
1100
+ fi
1101
+
540
1102
  echo "Dispatching #$ISSUE_NUM via forgedock run-issue --lane milestone/$MS_SLUG"
1103
+ DISPATCHED_ISSUES+=("$ISSUE_NUM")
541
1104
  forgedock run-issue "$ISSUE_NUM" --lane "milestone/$MS_SLUG" &
542
1105
  done
543
1106
 
544
1107
  wait
545
1108
  echo "Engine dispatch complete for milestone '$MS_TITLE'"
1109
+
1110
+ # Terminal-state verification sweep for milestone batch.
1111
+ echo "Verifying terminal state for $( echo "$MS_ISSUE_NUMS" | wc -w | tr -d ' ') dispatched milestone issue(s)..."
1112
+ for ISSUE_NUM in $MS_ISSUE_NUMS; do
1113
+ ISSUE_LABELS=$(gh issue view "$ISSUE_NUM" $GH_FLAG \
1114
+ --json labels,state \
1115
+ --jq '(.labels | map(.name) | join(",")) + "|" + .state' \
1116
+ 2>/dev/null || echo "unknown|unknown")
1117
+ ISSUE_LABEL_STR="${ISSUE_LABELS%%|*}"
1118
+ ISSUE_STATE="${ISSUE_LABELS##*|}"
1119
+
1120
+ if echo "$ISSUE_LABEL_STR" | grep -qE 'workflow:merged|workflow:invalid|needs-human' || [ "$ISSUE_STATE" = "CLOSED" ]; then
1121
+ echo " #$ISSUE_NUM ✓ terminal ($ISSUE_LABEL_STR)"
1122
+ elif echo "$ISSUE_LABEL_STR" | grep -q 'workflow:in-review'; then
1123
+ echo " #$ISSUE_NUM ⟳ stalled at workflow:in-review — resuming via recover-orphans"
1124
+ if [ "$RECOVER_ORPHANS_AVAILABLE" = "true" ]; then
1125
+ Skill("recover-orphans", args="--issue $ISSUE_NUM")
1126
+ else
1127
+ echo " WARNING: recover-orphans not installed — cannot auto-resume #$ISSUE_NUM"
1128
+ STALLED_ISSUES+=("$ISSUE_NUM")
1129
+ fi
1130
+ else
1131
+ echo " #$ISSUE_NUM ⚠ non-terminal ($ISSUE_LABEL_STR) — will surface in Phase 4 report"
1132
+ STALLED_ISSUES+=("$ISSUE_NUM")
1133
+ fi
1134
+ done
546
1135
  else
547
1136
  echo "INFO: Using agent dispatch mode (forgedock CLI not in PATH — run \`npm install -g forgedock\` for engine-mode dispatch)"
1137
+ # Safety Rule 2 (needs-human guarantee): See fast-lane fallback comment above.
1138
+ # /orchestrate enforces the needs-human exclusion at its own phase entry.
1139
+
1140
+ # Snapshot milestone issue list before orchestrate so we can verify afterward.
1141
+ MS_PRE_DISPATCH=$(gh issue list $GH_FLAG \
1142
+ --state open \
1143
+ --limit 200 \
1144
+ --json number,milestone,labels \
1145
+ --jq --arg slug "$MS_SLUG" \
1146
+ '[.[] | select(
1147
+ .milestone != null and
1148
+ (.milestone.title | ascii_downcase | gsub("[^a-z0-9]+"; "-") | ltrimstr("-") | rtrimstr("-")) == $slug and
1149
+ (.labels | map(.name) | any(. == "workflow:merged" or . == "workflow:invalid" or . == "workflow:decomposed" or . == "needs-human") | not)
1150
+ )] | .[].number' \
1151
+ 2>/dev/null || echo '')
1152
+ for N in $MS_PRE_DISPATCH; do DISPATCHED_ISSUES+=("$N"); done
1153
+
548
1154
  Skill("orchestrate", args="milestone $MS_SLUG")
1155
+
1156
+ # Terminal-state verification after orchestrate fallback.
1157
+ echo "Verifying terminal state for $( echo "$MS_PRE_DISPATCH" | wc -w | tr -d ' ') dispatched milestone issue(s) (orchestrate path)..."
1158
+ for ISSUE_NUM in $MS_PRE_DISPATCH; do
1159
+ ISSUE_LABELS=$(gh issue view "$ISSUE_NUM" $GH_FLAG \
1160
+ --json labels,state \
1161
+ --jq '(.labels | map(.name) | join(",")) + "|" + .state' \
1162
+ 2>/dev/null || echo "unknown|unknown")
1163
+ ISSUE_LABEL_STR="${ISSUE_LABELS%%|*}"
1164
+ ISSUE_STATE="${ISSUE_LABELS##*|}"
1165
+
1166
+ if echo "$ISSUE_LABEL_STR" | grep -qE 'workflow:merged|workflow:invalid|needs-human' || [ "$ISSUE_STATE" = "CLOSED" ]; then
1167
+ echo " #$ISSUE_NUM ✓ terminal ($ISSUE_LABEL_STR)"
1168
+ else
1169
+ echo " #$ISSUE_NUM ⚠ non-terminal ($ISSUE_LABEL_STR)"
1170
+ STALLED_ISSUES+=("$ISSUE_NUM")
1171
+ fi
1172
+ done
549
1173
  fi
550
1174
  else
551
1175
  if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
@@ -602,25 +1226,212 @@ echo "$MILESTONES" | jq -c '.[]' | while IFS= read -r milestone; do
602
1226
  --jq '[.[] | select(.milestone == null and (.labels | map(.name) | any(. == "review-finding")))] | length' \
603
1227
  2>/dev/null || echo '0')
604
1228
  [ "$MS_FINDINGS" -gt 0 ] && echo "NOTE: $MS_FINDINGS review finding(s) now in fast lane — processed next cycle"
605
- done
1229
+ done < <(echo "$MILESTONES" | jq -c '.[]')
606
1230
 
607
1231
  echo "Milestone loop complete: $MILESTONE_ITERATIONS milestone(s) processed, $MILESTONE_DEPLOYS milestone ship(s)"
1232
+
1233
+ fi # end SKIP_MILESTONE guard
1234
+
1235
+ # Phase 3 complete — record phase marker
1236
+ PHASE_MARKERS_COMMITTED="${PHASE_MARKERS_COMMITTED:+$PHASE_MARKERS_COMMITTED,}milestone"
1237
+ echo "Phase 3 complete. Committed phases: $PHASE_MARKERS_COMMITTED"
608
1238
  ```
609
1239
 
610
1240
  ---
611
1241
 
612
- ## Phase 4: Final Report
1242
+ ## Phase 4: Final Report and Durable Cycle Annotation
613
1243
 
614
- Print a summary of everything done in this autopilot run:
1244
+ Write the machine-readable `FORGE:AUTOPILOT_CYCLE` annotation to the ops issue, then print the human-readable terminal summary. The annotation is written FIRST so that partial-report crashes do not leave a terminal-appearing state without the durable record.
1245
+
1246
+ ### 4A: Collect final metrics and write FORGE:AUTOPILOT_CYCLE annotation
615
1247
 
616
1248
  ```bash
617
1249
  CYCLE_END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
1250
+ CYCLE_START_TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) # best-effort — pipeline start is earlier
618
1251
 
619
1252
  # Re-check final open issue count
620
1253
  FINAL_OPEN=$(gh issue list $GH_FLAG --state open --limit 200 --json number --jq 'length' 2>/dev/null || echo '0')
621
1254
  FINAL_FAST_LANE=$(gh issue list $GH_FLAG --state open --limit 200 --json number,milestone \
622
1255
  --jq '[.[] | select(.milestone == null)] | length' 2>/dev/null || echo '0')
1256
+ FINAL_CI_FAILURES="${RECENT_FAILURES:-0}"
1257
+ FINAL_PENDING_FINDINGS=$(gh issue list $GH_FLAG --state open --label "review-finding" --limit 200 --json number --jq 'length' 2>/dev/null || echo '0')
1258
+
1259
+ # Phase 4 complete — record phase marker (before annotation write so it's in the sentinel block)
1260
+ PHASE_MARKERS_COMMITTED="${PHASE_MARKERS_COMMITTED:+$PHASE_MARKERS_COMMITTED,}report"
1261
+
1262
+ # Build baseline JSON for this cycle (consumed by cycle N+1's Phase 0G)
1263
+ BASELINE_JSON="{\"open_issues\":${FINAL_OPEN},\"ci_failures\":${FINAL_CI_FAILURES},\"pending_findings\":${FINAL_PENDING_FINDINGS}}"
1264
+
1265
+ # Build findings summary (recon-created issue numbers)
1266
+ FINDINGS_SUMMARY="${#RECON_ISSUES[@]:-0} recon issue(s) created"
1267
+ if [ "${#RECON_ISSUES[@]}" -gt 0 ]; then
1268
+ FINDINGS_SUMMARY="$FINDINGS_SUMMARY: ${RECON_ISSUES[*]}"
1269
+ fi
1270
+
1271
+ # Build delta section vs prior cycle
1272
+ DELTA_LINES=""
1273
+ if [ -n "$PREV_CYCLE_ID" ]; then
1274
+ if [ -n "$PREV_CYCLE_OPEN_ISSUES" ]; then
1275
+ DELTA_OPEN=$((FINAL_OPEN - PREV_CYCLE_OPEN_ISSUES))
1276
+ DELTA_SIGN=$( [ "$DELTA_OPEN" -lt 0 ] && echo "" || echo "+")
1277
+ DELTA_LINES="${DELTA_LINES}- Open issues: $PREV_CYCLE_OPEN_ISSUES → $FINAL_OPEN (${DELTA_SIGN}${DELTA_OPEN})\n"
1278
+ fi
1279
+ if [ -n "$PREV_CYCLE_CI_FAILURES" ]; then
1280
+ DELTA_CI=$((FINAL_CI_FAILURES - PREV_CYCLE_CI_FAILURES))
1281
+ DELTA_CI_SIGN=$( [ "$DELTA_CI" -lt 0 ] && echo "" || echo "+")
1282
+ DELTA_LINES="${DELTA_LINES}- CI failures: $PREV_CYCLE_CI_FAILURES → $FINAL_CI_FAILURES (${DELTA_CI_SIGN}${DELTA_CI})\n"
1283
+ fi
1284
+ if [ -n "$PREV_CYCLE_PENDING_FINDINGS" ]; then
1285
+ DELTA_PF=$((FINAL_PENDING_FINDINGS - PREV_CYCLE_PENDING_FINDINGS))
1286
+ DELTA_PF_SIGN=$( [ "$DELTA_PF" -lt 0 ] && echo "" || echo "+")
1287
+ DELTA_LINES="${DELTA_LINES}- Pending findings: $PREV_CYCLE_PENDING_FINDINGS → $FINAL_PENDING_FINDINGS (${DELTA_PF_SIGN}${DELTA_PF})\n"
1288
+ fi
1289
+ DELTA_SECTION="### Deltas vs Cycle ${PREV_CYCLE_ID}\n$(printf '%b' "$DELTA_LINES")"
1290
+ else
1291
+ DELTA_SECTION="### Deltas vs Prior Cycle\nNo prior complete cycle found — this is the baseline cycle."
1292
+ fi
1293
+
1294
+ # Write FORGE:AUTOPILOT_CYCLE annotation to the ops issue (machine-readable durable record)
1295
+ if [ -n "$OPS_ISSUE_NUMBER" ] && [ "$OPS_ISSUE_NUMBER" != "0" ] && [ "$DRY_RUN" = "false" ]; then
1296
+ gh issue comment "$OPS_ISSUE_NUMBER" $GH_FLAG --body "<!-- FORGE:AUTOPILOT_CYCLE -->
1297
+ ## Autopilot Cycle — ${CYCLE_ID}
1298
+
1299
+ **cycle_id**: ${CYCLE_ID}
1300
+ **timestamp**: ${CYCLE_END}
1301
+ **baseline**: ${BASELINE_JSON}
1302
+ **phase_markers**: ${PHASE_MARKERS_COMMITTED}
1303
+
1304
+ $(printf '%b' "$DELTA_SECTION")
1305
+
1306
+ ### Findings Created This Cycle
1307
+ ${FINDINGS_SUMMARY}
1308
+
1309
+ ### Fast Lane
1310
+ - Iterations: ${FAST_LANE_ITERATIONS:-0}
1311
+ - Deploys (staging→${DEFAULT_BRANCH}): ${FAST_LANE_DEPLOYS:-0}
1312
+ - Issues dispatched: ${#DISPATCHED_ISSUES[@]:-0}
1313
+
1314
+ ### Milestone Loop
1315
+ - Milestones processed: ${MILESTONE_ITERATIONS:-0}
1316
+ - Milestone ships: ${MILESTONE_DEPLOYS:-0}
1317
+
1318
+ ### Autonomy Policy Decisions
1319
+ - Fix mode: ${AUTOPILOT_FIX} (headless: ${AUTOPILOT_HEADLESS})
1320
+ - Approved by policy: ${#POLICY_APPROVED_ISSUES[@]:-0} issue(s)$([ "${#POLICY_APPROVED_ISSUES[@]:-0}" -gt 0 ] && echo " — ${POLICY_APPROVED_ISSUES[*]}" || echo "")
1321
+ - Gated (needs-human): ${#POLICY_GATED_ISSUES[@]:-0} issue(s)$([ "${#POLICY_GATED_ISSUES[@]:-0}" -gt 0 ] && echo " — ${POLICY_GATED_ISSUES[*]}" || echo "")
1322
+ - Deferred (budget ceiling): ${#POLICY_DEFERRED_ISSUES[@]:-0} issue(s)$([ "${#POLICY_DEFERRED_ISSUES[@]:-0}" -gt 0 ] && echo " — ${POLICY_DEFERRED_ISSUES[*]}" || echo "")
1323
+ - Budget used: ${FIX_COUNT:-0} / ${BUDGET_PER_CYCLE_FIXES:-unlimited}
1324
+ - Approve map: {p0:${AUTOPILOT_APPROVE_P0},p1:${AUTOPILOT_APPROVE_P1},p2:${AUTOPILOT_APPROVE_P2},p3:${AUTOPILOT_APPROVE_P3}}
1325
+
1326
+ ### Final State
1327
+ - Open issues: ${FINAL_OPEN} total (${FINAL_FAST_LANE} unmilestoned)
1328
+ - CI failures (24h): ${FINAL_CI_FAILURES}
1329
+ - Pending review findings: ${FINAL_PENDING_FINDINGS}
1330
+
1331
+ <!-- FORGE:AUTOPILOT_CYCLE:COMPLETE -->" 2>/dev/null \
1332
+ && echo "FORGE:AUTOPILOT_CYCLE annotation written to ops issue #$OPS_ISSUE_NUMBER" \
1333
+ || echo "WARNING: Failed to write FORGE:AUTOPILOT_CYCLE annotation — ops issue #$OPS_ISSUE_NUMBER may be inaccessible"
1334
+ elif [ "$DRY_RUN" = "true" ]; then
1335
+ echo "[DRY-RUN] Would write FORGE:AUTOPILOT_CYCLE annotation to ops issue #$OPS_ISSUE_NUMBER"
1336
+ echo "[DRY-RUN] cycle_id=$CYCLE_ID baseline=$BASELINE_JSON phase_markers=$PHASE_MARKERS_COMMITTED"
1337
+ echo "[DRY-RUN] policy: fix=$AUTOPILOT_FIX headless=$AUTOPILOT_HEADLESS approved=${#POLICY_APPROVED_ISSUES[@]:-0} gated=${#POLICY_GATED_ISSUES[@]:-0} deferred=${#POLICY_DEFERRED_ISSUES[@]:-0} budget_used=$FIX_COUNT/$BUDGET_PER_CYCLE_FIXES"
1338
+ fi
1339
+ ```
1340
+
1341
+ ### 4B: Recurrence detection
1342
+
1343
+ After writing the current cycle annotation, fetch the last 3 COMPLETE cycle annotations from the ops issue. If the same recon-created issue title prefix appears in ALL 3, create a meta-issue citing the cycle annotations — implementing the recurrence detection claim.
623
1344
 
1345
+ ```bash
1346
+ # Recurrence detection — only run if there are recon findings this cycle and ops issue exists
1347
+ if [ "${#RECON_ISSUES[@]:-0}" -gt 0 ] && [ -n "$OPS_ISSUE_NUMBER" ] && [ "$OPS_ISSUE_NUMBER" != "0" ]; then
1348
+ # Fetch last 3 complete AUTOPILOT_CYCLE annotations (now includes the one we just wrote)
1349
+ LAST_3_CYCLES=$(gh api repos/${GH_REPO}/issues/${OPS_ISSUE_NUMBER}/comments \
1350
+ --jq '[.[] | select(.body | contains("FORGE:AUTOPILOT_CYCLE:COMPLETE"))] | .[-3:] | .[].body' \
1351
+ 2>/dev/null || echo '')
1352
+
1353
+ CYCLE_COUNT=$(echo "$LAST_3_CYCLES" | grep -c "FORGE:AUTOPILOT_CYCLE:COMPLETE" || echo '0')
1354
+
1355
+ if [ "${CYCLE_COUNT:-0}" -ge 3 ]; then
1356
+ # Check each recon finding title prefix against all 3 cycle annotations
1357
+ for ISSUE_NUM in "${RECON_ISSUES[@]}"; do
1358
+ FINDING_TITLE=$(gh issue view "$ISSUE_NUM" $GH_FLAG --json title --jq '.title' 2>/dev/null | cut -c1-60 || echo '')
1359
+ [ -z "$FINDING_TITLE" ] && continue
1360
+
1361
+ # Extract the first 30 chars as a pattern (enough to distinguish issues without being too strict)
1362
+ FINDING_PATTERN=$(echo "$FINDING_TITLE" | cut -c1-30)
1363
+
1364
+ # Count how many of the 3 cycles contain this pattern in their findings section
1365
+ MATCH_COUNT=$(echo "$LAST_3_CYCLES" | grep -cF "$FINDING_PATTERN" || echo '0')
1366
+
1367
+ if [ "${MATCH_COUNT:-0}" -ge 3 ]; then
1368
+ echo "RECURRENCE DETECTED: '$FINDING_PATTERN' appeared in all 3 recent cycles — creating meta-issue"
1369
+
1370
+ # Extract cycle IDs from the last 3 annotations for citation
1371
+ CITING_CYCLES=$(echo "$LAST_3_CYCLES" \
1372
+ | grep -oP '(?<=\*\*cycle_id\*\*: )[^\n]+' | tr -d '[:space:]' | tr '\n' ', ' | sed 's/,$//')
1373
+
1374
+ # Dedup check — avoid creating a duplicate meta-issue for the same pattern
1375
+ EXISTING_META=$(gh issue list $GH_FLAG --state open \
1376
+ --search "meta: recurring autopilot finding $FINDING_PATTERN" \
1377
+ --limit 1 --json number --jq '.[0].number // empty' 2>/dev/null || echo '')
1378
+
1379
+ if [ -z "$EXISTING_META" ] && [ "$DRY_RUN" = "false" ]; then
1380
+ gh issue create $GH_FLAG \
1381
+ --title "meta: recurring autopilot finding — $FINDING_PATTERN" \
1382
+ --label "priority:P2" \
1383
+ --body "$(cat <<META_EOF
1384
+ ## Problem
1385
+
1386
+ The following finding pattern has appeared in **3 or more consecutive /autopilot cycles**, indicating a systemic issue rather than a one-off occurrence.
1387
+
1388
+ **Pattern**: \`${FINDING_PATTERN}\`
1389
+ **Detected in cycles**: ${CITING_CYCLES}
1390
+
1391
+ This meta-issue is created by /autopilot recurrence detection (Phase 4B) when the same finding pattern appears in 3 consecutive complete cycle annotations on the ops issue (#${OPS_ISSUE_NUMBER}).
1392
+
1393
+ ## Root Cause (if known)
1394
+
1395
+ Root cause unknown — the recurrent nature suggests either:
1396
+ 1. The underlying issue is not being fixed by the regular work-on pipeline (check if prior issues for this pattern were closed with workflow:invalid or blocked)
1397
+ 2. A systemic condition keeps re-triggering the finding (e.g., a flaky CI step, a recurring deployment error)
1398
+ 3. The issue is being created and merged but the fix does not hold (regression)
1399
+
1400
+ ## Affected Files
1401
+
1402
+ Files to be identified during investigation.
1403
+
1404
+ ## Acceptance Criteria
1405
+
1406
+ - [ ] Root cause of recurrence identified
1407
+ - [ ] Fix confirmed: pattern does not appear in 3 subsequent /autopilot cycles after merge
1408
+ - [ ] Prior per-cycle issues for this pattern are closed with explanation
1409
+
1410
+ ## Context
1411
+
1412
+ **Ops issue (cycle annotations)**: #${OPS_ISSUE_NUMBER}
1413
+ **Citing cycles**: ${CITING_CYCLES}
1414
+ **Detected by**: /autopilot Phase 4B recurrence detection
1415
+ META_EOF
1416
+ )" \
1417
+ --json number --jq '"Created meta-issue #\(.number) for recurrent pattern: $ENV.FINDING_PATTERN"' \
1418
+ 2>/dev/null || echo "WARNING: Failed to create meta-issue for recurrence pattern: $FINDING_PATTERN"
1419
+ elif [ -n "$EXISTING_META" ]; then
1420
+ echo "Recurrence meta-issue already exists (#$EXISTING_META) for pattern '$FINDING_PATTERN' — skipping"
1421
+ elif [ "$DRY_RUN" = "true" ]; then
1422
+ echo "[DRY-RUN] Would create meta-issue for recurrent pattern: $FINDING_PATTERN (cycles: $CITING_CYCLES)"
1423
+ fi
1424
+ fi
1425
+ done
1426
+ else
1427
+ echo "Recurrence check: only $CYCLE_COUNT complete cycle(s) on record — need 3 for detection (will activate after more cycles)"
1428
+ fi
1429
+ fi
1430
+ ```
1431
+
1432
+ ### 4C: Human-readable terminal summary
1433
+
1434
+ ```bash
624
1435
  echo ""
625
1436
  echo "╔═══════════════════════════════════════════════════╗"
626
1437
  echo "║ Autopilot Complete ║"
@@ -630,6 +1441,9 @@ echo "║ ║"
630
1441
  cat <<REPORT
631
1442
  ## Autopilot Cycle Report — $CYCLE_END
632
1443
 
1444
+ **Cycle ID**: ${CYCLE_ID}
1445
+ **Ops Issue**: #${OPS_ISSUE_NUMBER} (FORGE:AUTOPILOT_CYCLE annotation written)
1446
+
633
1447
  ### Pipeline State at Start
634
1448
  - staging→main PR: ${STAGING_PR_NUMBER:-none}
635
1449
  - In-flight issues recovered: $INFLIGHT_ISSUES
@@ -641,6 +1455,10 @@ cat <<REPORT
641
1455
  - Recurring failures: ${RECURRING:-none}
642
1456
  - Issues created from recon: ${#RECON_ISSUES[@]:-0}
643
1457
 
1458
+ ### Hygiene
1459
+ - Orphaned issues recovered (/recover-orphans): $INFLIGHT_ISSUES
1460
+ - Label hygiene sweep (/cleanup labels): $([ "${CLEANUP_LABELS_FIXED:-0}" -eq 1 ] && echo "ran — see /cleanup output for counts" || echo "skipped (not installed or dry-run)")
1461
+
644
1462
  ### Fast Lane
645
1463
  - Iterations: $FAST_LANE_ITERATIONS
646
1464
  - Deploys (staging→$DEFAULT_BRANCH): $FAST_LANE_DEPLOYS
@@ -650,10 +1468,57 @@ cat <<REPORT
650
1468
  - Milestones processed: $MILESTONE_ITERATIONS
651
1469
  - Milestone ships: $MILESTONE_DEPLOYS
652
1470
 
1471
+ ### Autonomy Policy
1472
+ - Fix mode: $AUTOPILOT_FIX (headless: $AUTOPILOT_HEADLESS)
1473
+ - Approved by policy: ${#POLICY_APPROVED_ISSUES[@]:-0} issue(s)
1474
+ - Gated (needs-human, posted to ops issue): ${#POLICY_GATED_ISSUES[@]:-0} issue(s)
1475
+ - Deferred (budget ceiling hit): ${#POLICY_DEFERRED_ISSUES[@]:-0} issue(s)
1476
+ - Budget used: ${FIX_COUNT:-0} / ${BUDGET_PER_CYCLE_FIXES:-unlimited}
1477
+
653
1478
  ### Final State
654
1479
  - Open issues: $FINAL_OPEN total ($FINAL_FAST_LANE unmilestoned)
655
1480
  REPORT
656
1481
 
1482
+ # Per-issue terminal-state disposition table (GitHub-verified).
1483
+ # Sources DISPATCHED_ISSUES accumulated across all dispatch paths this cycle.
1484
+ # This table is the canonical record of what autopilot dispatched and what state each reached.
1485
+ if [ "${#DISPATCHED_ISSUES[@]}" -gt 0 ]; then
1486
+ echo ""
1487
+ echo "### Dispatch Disposition (GitHub-verified terminal state)"
1488
+ echo ""
1489
+ echo "| Issue | Title (50 chars) | GitHub Label | Terminal? |"
1490
+ echo "|-------|-----------------|-------------|-----------|"
1491
+ DISPATCH_TERMINAL_COUNT=0
1492
+ DISPATCH_STALLED_COUNT=0
1493
+ for ISSUE_NUM in "${DISPATCHED_ISSUES[@]}"; do
1494
+ ISSUE_DATA=$(gh issue view "$ISSUE_NUM" $GH_FLAG \
1495
+ --json number,title,labels,state \
1496
+ --jq '{n: .number, title: (.title | .[0:50]), labels: (.labels | map(.name) | join(",")), state: .state}' \
1497
+ 2>/dev/null || echo "{\"n\":$ISSUE_NUM,\"title\":\"(fetch failed)\",\"labels\":\"unknown\",\"state\":\"unknown\"}")
1498
+ DISP_LABELS=$(echo "$ISSUE_DATA" | jq -r '.labels // "unknown"')
1499
+ DISP_STATE=$(echo "$ISSUE_DATA" | jq -r '.state // "unknown"')
1500
+ DISP_TITLE=$(echo "$ISSUE_DATA" | jq -r '.title // "(unknown)"')
1501
+ if echo "$DISP_LABELS" | grep -qE 'workflow:merged|workflow:invalid|needs-human' || [ "$DISP_STATE" = "CLOSED" ]; then
1502
+ echo "| #$ISSUE_NUM | $DISP_TITLE | \`$DISP_LABELS\` | ✓ yes |"
1503
+ DISPATCH_TERMINAL_COUNT=$((DISPATCH_TERMINAL_COUNT + 1))
1504
+ else
1505
+ echo "| #$ISSUE_NUM | $DISP_TITLE | \`$DISP_LABELS\` | ✗ no — stalled |"
1506
+ DISPATCH_STALLED_COUNT=$((DISPATCH_STALLED_COUNT + 1))
1507
+ fi
1508
+ done
1509
+ echo ""
1510
+ echo "**Summary**: ${DISPATCH_TERMINAL_COUNT}/${#DISPATCHED_ISSUES[@]} dispatched issues reached terminal state. ${DISPATCH_STALLED_COUNT} stalled."
1511
+ if [ "$DISPATCH_STALLED_COUNT" -gt 0 ]; then
1512
+ echo ""
1513
+ echo "⚠ Stalled issues re-enter the fast-lane queue on the next /autopilot run."
1514
+ echo " Persistent stalls (3+ cycles): gh issue list $GH_FLAG --label workflow:building"
1515
+ fi
1516
+ else
1517
+ echo ""
1518
+ echo "### Dispatch Disposition"
1519
+ echo "No issues dispatched this cycle."
1520
+ fi
1521
+
657
1522
  if [ "$FINAL_FAST_LANE" -eq 0 ]; then
658
1523
  echo ""
659
1524
  echo "Zero open unmilestoned issues remain. Pipeline is clean."
@@ -730,15 +1595,31 @@ done
730
1595
 
731
1596
  ## Safety Rules
732
1597
 
733
- 1. **Overrides "never merge to main"** — `/autopilot` IS the authorized deploy system. staging→main is normal operation.
734
- 2. **Never skip investigation** — all issues go through full `/work-on` pipeline (via /orchestrate) before any fix lands.
735
- 3. **Never process `needs-human` issues** — /orchestrate and /work-on skip these automatically.
736
- 4. **Never create duplicate issues** — always dedup against existing open issues before creating.
737
- 5. **Loop safety cap** — max 20 fast-lane iterations prevents infinite loops on stuck state.
738
- 6. **DRY_RUN means NO side effects** — no issues created, no PRs merged, no labels changed. Report only.
739
- 7. **Graceful sub-skill degradation** — if /recover-orphans is not installed, log a warning and continue.
740
- 8. **State always re-read** — never trust a cached issue count. Re-query GitHub at each loop iteration.
741
- 9. **deploy-pr result is authoritative** — if status is not "merged", do not assume the deploy succeeded. Log and continue the loop.
1598
+ Rules are listed in **precedence order** — when two rules appear to conflict, the rule with the lower number wins. Rule 0 is inviolable; no other rule can override it.
1599
+
1600
+ **Rule 0 (INVIOLABLE): `--dry-run` means NO side effects.** When `DRY_RUN=true`, autopilot produces a report and exits. No issues are created, no PRs are merged, no labels are changed, no sub-skills with side effects are invoked. This holds regardless of priority (P0, P1, or any other), regardless of loop state, and regardless of what any other rule says. Every dispatch block in this spec checks `DRY_RUN` before acting. If a code path appears to conflict with this rule, `DRY_RUN` wins.
1601
+
1602
+ **Rule 1: `/autopilot` overrides "never merge to main"** — autopilot IS the authorized deploy system. staging→main is normal operation in Phases 2 and 3.
1603
+
1604
+ **Rule 2: Never process `needs-human` issues.** Issues labeled `needs-human` must never be autonomously worked on, regardless of priority. The engine-first dispatch path (Phase 2, Step 1 and Phase 3, Step 1) enforces this with an explicit jq filter. The /orchestrate fallback path delegates this guarantee to /orchestrate and /work-on, which skip `needs-human` issues at their own phase entry. A P0 issue labeled `needs-human` — meaning a human explicitly parked it as requiring human judgment — is reported in the Phase 1C recon output but never added to any dispatch queue. Rule 0 (dry-run) takes precedence over this rule: a dry-run run reports the `needs-human` P0 and takes no action.
1605
+
1606
+ **Rule 3: Never skip investigation** — all issues go through the full `/work-on` pipeline (via /orchestrate or the durable engine) before any fix lands.
1607
+
1608
+ **Rule 4: Never create duplicate issues** — always dedup against existing open issues before creating.
1609
+
1610
+ **Rule 5: Loop safety cap** — max 20 fast-lane iterations prevents infinite loops on stuck state.
1611
+
1612
+ **Rule 6: Autopilot never directly mutates issues or labels.** All issue close and workflow label changes must route through `/cleanup` (for label hygiene) or `/recover-orphans` (for orphan recovery) — commands that verify state before acting and maintain an audit trail. Autopilot composes; it does not own destructive writes. If either command is not installed, log an informational message and continue — no bare `gh issue close` or `gh issue edit --add-label` calls are acceptable in this spec.
1613
+
1614
+ **Rule 6a: Graceful sub-skill degradation** — if /recover-orphans or /cleanup is not installed (extras tier), log an informational message and continue. The pipeline proceeds without hygiene rather than failing.
1615
+
1616
+ **Rule 7: State always re-read** — never trust a cached issue count. Re-query GitHub at each loop iteration.
1617
+
1618
+ **Rule 8: deploy-pr result is authoritative** — if status is not "merged", do not assume the deploy succeeded. Log and continue the loop.
1619
+
1620
+ **Rule 9: Terminal-state verification is mandatory after every dispatch batch.** After every `forgedock run-issue … wait` block or `Skill(orchestrate)` call, autopilot MUST query the actual GitHub label state for each dispatched issue. An issue whose process exits 0 but whose GitHub label is still `workflow:in-review` or `workflow:building` is NOT done — the close phase may have been interrupted. Stalls at `workflow:in-review` with a merged PR are the known recoverable class; autopilot resumes them via `Skill("recover-orphans", args="--issue N")` once before recording them as stalled. The Phase 4 report MUST emit a per-issue disposition table sourced from these GitHub-verified states — self-reported sub-process success is not sufficient. <!-- Added: forge#1751 -->
1621
+
1622
+ **Headless / unattended operation**: `/autopilot` has no human checkpoint and never waits for user input. When invoked via `/loop 4h /autopilot` or any other unattended runner, it runs to completion and exits. Human escalation is exclusively via the `needs-human` label (Rule 2 above) — autopilot surfaces `needs-human` issues in the recon report but never stalls waiting for a response. There is no "Phase 4B confirm before fixing" gate in the current design; that checkpoint was intentionally removed in the #1673 rewrite. If a future design adds a confirmation gate, it must be guarded by both Rule 0 (dry-run) and Rule 2 (needs-human) to remain safe.
742
1623
 
743
1624
  ---
744
1625
 
@@ -748,4 +1629,4 @@ done
748
1629
  - **Token budget**: Recon is cheap (mostly API calls). Each orchestrate+deploy cycle is expensive (full /work-on per issue via /orchestrate).
749
1630
  - **Idempotent**: Safe to run multiple times — /work-on resumes from pipeline checkpoints, /deploy-pr detects existing open PRs.
750
1631
  - **Pairs with /loop**: Run `/loop 4h /autopilot` for continuous improvement cycles.
751
- - **Event-driven complement**: `/autopilot` is a periodic loop. For targeted signal-driven response (metric regression, incident, GEO gap), use `/signal-planner` instead — it converts a specific signal into a dependency-ordered issue DAG, executes via `/orchestrate`, and verifies the originating signal is resolved after the work merges. Use `/autopilot` for scheduled sweeps; use `/signal-planner` for targeted responses.
1632
+ - **Event-driven complement**: `/autopilot` is a periodic loop. For targeted signal-driven response (metric regression, incident, CI failure spike), use `/signal-planner` instead — it converts a specific signal into a dependency-ordered issue DAG, executes via `/orchestrate`, and verifies the originating signal is resolved after the work merges. Use `/autopilot` for scheduled sweeps; use `/signal-planner` for targeted responses.