forgedock 1.1.6 → 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 (43) hide show
  1. package/README.md +3 -1
  2. package/bin/engine/invariants.mjs +361 -0
  3. package/bin/hooks/pre-tool-use.mjs +182 -0
  4. package/bin/journey.mjs +77 -7
  5. package/bin/labels.json +10 -0
  6. package/bin/recall.mjs +540 -0
  7. package/commands/autopilot.md +959 -78
  8. package/commands/cleanup.md +52 -3
  9. package/commands/optimize.md +585 -0
  10. package/commands/orchestrate/phase-1-resolve.md +35 -25
  11. package/commands/orchestrate/phase-3-dependency.md +465 -34
  12. package/commands/orchestrate/phase-4-execution.md +448 -29
  13. package/commands/orchestrate/phase-6-report.md +198 -9
  14. package/commands/pipeline-health/phase-2-metrics.md +305 -1
  15. package/commands/pipeline-health/phase-4-proposals.md +127 -0
  16. package/commands/quality-gate.md +417 -0
  17. package/commands/review-pr.md +391 -13
  18. package/commands/spec-doctor.md +429 -0
  19. package/commands/validate.md +52 -0
  20. package/commands/work-on/build/architect.md +77 -0
  21. package/commands/work-on/build/context.md +357 -22
  22. package/commands/work-on/build/implement.md +2 -0
  23. package/commands/work-on/build/validate.md +28 -0
  24. package/commands/work-on/build.md +44 -0
  25. package/commands/work-on/close.md +621 -8
  26. package/commands/work-on/investigate.md +221 -0
  27. package/commands/work-on.md +15 -14
  28. package/docs/FORGE-PROTOCOL.md +101 -0
  29. package/docs/design/awesome-list-submissions.md +105 -0
  30. package/docs/design/f5bot-monitoring-setup.md +43 -0
  31. package/docs/design/growth-strategy-2026.md +579 -0
  32. package/docs/spec/forge-protocol-v1.md +97 -5
  33. package/docs/spec/label-state-machine.md +6 -1
  34. package/package.json +2 -2
  35. package/packages/protocol/src/cli.js +374 -84
  36. package/packages/protocol/src/types.js +75 -1
  37. package/scripts/build-knowledge-index.mjs +1761 -0
  38. package/scripts/calibration.mjs +996 -0
  39. package/scripts/danger-zones.mjs +782 -0
  40. package/scripts/doctor-pipeline-state.sh +88 -2
  41. package/scripts/refresh-bot-token.sh +97 -0
  42. package/scripts/select-fix-targets.sh +234 -0
  43. package/scripts/transition-label.sh +157 -6
@@ -162,11 +162,111 @@ When file extraction yields **fewer than 2 file paths** for an issue (common for
162
162
 
163
163
  **Rationale**: The cost of a false-negative (two agents conflict → one fails with merge error, wasting the full agent run) far exceeds the cost of a false-positive (an issue waits for one predecessor before starting). Always err toward serialization when uncertain.
164
164
 
165
- #### Layer 5: Historical co-change coupling <!-- Added: forge#1196 --> <!-- Empty-set guard: forge#1206 -->
165
+ #### Layer 5: Historical co-change coupling <!-- Added: forge#1196 --> <!-- Empty-set guard: forge#1206 --> <!-- Matrix lookup: forge#1738 -->
166
166
 
167
- Layers 1-4 infer conflict risk from structure — path overlap, directory nesting, hard-coded high-fan-in lists. They miss the case where two files with no directory or naming relationship have historically changed together in the same commits (e.g. `models/user.py` and `services/billing/charge.py`), and they over-serialize the inverse case where files merely sit near each other but have never actually co-changed. Git commit history answers both questions directly and empirically. This layer reads **commit metadata only** — the list of files touched per commit — never file contents, so it does not violate Hard Rule 2 ("dispatcher, not a builder... never read code"). This is the same category of operation already established as compliant in `commands/work-on/investigate.md` and `commands/work-on/build/context.md`, which mine `git log` for issue cross-referencing.
167
+ Layers 1-4 infer conflict risk from structure — path overlap, directory nesting, hard-coded high-fan-in lists. They miss the case where two files with no directory or naming relationship have historically changed together in the same commits (e.g. `models/user.py` and `services/billing/charge.py`), and they over-serialize the inverse case where files merely sit near each other but have never actually co-changed. Git commit history answers both questions directly and empirically. This layer reads **commit metadata only** — the list of files touched per commit — never file contents, so it does not violate Hard Rule 2 ("dispatcher, not a builder... never read code").
168
168
 
169
- **Bounded query** — restrict both the time window and the file set so this stays a small, deterministic, single-shot lookup (never a full-repo co-change matrix). An empty `ALL_AFFECTED_FILES` set MUST short-circuit before the query runs — passing an empty array to `git log --` does not mean "match nothing", it means "no pathspec restriction", which would silently widen the query to the entire repo:
169
+ **Primary path — persisted co-change matrix** (O(1) lookup, repo-wide coverage): <!-- Added: forge#1738 -->
170
+
171
+ Check whether `~/.forge/index/cochange.jsonl` (produced by `scripts/danger-zones.mjs`) exists before
172
+ falling back to the live `git log` query. The matrix is repo-wide and covers file pairs outside the
173
+ current batch — providing broader coupling signal than the batch-scoped live query alone.
174
+
175
+ ```bash
176
+ COCHANGE_INDEX="${HOME}/.forge/index/cochange.jsonl"
177
+ COCHANGE_META="${HOME}/.forge/index/cochange-meta.json"
178
+
179
+ if [ -f "$COCHANGE_INDEX" ]; then
180
+ echo "Layer 5: co-change matrix found — using persisted index for pair lookups"
181
+ LAYER5_SOURCE="matrix"
182
+ TOTAL_COMMITS=$(cat "$COCHANGE_META" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('totalCommits',0))" 2>/dev/null || echo "0")
183
+ else
184
+ echo "Layer 5: co-change matrix absent — falling back to live git query (run 'node scripts/danger-zones.mjs' to build)"
185
+ LAYER5_SOURCE="live"
186
+ TOTAL_COMMITS=0
187
+ fi
188
+ ```
189
+
190
+ **Matrix lookup per file pair** (when `LAYER5_SOURCE=matrix`):
191
+
192
+ For each pair of files across issues in the batch, query the matrix for coupling verdict:
193
+
194
+ ```bash
195
+ # Query a single file pair from the matrix
196
+ query_cochange_pair() {
197
+ local FILE_A="$1"
198
+ local FILE_B="$2"
199
+
200
+ # Find the record for FILE_A (matrix stores each pair once in lexicographic order,
201
+ # but also includes reverse-lookup entries for O(1) lookup from either side)
202
+ local RECORD
203
+ RECORD=$(grep -m1 "\"file\":\"${FILE_A}\"" "$COCHANGE_INDEX" 2>/dev/null || true)
204
+ if [ -z "$RECORD" ]; then
205
+ echo "unknown" # FILE_A not in matrix — insufficient history
206
+ return
207
+ fi
208
+
209
+ # Extract n(A) — sum of monthly ring buffer
210
+ local N_A
211
+ N_A=$(echo "$RECORD" | python3 -c "
212
+ import sys, json
213
+ r = json.loads(sys.stdin.read())
214
+ n = r.get('n', [0,0,0])
215
+ print(sum(n))
216
+ " 2>/dev/null || echo "0")
217
+
218
+ # Extract c(A,B) — co-occurrence count with FILE_B
219
+ local C_AB
220
+ C_AB=$(echo "$RECORD" | python3 -c "
221
+ import sys, json
222
+ r = json.loads(sys.stdin.read())
223
+ partners = r.get('partners', {})
224
+ c = partners.get('${FILE_B}', [0,0,0])
225
+ print(sum(c))
226
+ " 2>/dev/null || echo "0")
227
+
228
+ # Apply thresholds: cold-start check (n < 5), support (c >= 3), confidence
229
+ if [ "$N_A" -lt 5 ]; then
230
+ echo "unknown" # Insufficient history for FILE_A
231
+ return
232
+ fi
233
+
234
+ local FILE_B_RECORD
235
+ FILE_B_RECORD=$(grep -m1 "\"file\":\"${FILE_B}\"" "$COCHANGE_INDEX" 2>/dev/null || true)
236
+ local N_B=0
237
+ if [ -n "$FILE_B_RECORD" ]; then
238
+ N_B=$(echo "$FILE_B_RECORD" | python3 -c "
239
+ import sys, json
240
+ r = json.loads(sys.stdin.read())
241
+ n = r.get('n', [0,0,0])
242
+ print(sum(n))
243
+ " 2>/dev/null || echo "0")
244
+ fi
245
+
246
+ if [ "$N_B" -lt 5 ]; then
247
+ echo "unknown" # Insufficient history for FILE_B
248
+ return
249
+ fi
250
+
251
+ # Use danger-zones.mjs --query for the authoritative normalization verdict
252
+ # (handles ubiquity, directional confidence, companions — avoids re-implementing)
253
+ VERDICT=$(node "$(git rev-parse --show-toplevel 2>/dev/null)/scripts/danger-zones.mjs" \
254
+ --query "$FILE_A" 2>/dev/null \
255
+ | python3 -c "
256
+ import sys, json
257
+ d = json.loads(sys.stdin.read())
258
+ partners = d.get('cochangePartners', [])
259
+ for p in partners:
260
+ if p['file'] == '${FILE_B}':
261
+ print(p['verdict'])
262
+ sys.exit(0)
263
+ print('unknown')
264
+ " 2>/dev/null || echo "unknown")
265
+ echo "$VERDICT"
266
+ }
267
+ ```
268
+
269
+ **Fallback path — live git query** (when matrix is absent or file pair not found in matrix):
170
270
 
171
271
  ```bash
172
272
  # Union of affected files across all issues in the CURRENT batch only (already
@@ -204,14 +304,16 @@ else
204
304
  fi
205
305
  ```
206
306
 
207
- **Scoring rule**: A file pair is **co-change coupled** when it appears together in **3 or more** of the commits captured by the bounded query above. A pair with **zero** co-occurrences across the entire window is **verified independent**.
307
+ **Scoring rule**: A file pair is **co-change coupled** when it appears together in **3 or more** commits in the window. A pair with **zero** co-occurrences across the entire window (and both files have n ≥ 5 commits) is **verified independent**. Pairs where either file has fewer than 5 commits in the window are **unknown** — the matrix must NOT be used to downgrade edges for unknown pairs.
208
308
 
209
309
  **Apply the signal:**
210
310
  - **High co-change pair spans two different issues in the batch** → add a serialization edge between them (same directed-edge convention as Layers 1-4: lower issue number is predecessor), OR, if the pair also carries competing investigation recommendations, flag it for Phase 2.5 arbitration instead of a blind serialization edge (see cross-reference in Step 2.5B below).
211
- - **Verified-independent pair** → MAY be used to downgrade an existing Layer 2 "broad directory + different domain" or Layer 4 "conservative fallback" serialization to parallel. This downgrade is **never** applied to Layer 1 (same-file hard conflict, which is ground truth from the current batch, not historical inference) or to Layer 3 high-fan-in-file edges (a file can be structurally high-risk even with a thin history window, e.g. a newly added `main.py`).
212
- - If `ALL_AFFECTED_FILES` is empty, the guard above skips the query and Layer 5 contributes nothing for the entire batch. If the bounded query runs but returns no commits (e.g. brand-new files, or window/pair-set too small to have history) → Layer 5 contributes nothing; fall back silently to Layers 1-4's existing verdict for that pair.
311
+ - **Verified-independent pair** → MAY be used to downgrade an existing Layer 2 "broad directory + different domain" or Layer 4 "conservative fallback" serialization to parallel. This downgrade is **never** applied to Layer 1 (same-file hard conflict, which is ground truth from the current batch, not historical inference) or to Layer 3 high-fan-in-file edges (a file can be structurally high-risk even with a thin history window, e.g. a newly added `main.py`). Ubiquitous-file pairs (n/N > 0.2 for either file) are **ineligible** for verified-independent downgrade even with zero co-occurrences.
312
+ - If `ALL_AFFECTED_FILES` is empty, the guard above skips the query and Layer 5 contributes nothing for the entire batch. If the matrix or live query returns no data for a pair → Layer 5 contributes nothing; fall back silently to Layers 1-4's existing verdict for that pair.
213
313
 
214
- **Rationale**: Empirical co-change is a strictly stronger signal than directory proximity or naming convention — it is the actual observed outcome the other layers are trying to approximate. Bounding the window and pair-set keeps the query cheap and deterministic while still catching the cross-directory conflicts Layers 1-4 structurally cannot see.
314
+ **Wire-through proof (mandatory check)**: When `LAYER5_SOURCE=matrix`, confirm the matrix lookup path executes on at least one pair in the batch and log the verdict. This proves the path is live, not dead code. If no pairs are in the matrix, log that the live fallback ran instead. <!-- Ref: forge#1731, forge#1230, forge#1244 — Layer 5 has had two dead-code defects; this check prevents recurrence. -->
315
+
316
+ **Rationale**: The persisted matrix provides broader coverage (repo-wide, not batch-scoped) and O(1) lookup vs O(batch × commits) live query. The live fallback ensures no regression when the matrix is absent (cold start or first run). Bounding the live fallback to the batch file set keeps it cheap and deterministic.
215
317
 
216
318
  #### Combining all layers
217
319
 
@@ -244,8 +346,51 @@ Build a **directed acyclic graph (DAG)** of per-issue dependencies. Each issue g
244
346
  - **Domain serialization edges**: DATABASE issues form a linear chain (each has the previous DATABASE issue as its predecessor). Same-small-directory issues (Layer 2) and high-fan-in file issues (Layer 3) get directed edges as per Step 3C rules.
245
347
  - **Conservative fallback edges**: Low-confidence issues (Layer 4) get edges to same-domain issues as per Step 3C rules.
246
348
  - **Co-change coupling edges** <!-- Added: forge#1196 -->: High co-change file pairs (Layer 5, 3+ shared commits in the bounded window) that span two different issues get a directed edge using the same lower-issue-number-is-predecessor convention as Layer 1. Verified-independent pairs (Layer 5, zero shared commits) may instead REMOVE an edge that Layer 2 or Layer 4 would otherwise have added for that pair — Layer 1 and Layer 3 edges are never removed by a Layer 5 downgrade.
349
+ - **Claims-board downgrade (Layer 2/4 edges only)** <!-- Added: forge#1736 -->: After dispatch begins (Phase 4A), when both issues in a Layer-2 or Layer-4 serialized pair post `FORGE:CLAIM` annotations on the coordination issue and their claimed file sets are **disjoint** (no path appears in both claims), the serialization edge for that pair MAY be relaxed — the blocked issue becomes ready. This downgrade is **never** applied to Layer-1 (same-file) or Layer-3 (high-fan-in) edges. See Step 4B: Claims-board relaxation sweep for the runtime check.
247
350
  - **No artificial concurrency limit by default** — all issues with empty predecessor sets dispatch simultaneously. The only constraints are file overlap, explicit dependencies, and co-change coupling. When `forge.yaml → orchestration.max_concurrent` is set, the dispatch loop queues excess ready issues and releases them as running workers complete (see Engine mode § Concurrency model).
248
351
 
352
+ ### Step 3D.1: Create coordination issue (claims board) <!-- Added: forge#1736 -->
353
+
354
+ **When to run**: Immediately after DAG construction (Step 3D), before Step 3D.5 cycle detection. Run once per orchestration batch. Skip if `FORGE_COORD_ISSUE` is already set (e.g., resumed session).
355
+
356
+ **Purpose**: Create a dedicated GitHub issue that serves as the shared claims board for the batch. Agents post `FORGE:CLAIM` annotations here when they begin implementation; they post `FORGE:CLAIM_RELEASED` when they reach a terminal state. The orchestrator reads active claims during the Layer-2/4 relaxation sweep (Step 4B) to determine whether serialized pairs can now run in parallel.
357
+
358
+ ```bash
359
+ # Create coordination issue for this orchestration batch
360
+ BATCH_ISSUE_COUNT="${#ISSUES[@]}"
361
+ BATCH_ID="$(date -u +%Y%m%dT%H%M%S)-$$"
362
+
363
+ COORD_ISSUE_BODY="## Orchestration Batch Claims Board
364
+
365
+ This issue is the claims board for an orchestration batch of ${BATCH_ISSUE_COUNT} issues.
366
+ Agents post \`FORGE:CLAIM\` here on build start and \`FORGE:CLAIM_RELEASED\` on terminal state.
367
+
368
+ **Batch ID**: ${BATCH_ID}
369
+ **Issues in batch**: ${ISSUES[*]/#/#}
370
+ **Created**: $(date -u +%Y-%m-%dT%H:%M:%SZ)
371
+
372
+ <!-- FORGE:COORD_ISSUE -->
373
+ <!-- FORGE:BATCH_ID: ${BATCH_ID} -->"
374
+
375
+ COORD_ISSUE_URL=$(gh issue create -R {GH_REPO} \
376
+ --title "orchestrate: claims board for batch ${BATCH_ID}" \
377
+ --body "$COORD_ISSUE_BODY" \
378
+ --label "automation" 2>/dev/null || echo "")
379
+
380
+ if [ -z "$COORD_ISSUE_URL" ]; then
381
+ echo "WARNING: failed to create coordination issue — claims board disabled for this batch. Layer-2/4 relaxation will not run."
382
+ FORGE_COORD_ISSUE=""
383
+ else
384
+ COORD_ISSUE_NUMBER=$(echo "$COORD_ISSUE_URL" | grep -oE '[0-9]+$')
385
+ FORGE_COORD_ISSUE="$COORD_ISSUE_URL"
386
+ echo "Coordination issue created: ${COORD_ISSUE_URL} (#${COORD_ISSUE_NUMBER})"
387
+ export FORGE_COORD_ISSUE
388
+ export COORD_ISSUE_NUMBER
389
+ fi
390
+ ```
391
+
392
+ **Idempotency**: If `FORGE_COORD_ISSUE` is already set in the environment (e.g., after a compaction / orchestrator restart), skip creation and use the existing URL. The coordination issue persists for the lifetime of the batch.
393
+
249
394
  **Terminology:**
250
395
  - **Ready issues**: Issues whose predecessor set is empty (all predecessors have reached terminal state or were never added)
251
396
  - **Blocked issues**: Issues with one or more unresolved predecessors
@@ -371,6 +516,223 @@ fi
371
516
  - If `EXCLUDED_CYCLE` is non-empty, report it clearly in the Step 3E plan before asking for user confirmation
372
517
  - If `ISSUES[]` is empty after cycle exclusion (all issues were cyclic), the guard above aborts with `exit 1` — Step 3E is never reached with an empty plan <!-- Added: forge#1110 -->
373
518
 
519
+ ### Step 3E.5: Value/Cost Scoring Pass (MANDATORY) <!-- Added: forge#1743 -->
520
+
521
+ **Run immediately after Step 3D.5, before presenting the plan (Step 3E).** This step scores every issue in `ISSUES[]` by its expected value/cost ratio and re-orders the ready-set (issues with an empty predecessors set) in descending value/cost order. Dependency constraints are **never overridden** — this is a reordering pass within the existing ready-set only, not an edge-insertion pass. No new edges are added; cycle detection (Step 3D.5) has already completed.
522
+
523
+ **Purpose**: Ensure that when a budget is finite (see `--budget` in Phase 4), the highest-value-per-token work dispatches first. When no budget is set (the default, uncapped behavior), dispatch order still reflects value/cost — useful for observability even without a hard cap.
524
+
525
+ #### Value function (transparent heuristic — deferral decisions must be explainable)
526
+
527
+ ```
528
+ value(issue) = priority_weight × danger_zone_weight
529
+ ```
530
+
531
+ **Priority weight** (from issue labels):
532
+
533
+ | Label | Weight |
534
+ |-------|--------|
535
+ | `priority:P0` | 4.0 |
536
+ | `priority:P1` | 3.0 |
537
+ | `priority:P2` | 2.0 |
538
+ | `priority:P3` | 1.0 |
539
+ | *(no priority label)* | 1.5 |
540
+
541
+ **Danger-zone weight** (from affected files via FORGE:INVESTIGATOR comment): Read the `### Affected Files` section and check each file path against the danger-zone list from `forge.yaml → review.danger_zones[]`. Each affected file that appears in a danger zone adds 0.5 to the weight (additive, capped at 2.0). Default (no matches): 1.0.
542
+
543
+ ```
544
+ danger_zone_weight = min(2.0, 1.0 + 0.5 × count_of_danger_zone_files_affected)
545
+ ```
546
+
547
+ If `forge.yaml → review.danger_zones` is absent: danger_zone_weight = 1.0 for all issues.
548
+
549
+ #### Cost function (fallback hierarchy)
550
+
551
+ ```
552
+ cost_estimate(issue) → expected_spend_usd
553
+ ```
554
+
555
+ Resolve in this order (use the first that produces a non-null result):
556
+
557
+ 1. **Cost-prior lookup** (primary): Read `~/.forge/index/cost-priors.json`. Compute key = `task_type:module` where:
558
+ - `task_type` = FORGE:INVESTIGATOR `**Task Type**` field (lower-cased, spaces→hyphens). If no investigator comment: infer from issue labels (`feature` → `feature`, `bug` → `bug-fix`, else `unknown`).
559
+ - `module` = basename (no ext, lowercase) of the primary affected file from FORGE:INVESTIGATOR. If absent: `_unknown`.
560
+ - If the key exists in cost-priors.json: use `priors[key].mean`. Mark the issue as `has_prior: true` for exploration-reserve logic below.
561
+
562
+ 2. **Label heuristic fallback** (when cost-priors.json absent or key not found):
563
+ ```
564
+ bug/fix: $0.20 · feature: $0.40 · refactor: $0.30 · investigation: $0.50 · unknown: $0.35
565
+ ```
566
+ Mark the issue as `has_prior: false`.
567
+
568
+ 3. **File-count proxy** (last resort — no labels and no prior):
569
+ ```
570
+ estimated_cost = 0.10 + 0.05 × count_of_affected_files
571
+ ```
572
+ Mark the issue as `has_prior: false`.
573
+
574
+ #### Scoring and sorting
575
+
576
+ ```bash
577
+ # --- Step 3E.5: Value/Cost Scoring Pass ---
578
+ # Requires: ISSUES[] (post-cycle-detection), PREDECESSORS[], GH_REPO
579
+ # Outputs: ISSUE_SCORE[], ISSUE_COST_ESTIMATE[], ISSUE_HAS_PRIOR[], SORTED_READY_SET[]
580
+
581
+ declare -A ISSUE_SCORE # issue → value/cost ratio (float)
582
+ declare -A ISSUE_COST_ESTIMATE # issue → estimated cost (USD float string)
583
+ declare -A ISSUE_HAS_PRIOR # issue → true|false
584
+ declare -A ISSUE_VALUE # issue → value weight (float)
585
+
586
+ COST_PRIORS_PATH="${HOME}/.forge/index/cost-priors.json"
587
+
588
+ for NUM in "${ISSUES[@]}"; do
589
+ # 1. Fetch issue data for scoring (labels, investigator comment)
590
+ ISSUE_DATA=$(gh issue view "$NUM" -R {GH_REPO} --json labels,body \
591
+ --jq '{labels: [.labels[].name]}' 2>/dev/null || echo '{"labels":[]}')
592
+
593
+ LABELS=$(echo "$ISSUE_DATA" | jq -r '.labels[]' 2>/dev/null || echo '')
594
+
595
+ # --- Value: priority weight ---
596
+ if echo "$LABELS" | grep -q "priority:P0"; then
597
+ PRIO_WEIGHT=4.0
598
+ elif echo "$LABELS" | grep -q "priority:P1"; then
599
+ PRIO_WEIGHT=3.0
600
+ elif echo "$LABELS" | grep -q "priority:P2"; then
601
+ PRIO_WEIGHT=2.0
602
+ elif echo "$LABELS" | grep -q "priority:P3"; then
603
+ PRIO_WEIGHT=1.0
604
+ else
605
+ PRIO_WEIGHT=1.5
606
+ fi
607
+
608
+ # --- Value: danger-zone weight ---
609
+ DANGER_WEIGHT=1.0
610
+ DANGER_ZONES=$(yq '.review.danger_zones[]? // ""' forge.yaml 2>/dev/null || echo '')
611
+ if [ -n "$DANGER_ZONES" ]; then
612
+ # Fetch affected files from INVESTIGATOR comment
613
+ AFFECTED=$(gh api repos/{GH_REPO}/issues/${NUM}/comments \
614
+ --jq '.[] | select(.body | contains("FORGE:INVESTIGATOR")) | .body' 2>/dev/null \
615
+ | grep -oP '`[^`]+\.(py|mjs|ts|md|sh|yaml|yml)`' | tr -d '`' | head -20 || echo '')
616
+ ZONE_HIT_COUNT=0
617
+ while IFS= read -r dz; do
618
+ [ -z "$dz" ] && continue
619
+ if echo "$AFFECTED" | grep -q "$dz"; then
620
+ ZONE_HIT_COUNT=$((ZONE_HIT_COUNT + 1))
621
+ fi
622
+ done <<< "$DANGER_ZONES"
623
+ DZ_ADD=$(echo "scale=1; if ($ZONE_HIT_COUNT * 0.5 > 1.0) 1.0 else $ZONE_HIT_COUNT * 0.5" | bc 2>/dev/null || echo "0")
624
+ DANGER_WEIGHT=$(echo "scale=1; 1.0 + $DZ_ADD" | bc 2>/dev/null || echo "1.0")
625
+ fi
626
+
627
+ VALUE=$(echo "scale=4; $PRIO_WEIGHT * $DANGER_WEIGHT" | bc 2>/dev/null || echo "$PRIO_WEIGHT")
628
+ ISSUE_VALUE[$NUM]="$VALUE"
629
+
630
+ # --- Cost: prior lookup → label heuristic → file-count proxy ---
631
+ COST=""
632
+ HAS_PRIOR="false"
633
+
634
+ if [ -f "$COST_PRIORS_PATH" ]; then
635
+ # Derive task_type:module key
636
+ TASK_TYPE=$(gh api repos/{GH_REPO}/issues/${NUM}/comments \
637
+ --jq '[.[] | select(.body | contains("FORGE:INVESTIGATOR")) | .body] | last // ""' 2>/dev/null \
638
+ | grep -oP '(?<=\*\*Task Type\*\*: )\S+' | head -1 | tr '[:upper:]' '[:lower:]' \
639
+ | tr ' ' '-' || echo '')
640
+ [ -z "$TASK_TYPE" ] && {
641
+ if echo "$LABELS" | grep -q "^feature$"; then TASK_TYPE="feature"
642
+ elif echo "$LABELS" | grep -q "^bug$"; then TASK_TYPE="bug-fix"
643
+ else TASK_TYPE="unknown"; fi
644
+ }
645
+
646
+ PRIMARY_FILE=$(gh api repos/{GH_REPO}/issues/${NUM}/comments \
647
+ --jq '[.[] | select(.body | contains("FORGE:INVESTIGATOR")) | .body] | last // ""' 2>/dev/null \
648
+ | grep -oP '`[^`]+\.(py|mjs|ts|md|sh|yaml|yml)`' | tr -d '`' | head -1 || echo '')
649
+ MODULE=$(basename "${PRIMARY_FILE:-_unknown}" | sed 's/\.[^.]*$//' | tr '[:upper:]' '[:lower:]')
650
+ [ -z "$MODULE" ] && MODULE="_unknown"
651
+
652
+ PRIOR_KEY="${TASK_TYPE}:${MODULE}"
653
+ COST=$(jq -r --arg k "$PRIOR_KEY" '.priors[$k].mean // empty' "$COST_PRIORS_PATH" 2>/dev/null || echo '')
654
+ [ -n "$COST" ] && HAS_PRIOR="true"
655
+ fi
656
+
657
+ if [ -z "$COST" ]; then
658
+ # Label heuristic fallback
659
+ if echo "$LABELS" | grep -q "^feature$"; then COST="0.40"
660
+ elif echo "$LABELS" | grep -q "^bug$"; then COST="0.20"
661
+ elif echo "$LABELS" | grep -q "^refactor$"; then COST="0.30"
662
+ else COST="0.35"; fi
663
+ fi
664
+
665
+ # Score = value / cost (protected against divide-by-zero)
666
+ SCORE=$(echo "scale=4; if ($COST > 0) $VALUE / $COST else $VALUE / 0.01" | bc 2>/dev/null || echo "1.0")
667
+
668
+ ISSUE_SCORE[$NUM]="$SCORE"
669
+ ISSUE_COST_ESTIMATE[$NUM]="$COST"
670
+ ISSUE_HAS_PRIOR[$NUM]="$HAS_PRIOR"
671
+
672
+ echo "Score: #${NUM} value=${VALUE} cost_est=\$${COST} (prior=${HAS_PRIOR}) score=${SCORE}"
673
+ done
674
+
675
+ # --- ε-exploration reserve ---
676
+ # ε = 10% of budget allocated to no-prior issues (high-variance unknowns).
677
+ # A no-prior issue is guaranteed a dispatch slot within the ε reserve even if
678
+ # its score would otherwise place it below the budget cutoff. This prevents
679
+ # discovery starvation on novel modules with no cost history.
680
+ #
681
+ # Implementation: when --budget is set in Phase 4, the dispatch loop reserves
682
+ # EPSILON_BUDGET = 0.10 × BUDGET_LIMIT for issues where ISSUE_HAS_PRIOR[N] == "false".
683
+ # This step only MARKS the no-prior issues; Phase 4 reads ISSUE_HAS_PRIOR[] to apply the reserve.
684
+ #
685
+ # No-prior issues still compete in the main dispatch queue by score. The reserve
686
+ # acts as a safety net: if no no-prior issue has been dispatched by the time
687
+ # PROJECTED_SPEND reaches (BUDGET_LIMIT − EPSILON_BUDGET), the highest-scoring
688
+ # no-prior issue is force-dispatched from the reserve before budget cutoff.
689
+
690
+ NO_PRIOR_ISSUES=()
691
+ for NUM in "${ISSUES[@]}"; do
692
+ [ "${ISSUE_HAS_PRIOR[$NUM]:-false}" = "false" ] && NO_PRIOR_ISSUES+=("$NUM")
693
+ done
694
+ echo "Exploration reserve: ${#NO_PRIOR_ISSUES[@]} no-prior issues (ε=10% of budget reserved for these)"
695
+
696
+ # --- Sort the ready-set by descending score ---
697
+ # The ready-set is the subset of ISSUES with empty PREDECESSORS[].
698
+ # Dependency-constrained issues (non-empty PREDECESSORS[]) keep their original DAG ordering —
699
+ # their dispatch is triggered by predecessor completion, not by score rank.
700
+ # SORTED_READY_SET is consumed by Step 3E (plan) and Phase 4 (dispatch order).
701
+
702
+ READY_SET=()
703
+ for NUM in "${ISSUES[@]}"; do
704
+ [ -z "${PREDECESSORS[$NUM]:-}" ] && READY_SET+=("$NUM")
705
+ done
706
+
707
+ # Sort by score descending (bc-based comparison via temporary file)
708
+ SCORE_PAIRS=""
709
+ for NUM in "${READY_SET[@]}"; do
710
+ SCORE_PAIRS="${SCORE_PAIRS}${ISSUE_SCORE[$NUM]:-0} $NUM"$'\n'
711
+ done
712
+ SORTED_READY_SET=()
713
+ while IFS=' ' read -r _score num; do
714
+ [ -n "$num" ] && SORTED_READY_SET+=("$num")
715
+ done < <(echo "$SCORE_PAIRS" | sort -rn -k1,1 | grep -v '^$')
716
+
717
+ echo ""
718
+ echo "Ready-set dispatch order (descending value/cost):"
719
+ for NUM in "${SORTED_READY_SET[@]}"; do
720
+ PRIOR_TAG="${ISSUE_HAS_PRIOR[$NUM]:-false}"
721
+ [ "$PRIOR_TAG" = "false" ] && PRIOR_NOTE=" [ε-reserve eligible]" || PRIOR_NOTE=" [prior known]"
722
+ echo " #${NUM} score=${ISSUE_SCORE[$NUM]:-?} cost_est=\$${ISSUE_COST_ESTIMATE[$NUM]:-?}${PRIOR_NOTE}"
723
+ done
724
+ # --- End Step 3E.5 ---
725
+ ```
726
+
727
+ **Output state** (consumed by Step 3E and Phase 4):
728
+ - `ISSUE_SCORE[N]` — value/cost ratio for each issue
729
+ - `ISSUE_COST_ESTIMATE[N]` — estimated spend in USD
730
+ - `ISSUE_HAS_PRIOR[N]` — `true` if cost-priors.json had an entry; `false` = no-prior (ε-reserve eligible)
731
+ - `SORTED_READY_SET[]` — ready issues sorted by descending score (used in Step 3E plan table and Phase 4 dispatch loop)
732
+ - `NO_PRIOR_ISSUES[]` — issues with no cost history (a subset of `SORTED_READY_SET` + blocked issues)
733
+
734
+ **Important**: `ISSUE_SCORE[]` and `ISSUE_COST_ESTIMATE[]` are also set for blocked issues (for reporting). The sorted order only affects the ready-set — blocked issues dispatch when their predecessors complete, regardless of score.
735
+
374
736
  ### Step 3E: Present the plan to the user
375
737
 
376
738
  ```
@@ -404,17 +766,22 @@ fi
404
766
 
405
767
  ### Dependency Graph
406
768
 
407
- | Issue | Predecessors | Domain | Status |
408
- |-------|-------------|--------|--------|
409
- | #{A} | — | FRONTEND | Ready (dispatches immediately) |
410
- | #{B} | — | BILLING | Ready (dispatches immediately) |
411
- | #{C} | — | WORKER | Ready (dispatches immediately) |
412
- | #{D} | #{A} | FRONTEND | Blocked (waits for #{A} only) |
413
- | #{E} | — | DATABASE | Ready (dispatches immediately) |
414
- | #{F} | #{E} | DATABASE | Blocked (serialized — waits for #{E}) |
769
+ | Issue | Predecessors | Domain | Score | Est. Cost | Status |
770
+ |-------|-------------|--------|-------|-----------|--------|
771
+ | #{A} | — | FRONTEND | {score} | ${cost} | Ready (dispatches 1st by score) |
772
+ | #{B} | — | BILLING | {score} | ${cost} | Ready (dispatches 2nd by score) |
773
+ | #{C} | — | WORKER | {score} | ${cost} [ε] | Ready (dispatches 3rd — ε-reserve) |
774
+ | #{D} | #{A} | FRONTEND | {score} | ${cost} | Blocked (waits for #{A} only) |
775
+ | #{E} | — | DATABASE | {score} | ${cost} | Ready (dispatches 4th by score) |
776
+ | #{F} | #{E} | DATABASE | {score} | ${cost} | Blocked (serialized — waits for #{E}) |
777
+
778
+ **[ε]** = no cost prior; eligible for exploration reserve (10% of budget guaranteed for these)
779
+
780
+ **Score** = value / estimated_cost (value = priority_weight × danger_zone_weight; higher = dispatches first within the ready-set)
781
+ **Est. Cost** = cost-prior mean for (task_type × module), or label heuristic if no prior
415
782
 
416
783
  **Critical path**: #{E} → #{F} (2 steps, determines minimum wall-clock time)
417
- **Initial dispatch**: #{A}, #{B}, #{C}, #{E} (all predecessors resolved)
784
+ **Initial dispatch**: #{A}, #{B}, #{C}, #{E} (all predecessors resolved — ordered by score)
418
785
  **Streaming**: #{D} dispatches as soon as #{A} completes — does NOT wait for #{B}, #{C}, or #{E}
419
786
 
420
787
  **Note**: Investigations may create additional issues that will be automatically added to the dependency graph. The final graph will be confirmed after investigations complete.
@@ -557,39 +924,103 @@ The orchestrator context window must stay small regardless of how many issues ha
557
924
 
558
925
  **Contract**: After any compaction event or orchestrator wake (session resumed after idle/restart), do NOT rely on in-context variables. Instead, reconstruct the DAG dispatch state from GitHub before checking for newly ready issues:
559
926
 
927
+ This reconstruction MUST use the same three-way **DONE / GATED / FAILED** predecessor classification defined in `phase-4-execution.md` Step 4B ("Predecessor Classification") — not a binary terminal/non-terminal grep. A binary grep is exactly the bug forge#1812 fixed: it let `needs-human` simultaneously satisfy "predecessor is done, dispatch the successor" (this block, pre-fix) and "predecessor failed, skip the successor" (Step 4B's failure handler, pre-fix) — with no way to represent "predecessor is human-gated, its PR is still open, and its dependents should wait but not be abandoned." That third case is exactly what wake/compaction reconstruction hits most often, since a merge approved by a human typically happens *after* the orchestrator session that dispatched the predecessor has already ended — this block, not the live Step 4B loop, is the realistic trigger point for "gating PR merged while nobody was watching."
928
+
560
929
  ```bash
561
930
  # Reconstruct dispatch state from GitHub after compaction / wake
562
931
  # Run this block at the top of every resumed Phase 4 loop iteration.
563
932
 
564
- # 1. Re-fetch all issue labels to rebuild terminal-state knowledge
933
+ # 1. Re-fetch all issue labels and classify each into DONE / GATED / FAILED / IN_PROGRESS
934
+ # (same classify_predecessor_state() function defined in phase-4-execution.md Step 4B —
935
+ # re-declare it here if this block runs in a fresh context that hasn't sourced Step 4B yet).
936
+ declare -A ISSUE_CLASS
937
+ DONE_ISSUES=()
938
+ GATED_ISSUES=()
939
+ FAILED_ISSUES=()
940
+ ACTIVE_ISSUES=() # IN_PROGRESS — still mid-pipeline, not yet terminal-for-this-agent
941
+
565
942
  for NUM in {all_issue_numbers_in_batch}; do
566
- ISSUE_STATE=$(gh issue view "$NUM" -R {GH_REPO} --json state,labels \
567
- --jq '{state: .state, workflow: [.labels[].name | select(startswith("workflow:"))]}' \
568
- 2>/dev/null || echo '{}')
569
- # Classify: terminal if workflow:merged, workflow:invalid, needs-human, or CLOSED
570
- if echo "$ISSUE_STATE" | grep -qE 'workflow:merged|workflow:invalid|needs-human|CLOSED'; then
571
- TERMINAL_ISSUES+=("$NUM")
572
- else
573
- ACTIVE_ISSUES+=("$NUM")
574
- fi
943
+ CLASS=$(classify_predecessor_state "$NUM")
944
+ ISSUE_CLASS["$NUM"]="$CLASS"
945
+ case "$CLASS" in
946
+ DONE) DONE_ISSUES+=("$NUM") ;;
947
+ GATED) GATED_ISSUES+=("$NUM") ;;
948
+ FAILED) FAILED_ISSUES+=("$NUM") ;;
949
+ *) ACTIVE_ISSUES+=("$NUM") ;;
950
+ esac
575
951
  done
576
952
 
577
- # 2. Re-derive the ready set: any non-terminal issue whose predecessors are all terminal
953
+ # 2. Re-derive the ready set: any non-terminal issue whose predecessors are ALL classified DONE.
954
+ # A GATED predecessor blocks dispatch but does NOT fail the dependent — see step 2.5 below.
955
+ READY_ISSUES=()
956
+ NEWLY_BLOCKED=() # dependents whose gating predecessor is GATED — need blocked-on-human-merge tracking
578
957
  for NUM in "${ACTIVE_ISSUES[@]}"; do
579
958
  ALL_PREDS_DONE=true
959
+ GATING_PRED=""
580
960
  for PRED in {predecessors_of_NUM}; do
581
- if ! printf '%s\n' "${TERMINAL_ISSUES[@]}" | grep -qx "$PRED"; then
582
- ALL_PREDS_DONE=false
583
- break
584
- fi
961
+ case "${ISSUE_CLASS[$PRED]:-IN_PROGRESS}" in
962
+ DONE) ;;
963
+ GATED) ALL_PREDS_DONE=false; GATING_PRED="$PRED" ;;
964
+ *) ALL_PREDS_DONE=false ;;
965
+ esac
585
966
  done
586
- $ALL_PREDS_DONE && READY_ISSUES+=("$NUM")
967
+ if [ "$ALL_PREDS_DONE" = "true" ]; then
968
+ READY_ISSUES+=("$NUM")
969
+ elif [ -n "$GATING_PRED" ]; then
970
+ NEWLY_BLOCKED+=("$NUM|$GATING_PRED")
971
+ fi
972
+ done
973
+
974
+ # 2.5. Track newly-blocked dependents (mirrors phase-4-execution.md Step 4B item 6.5)
975
+ # Self-heal the label if not yet bootstrapped (same pattern as review-pr.md 6C / phase-4-execution.md item 6.5).
976
+ gh label create "blocked-on-human-merge" --color "006B75" --description "Dependent of a gated (needs-human/awaiting-merge) predecessor. Managed by ForgeDock." --force -R {GH_REPO} 2>/dev/null
977
+ for ENTRY in "${NEWLY_BLOCKED[@]:-}"; do
978
+ [ -z "$ENTRY" ] && continue
979
+ DEP="${ENTRY%%|*}"
980
+ PRED="${ENTRY##*|}"
981
+ # Anchor on the exact "**Gating predecessor**: #N" label with a word boundary —
982
+ # a bare contains("#N") substring would false-match #50/#500 for predecessor #5. <!-- forge#1830 -->
983
+ ALREADY_TRACKED=$(gh api repos/{GH_REPO}/issues/${DEP}/comments \
984
+ --jq --arg prednum "${PRED}" '[.[] | select(.body | contains("FORGE:BLOCKED_ON_HUMAN_MERGE") and test("Gating predecessor\\*\\*: #" + $prednum + "\\b"))] | length' 2>/dev/null || echo "0")
985
+ if [ "$ALREADY_TRACKED" -eq 0 ]; then
986
+ GATING_PR=$(gh pr list -R {GH_REPO} --state open --search "\"Closes #${PRED}\" in:body" \
987
+ --json number --jq '.[0].number // empty' 2>/dev/null || echo "")
988
+ gh issue comment "$DEP" -R {GH_REPO} --body "<!-- FORGE:BLOCKED_ON_HUMAN_MERGE -->
989
+ **Gating predecessor**: #${PRED} (state: \`${ISSUE_CLASS[$PRED]}\`${GATING_PR:+, open PR #${GATING_PR}})
990
+ **Status**: Detected on orchestrator wake/compaction reconstruction. Ready to dispatch as soon as #${PRED} reaches \`workflow:merged\`."
991
+ gh issue edit "$DEP" -R {GH_REPO} --add-label "blocked-on-human-merge" 2>/dev/null || true
992
+ fi
993
+ done
994
+
995
+ # 3. Merge-triggered wake: any issue tracked as blocked-on-human-merge whose gating predecessor
996
+ # is now DONE gets un-blocked and added to the ready set. This is the wake-time equivalent of
997
+ # phase-4-execution.md Step 4B item 6.6 — it is what makes "auto-dispatch on merge, no manual
998
+ # /orchestrate re-run" hold true even when the merge happened after the session ended.
999
+ BLOCKED_NOW=$(gh issue list -R {GH_REPO} --state open --label "blocked-on-human-merge" --json number \
1000
+ --jq '.[].number' 2>/dev/null || echo "")
1001
+ for DEP in $BLOCKED_NOW; do
1002
+ # Read which predecessor(s) this DEP is tracked against
1003
+ GATING_PREDS_RAW=$(gh api repos/{GH_REPO}/issues/${DEP}/comments \
1004
+ --jq '[.[] | select(.body | contains("FORGE:BLOCKED_ON_HUMAN_MERGE")) | (.body | capture("Gating predecessor\\*\\*: #(?<p>[0-9]+)").p)]' 2>/dev/null || echo '[]')
1005
+ STILL_GATED=false
1006
+ for GPRED in $(echo "$GATING_PREDS_RAW" | jq -r '.[]' 2>/dev/null); do
1007
+ GPRED_CLASS=$(classify_predecessor_state "$GPRED")
1008
+ [ "$GPRED_CLASS" != "DONE" ] && STILL_GATED=true
1009
+ done
1010
+ if [ "$STILL_GATED" = "false" ]; then
1011
+ gh issue edit "$DEP" -R {GH_REPO} --remove-label "blocked-on-human-merge" 2>/dev/null || true
1012
+ gh issue comment "$DEP" -R {GH_REPO} --body "<!-- FORGE:UNBLOCKED -->
1013
+ All gating predecessor(s) reached \`workflow:merged\` (detected on orchestrator wake) — dispatching now."
1014
+ READY_ISSUES+=("$DEP")
1015
+ fi
587
1016
  done
588
1017
 
589
- # 3. Dispatch the reconstructed ready set via standard Step 4A.pre.0 → 4A.pre → 4A flow
1018
+ # 4. Dispatch the reconstructed ready set (DONE_ISSUES-unblocked + merge-triggered-woken) via the
1019
+ # standard Step 4A.pre.0 → 4A.pre → 4A flow. FAILED_ISSUES' transitive dependents remain marked
1020
+ # "skipped — dependency failed" per phase-4-execution.md Step 4B item 6 — do not re-add them here.
590
1021
  ```
591
1022
 
592
- **Why this keeps context small**: Each `Agent()` call returns an agent ID stored only in `AGENT_ISSUE_MAP`, which is rebuilt per Step 4A.pre dispatch batch. After compaction, the map is gone — but the DAG state is fully on GitHub. The reconstruction above re-derives the ready set from labels alone, so the orchestrator context never needs to hold cumulative dispatch history.
1023
+ **Why this keeps context small**: Each `Agent()` call returns an agent ID stored only in `AGENT_ISSUE_MAP`, which is rebuilt per Step 4A.pre dispatch batch. After compaction, the map is gone — but the DAG state, including `blocked-on-human-merge` tracking (a durable `FORGE:BLOCKED_ON_HUMAN_MERGE` comment plus label, not an in-context variable), is fully on GitHub. The reconstruction above re-derives the ready set, the gated set, and the blocked-on-human-merge set from labels and comments alone, so the orchestrator context never needs to hold cumulative dispatch history.
593
1024
 
594
1025
  ---
595
1026