forgedock 1.7.1 → 1.8.0

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 (47) hide show
  1. package/bin/engine/admission.mjs +242 -7
  2. package/bin/engine/admission.test.mjs +183 -2
  3. package/bin/engine/phases.mjs +31 -22
  4. package/bin/engine/resolve.mjs +8 -1
  5. package/bin/engine/resolve.test.mjs +5 -0
  6. package/bin/engine.mjs +26 -17
  7. package/bin/forgedock.mjs +15 -2
  8. package/bin/hooks/pre-tool-use.mjs +573 -2
  9. package/bin/journey.mjs +11 -7
  10. package/bin/labels.json +5 -0
  11. package/bin/opencode-adapter.mjs +47 -13
  12. package/bin/orchestrate-preflight.mjs +434 -0
  13. package/bin/runner.mjs +30 -4
  14. package/commands/audit-agents.md +75 -26
  15. package/commands/autopilot.md +4 -4
  16. package/commands/cleanup.md +10 -5
  17. package/commands/issue.md +95 -35
  18. package/commands/orchestrate/phase-1-resolve.md +70 -32
  19. package/commands/orchestrate/phase-3-dependency.md +141 -13
  20. package/commands/orchestrate/phase-4-execution.md +660 -70
  21. package/commands/orchestrate/phase-5-cleanup.md +5 -11
  22. package/commands/orchestrate/phase-6-report.md +27 -1
  23. package/commands/orchestrate/safety.md +1 -1
  24. package/commands/orchestrate.md +39 -1
  25. package/commands/quality-gate.md +30 -10
  26. package/commands/review-pr-agents/protocols.md +13 -0
  27. package/commands/review-pr-agents.md +1 -1
  28. package/commands/review-pr-staging.md +100 -18
  29. package/commands/review-pr.md +78 -24
  30. package/commands/work-on/build/validate.md +25 -4
  31. package/commands/work-on/close.md +40 -10
  32. package/commands/work-on/decompose.md +33 -1
  33. package/commands/work-on/investigate.md +33 -0
  34. package/commands/work-on/remediate.md +40 -5
  35. package/commands/work-on/review.md +27 -0
  36. package/commands/work-on.md +48 -5
  37. package/docs/CONFIG.md +45 -10
  38. package/docs/OPENCODE.md +21 -6
  39. package/docs/spec/review-protocol.md +7 -0
  40. package/package.json +1 -1
  41. package/scripts/check-admission-parity.mjs +1 -0
  42. package/scripts/check-command-side-effects.sh +14 -13
  43. package/scripts/check-shell-portability.sh +36 -0
  44. package/scripts/check-spec-markers.sh +21 -11
  45. package/scripts/checkbox-sections.test.sh +67 -0
  46. package/scripts/extract-affected-files.sh +85 -5
  47. package/scripts/extract-affected-files.test.sh +154 -2
@@ -167,9 +167,21 @@ def parse_agent(filepath, agent_id):
167
167
  skill_invocations = [] # unique (ts, skill) pairs
168
168
  skill_set = set()
169
169
  tool_counts = defaultdict(int)
170
- resume_count = 0
171
170
  first_ts = last_ts = None
172
171
  end_turn_points = []
172
+ event_timestamps = []
173
+ operator_resume_cycles = 0
174
+ pending_end_turn = False
175
+
176
+ def is_terminal_skill(skill):
177
+ return skill.lower() in {'close', 'work-on/close', 'work-on:close'}
178
+
179
+ def is_terminal_text(text):
180
+ text = text.lower()
181
+ return any(marker in text for marker in (
182
+ 'verdict: invalid', 'status: invalid', 'state: invalid',
183
+ 'status: merged', 'state: merged', 'status: closed', 'state: closed',
184
+ ))
173
185
 
174
186
  for line in lines:
175
187
  data = json.loads(line)
@@ -181,11 +193,18 @@ def parse_agent(filepath, agent_id):
181
193
  if first_ts is None:
182
194
  first_ts = ts
183
195
  last_ts = ts
196
+ event_timestamps.append(ts)
184
197
 
185
198
  msg = data.get('message', {})
186
199
  stop_reason = msg.get('stop_reason', '')
187
200
  content = msg.get('content', [])
188
201
 
202
+ # A user turn after an assistant end_turn is an operator-style resume,
203
+ # even when the transcript is appended rather than replayed.
204
+ if data.get('type') == 'user' and pending_end_turn:
205
+ operator_resume_cycles += 1
206
+ pending_end_turn = False
207
+
189
208
  if stop_reason == 'end_turn':
190
209
  # Find the text content of this end_turn message
191
210
  text = ''
@@ -194,7 +213,15 @@ def parse_agent(filepath, agent_id):
194
213
  if c.get('type') == 'text':
195
214
  text = c.get('text', '')[:100]
196
215
  break
197
- end_turn_points.append({'ts': ts, 'text': text})
216
+ terminal = is_terminal_text(text) or any(
217
+ is_terminal_skill(si['skill']) for si in skill_invocations
218
+ )
219
+ end_turn_points.append({
220
+ 'ts': ts,
221
+ 'text': text,
222
+ 'terminal': terminal,
223
+ })
224
+ pending_end_turn = not terminal
198
225
 
199
226
  if isinstance(content, list):
200
227
  for c in content:
@@ -217,17 +244,6 @@ def parse_agent(filepath, agent_id):
217
244
  # Duplicate = resume replay
218
245
  pass
219
246
 
220
- # Detect resume points: user messages that contain resume instructions
221
- if data.get('type') == 'user':
222
- if isinstance(content, str) and any(kw in content.lower() for kw in ['continue', 'resume', 'you need to']):
223
- resume_count += 1
224
- elif isinstance(content, list):
225
- for c in content:
226
- if c.get('type') == 'text':
227
- t = c.get('text', '').lower()
228
- if any(kw in t for kw in ['continue the', 'resume', 'you need to continue']):
229
- resume_count += 1
230
-
231
247
  # Count duplicate skill timestamps to detect resume replays
232
248
  all_skill_timestamps = []
233
249
  for line in lines:
@@ -244,7 +260,8 @@ def parse_agent(filepath, agent_id):
244
260
  for t in all_skill_timestamps:
245
261
  ts_counts[t] += 1
246
262
  max_replays = max(ts_counts.values()) if ts_counts else 1
247
- resume_cycles = max_replays - 1 # first occurrence is original, rest are replays
263
+ replay_resume_cycles = max_replays - 1 # first occurrence is original, rest are replays
264
+ resume_cycles = replay_resume_cycles + operator_resume_cycles
248
265
 
249
266
  # Build phase timeline from unique skill invocations.
250
267
  # Stall detection: a gap is a TRUE STALL only when it is >120s AND contains zero
@@ -276,7 +293,17 @@ def parse_agent(filepath, agent_id):
276
293
  gap_sec = (si['ts'] - prev_ts).total_seconds()
277
294
  # Any tool_use event inside the gap window means the agent was active
278
295
  has_tool_use_in_gap = any(prev_ts < t <= si['ts'] for t in all_tool_use_ts)
279
- is_stall = gap_sec > 120 and not has_tool_use_in_gap
296
+ # An incomplete end_turn in this gap is accounted for separately below,
297
+ # so the same wall-clock wait is never counted twice.
298
+ has_incomplete_end_turn_in_gap = any(
299
+ not p['terminal'] and prev_ts < p['ts'] <= si['ts']
300
+ for p in end_turn_points
301
+ )
302
+ is_stall = (
303
+ gap_sec > 120
304
+ and not has_tool_use_in_gap
305
+ and not has_incomplete_end_turn_in_gap
306
+ )
280
307
  phases.append({
281
308
  'ts': si['ts'],
282
309
  'skill': si['skill'],
@@ -286,9 +313,27 @@ def parse_agent(filepath, agent_id):
286
313
  })
287
314
  prev_ts = si['ts']
288
315
 
289
- total_sec = (last_ts - first_ts).total_seconds() if first_ts and last_ts else 0
290
- stall_sec = sum(p['gap_from_prev_sec'] for p in phases if p['is_stall'])
316
+ # An end_turn before a terminal phase is a stall even with no idle gap. Its
317
+ # wait is measured until the next transcript event; a terminal transcript
318
+ # has no following event, so give it the detector's 120-second floor.
319
+ incomplete_end_turns = [p for p in end_turn_points if not p['terminal']]
320
+ for point in incomplete_end_turns:
321
+ next_ts = next((t for t in event_timestamps if t > point['ts']), None)
322
+ point['stall_sec'] = (next_ts - point['ts']).total_seconds() if next_ts else 120
323
+ point['is_stall'] = True
324
+
325
+ observed_total_sec = (last_ts - first_ts).total_seconds() if first_ts and last_ts else 0
326
+ unobserved_stall_sec = sum(
327
+ p['stall_sec'] for p in incomplete_end_turns if p['ts'] == last_ts
328
+ )
329
+ total_sec = observed_total_sec + unobserved_stall_sec
330
+ phase_stall_sec = sum(p['gap_from_prev_sec'] for p in phases if p['is_stall'])
331
+ end_turn_stall_sec = sum(p['stall_sec'] for p in incomplete_end_turns)
332
+ stall_sec = phase_stall_sec + end_turn_stall_sec
291
333
  active_sec = total_sec - stall_sec
334
+ reached_terminal_state_unaided = (
335
+ any(p['terminal'] for p in end_turn_points) and resume_cycles == 0
336
+ )
292
337
 
293
338
  return {
294
339
  'agent_id': agent_id,
@@ -304,6 +349,8 @@ def parse_agent(filepath, agent_id):
304
349
  'phases': phases,
305
350
  'end_turn_points': end_turn_points,
306
351
  'resume_cycles': resume_cycles,
352
+ 'operator_resume_cycles': operator_resume_cycles,
353
+ 'reached_terminal_state_unaided': reached_terminal_state_unaided,
307
354
  'skill_count': len(skill_invocations),
308
355
  }
309
356
  ```
@@ -346,7 +393,8 @@ For each agent, display:
346
393
  ```
347
394
  ## Agent: #{ISSUE_NUMBER} — {ISSUE_TITLE}
348
395
  **Duration**: {total_min} min (active: {active_min} min, idle: {stall_min} min — {idle_pct}% idle)
349
- **Resume cycles**: {resume_cycles} (agent stopped and was resumed {resume_cycles} times)
396
+ **Resume cycles**: {resume_cycles} (replay: {replay_resume_cycles}, injected user turn: {operator_resume_cycles})
397
+ **Terminal state**: {"reached unaided" if reached_terminal_state_unaided else "required intervention or did not reach terminal state"}
350
398
  **JSONL lines**: {lines} | **Tool calls**: Bash:{N} Read:{N} Edit:{N} Skill:{N}
351
399
 
352
400
  ### Phase Timeline
@@ -376,15 +424,15 @@ For each agent, display:
376
424
  ```
377
425
  ## Wave Summary
378
426
 
379
- | Agent | Issue | Total | Active | Idle | Idle% | Resumes | Stall Points |
380
- |-------|-------|-------|--------|------|-------|---------|--------------|
381
- | afbc… | #14513 | 40m | 8m | 31m | 80% | 2 | investigate→build, context→architect |
382
- | a3b5… | #14508 | 23m | 23m | 0m | 0% | 0 | — |
383
- | adf5… | #14514 | 55m | 12m | 43m | 78% | 3 | investigate→build, context→architect, implement→validate |
427
+ | Agent | Issue | Total | Active | Idle | Idle% | Resumes | Terminal unaided | Stall Points |
428
+ |-------|-------|-------|--------|------|-------|---------|------------------|--------------|
429
+ | afbc… | #14513 | 40m | 8m | 31m | 80% | 2 | no | investigate→build, context→architect |
430
+ | a3b5… | #14508 | 23m | 23m | 0m | 0% | 0 | yes | — |
431
+ | adf5… | #14514 | 55m | 12m | 43m | 78% | 3 | no | investigate→build, context→architect, implement→validate |
384
432
 
385
433
  **Wave efficiency**: {avg_idle_pct}% idle time across all agents
386
434
  **Longest stall**: {max_stall_min} min ({agent_id} between {phase_a} → {phase_b})
387
- **Clean agents**: {N} of {total} ran without stalls
435
+ **Clean agents**: {N} of {total} ran without stalls or intervention
388
436
  ```
389
437
 
390
438
  ### Step 4C: Stall pattern analysis
@@ -416,6 +464,7 @@ Based on the data, output specific recommendations:
416
464
 
417
465
  - If idle% > 50% across wave → "Orchestrator polling too slow — agents spend more time waiting than working"
418
466
  - If resume_cycles > 0 for most agents → "Routing loop in work-on.md not continuing past phase boundaries"
467
+ - If any agent did not reach a terminal state unaided → "Routing loop required operator intervention; inspect end_turn stops before declaring the wave clean"
419
468
  - If specific boundary stalls repeatedly → "Phase {X} returns text with end_turn instead of continuing loop — check work-on.md routing instructions"
420
469
  - If one agent ran clean but others didn't → "Compare clean agent (#XXXX) vs stalled agents — what differs?"
421
470
 
@@ -480,7 +529,7 @@ fi
480
529
  TOTAL_AGENTS=$(echo "${AGENT_DATA[@]}" | jq 'length')
481
530
  AVG_IDLE=$(echo "${AGENT_DATA[@]}" | jq '[.[].idle_pct] | add / length | . * 10 | round / 10')
482
531
  AVG_RESUMES=$(echo "${AGENT_DATA[@]}" | jq '[.[].resume_cycles] | add / length | . * 100 | round / 100')
483
- CLEAN_N=$(echo "${AGENT_DATA[@]}" | jq '[.[] | select(.idle_pct == 0 and .resume_cycles == 0)] | length')
532
+ CLEAN_N=$(echo "${AGENT_DATA[@]}" | jq '[.[] | select(.idle_pct == 0 and .resume_cycles == 0 and .reached_terminal_state_unaided)] | length')
484
533
 
485
534
  # Top stall boundaries: aggregate gap_from_prev_sec > 120 entries by skill transition label
486
535
  # Format: "investigate→build(4), context→architect(3), implement→validate(2)"
@@ -541,4 +590,4 @@ This enables tracking whether orchestrator/prompt changes actually improved thro
541
590
  - **File format**: Agent outputs are JSONL (one JSON object per line). Each line has `type` (user/assistant), `timestamp`, `message` (with `content` array and optional `stop_reason`).
542
591
  - **Subagent files**: Located at `~/.claude/projects/{munged-project-path}/{sessionId}/subagents/agent-{agentId}.jsonl`. The munging rule: both `/` and `.` in the project path are replaced with `-` (e.g. `/home/user/projects/myapp` → `-home-user-projects-myapp`). There are no platform-specific temp paths or symlinks involved.
543
592
  - **Size filtering**: Only analyze files > 10 lines. Small files are helper/polling agents spawned by the orchestrator for status checks.
544
- - **Resume detection**: When an agent is resumed, the full conversation is replayed. Duplicate `(timestamp, skill_name)` pairs indicate replay cycles. `max(duplicates) - 1 = resume_cycles`.
593
+ - **Resume detection**: Replay-dispatched resumes produce duplicate `(timestamp, skill_name)` pairs. SendMessage-style resumes append a user turn after an assistant `end_turn`. Both signals contribute to `resume_cycles`.
@@ -858,7 +858,7 @@ To approve: remove \`needs-human\` label from #${ISSUE_NUM} and re-run \`/autopi
858
858
  2>/dev/null || echo '')
859
859
  for N in $PRE_DISPATCH_ISSUES; do DISPATCHED_ISSUES+=("$N"); done
860
860
 
861
- Skill("orchestrate", args="fast-lane")
861
+ Skill("orchestrate", args="fast-lane --auto")
862
862
 
863
863
  # Terminal-state verification after orchestrate (fallback path).
864
864
  # Re-query GitHub state for each pre-dispatch issue.
@@ -883,7 +883,7 @@ To approve: remove \`needs-human\` label from #${ISSUE_NUM} and re-run \`/autopi
883
883
  if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
884
884
  echo "[DRY-RUN] Would dispatch each fast-lane issue via: forgedock run-issue <issue> --lane staging"
885
885
  else
886
- echo "[DRY-RUN] Would invoke: Skill(orchestrate, fast-lane)"
886
+ echo "[DRY-RUN] Would invoke: Skill(orchestrate, fast-lane --auto)"
887
887
  fi
888
888
  fi
889
889
 
@@ -1112,7 +1112,7 @@ To approve: remove \`needs-human\` label from #${ISSUE_NUM} and re-run \`/autopi
1112
1112
  2>/dev/null || echo '')
1113
1113
  for N in $MS_PRE_DISPATCH; do DISPATCHED_ISSUES+=("$N"); done
1114
1114
 
1115
- Skill("orchestrate", args="milestone $MS_SLUG")
1115
+ Skill("orchestrate", args="milestone $MS_SLUG --auto")
1116
1116
 
1117
1117
  # Terminal-state verification after orchestrate fallback.
1118
1118
  echo "Verifying terminal state for $( echo "$MS_PRE_DISPATCH" | wc -w | tr -d ' ') dispatched milestone issue(s) (orchestrate path)..."
@@ -1136,7 +1136,7 @@ To approve: remove \`needs-human\` label from #${ISSUE_NUM} and re-run \`/autopi
1136
1136
  if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
1137
1137
  echo "[DRY-RUN] Would dispatch each milestone issue via: forgedock run-issue <issue> --lane milestone/$MS_SLUG"
1138
1138
  else
1139
- echo "[DRY-RUN] Would invoke: Skill(orchestrate, milestone $MS_SLUG)"
1139
+ echo "[DRY-RUN] Would invoke: Skill(orchestrate, milestone $MS_SLUG --auto)"
1140
1140
  fi
1141
1141
  fi
1142
1142
  else
@@ -386,7 +386,7 @@ Eligibility is **default-batchable**: any open `review-finding` + a P3 priority
386
386
  ```bash
387
387
  # Find all open, unbatched P3 review findings. Excludes findings already
388
388
  # claimed by a batch ("batch" label), and applies the same extended safety
389
- # exclusions (security, billing, anti-bot, auth) as the other two batching
389
+ # billing exclusion and security-class batching rules as the other two batching
390
390
  # sites, using the identical jq test() (Oniguruma) patterns so classification
391
391
  # cannot diverge between the three sweep locations. <!-- Changed: forge#1828 -->
392
392
  # NOTE: "priority:P3" is intentionally NOT in the --label filter — GH label filters are
@@ -423,10 +423,15 @@ UNBATCHED_P3=$(gh issue list {GH_FLAG} \
423
423
  --json number,title,body,labels,createdAt \
424
424
  --jq '.[] | select([.labels[].name] | any(test("^(priority:)?P3$")))
425
425
  | select(([.labels[].name] | any(. == "batch")) | not)
426
- | select((.title | test("\\b(security|billing|anti-bot|auth|authentication|authorization|authn|authz)\\b"; "i")) | not)
426
+ | select((.title | test("\\b(billing|operator-only|manual action required|human action required)\\b"; "i")) | not)
427
427
  | (.body | gsub("(?m)^\\*\\*(?:Confidence\\*\\*: (?:CONFIRMED|LIKELY|POSSIBLE)|Severity\\*\\*: (?:CRITICAL|HIGH|MEDIUM|LOW|INFO)|Review comment\\*\\*: https?://\\S+)$"; "")) as $stripped_body
428
- | select($stripped_body | test("## Problem[\\s\\S]{0,500}\\b(security|billing|anti-bot|auth|authentication|authorization|authn|authz)\\b"; "i") | not)
429
- | select(([.labels[].name] | any(. == "security" or . == "billing" or . == "anti-bot" or . == "auth")) | not)')
428
+ | select($stripped_body | test("## Problem[\\s\\S]{0,500}\\b(billing|operator-only|manual action required|human action required)\\b"; "i") | not)
429
+ | select(([.labels[].name] | any(. == "billing" or . == "needs-human" or . == "blocked" or . == "operator-only")) | not)')
430
+
431
+ # Classify each remaining finding with admission.mjs's classifyBatchSafety(). A
432
+ # non-null, non-billing class may share a batch only with that exact class, has
433
+ # a three-member cap, and requires a `live vector`/`defence-in-depth` verdict
434
+ # beside every member in the generated batch body. <!-- Added: forge#2859 -->
430
435
 
431
436
  echo "Unbatched batchable P3 findings: $(echo "$UNBATCHED_P3" | jq -s 'length')"
432
437
  ```
@@ -515,7 +520,7 @@ echo "$DIR_ISSUES" | while IFS=: read -r dir issue_list; do
515
520
  done
516
521
  ```
517
522
 
518
- **Batch creation** uses the same template as `orchestrate.md Phase 1 → P3 Review-Finding Batching`, including its surface-area path sanitization (`tr -cd 'A-Za-z0-9._/-'` on the file/directory value before interpolating it into the batch issue's `--title`/`--body`). After creating the batch issue, add the `batch` label to each member issue to prevent re-batching on the next sweep.
523
+ **Batch creation** uses the same template and bounded generation policy as `orchestrate.md Phase 1 → P3 Review-Finding Batching`, including its surface-area path sanitization (`tr -cd 'A-Za-z0-9._/-'` on the file/directory value before interpolating it into the batch issue's `--title`/`--body`). Compute every member's generation, exclude members above `orchestration.cascade.batch_max_generation` (default `2`), and record the retained maximum with `FORGE:BATCH_MAX_GENERATION`; list every generation-2-or-higher member in the body. After creating the batch issue, add the `batch` label to each member issue to prevent re-batching on the next sweep.
519
524
 
520
525
  ### Step 4B.4: Report
521
526
 
package/commands/issue.md CHANGED
@@ -13,7 +13,7 @@ You create GitHub issues with the exact structure the `/work-on` pipeline expect
13
13
 
14
14
  `/issue` is the structured create-hook for issue creation across the pipeline — it enforces the canonical template, reads code before drafting, runs dedup, and validates mandatory sections. Because those checks are deterministic, `/issue` **creates the issue by default once they pass** — it does not wait for a human to approve the draft. Pass `--dry-run` to review the draft without creating anything (see Argument Parsing below).
15
15
 
16
- `/issue` also accepts a **programmatic invocation form** for callers that have already composed a title, body, labels, and (optionally) a milestone — e.g. `Skill(skill="issue", args="--title \"fix: ...\" --body-file \"$(mktemp)\" --label bug --label P2")` (the caller writes the body to that path before invoking). This form skips the free-text parsing (Phase 1) and LLM drafting (Phase 3) entirely, but still runs the same dedup and body-validation correctness gates as the interactive path. See **Programmatic Invocation Contract** below — in particular, `--body-file` callers MUST use a `mktemp`-created path, never a fixed literal like `/tmp/body.md`; concurrent orchestrated agents share this host's `/tmp`, and a fixed path can be clobbered mid-flight by another agent (forge#2198).
16
+ `/issue` also accepts a **programmatic invocation form** for callers that have already composed a title, body, labels, and (optionally) a milestone — e.g. `Skill(skill="issue", args="--title \"fix: ...\" --body-file \"$BODY_FILE\" --label bug --label P2")` (the caller writes the body to that path before invoking). This form skips the free-text parsing (Phase 1) and LLM drafting (Phase 3) entirely, but still runs the same dedup and body-validation correctness gates as the interactive path. See **Programmatic Invocation Contract** below — every `--body-file` caller must use an entity- and agent-scoped scratch path and include a unique integrity marker which `/issue` verifies after creation. This is required because a collision can substitute plausible but unrelated content, not just produce an obvious file error (forge#2843).
17
17
 
18
18
  **Agent model policy**: `model: "{DEFAULT_MODEL}"` — resolved from forge.yaml `agents.default_model`, else "sonnet" (standard tier). Fallback: `model: "opus"` if rate-limited. Feature gate: pass `effort` in Task/Skill spawns only on Claude Code >= 2.1.154.
19
19
  **NEVER use plan mode (EnterPlanMode).**
@@ -184,9 +184,20 @@ For callers that have already composed a title, body, labels, and (optionally) a
184
184
 
185
185
  If `--title` is present but neither `--body` nor `--body-file` is supplied, or both are supplied, this is a usage error: print `ERROR: programmatic mode requires exactly one of --body or --body-file` and STOP — do not fall through to Phase 2D or Phase 4B.
186
186
 
187
- **`--body-file` path requirement (MANDATORY for the caller)**: The path passed to `--body-file` MUST come from `mktemp` (e.g. `BODY_FILE="$(mktemp)"`), never a fixed literal such as `/tmp/body.md` or `/tmp/issue.json`. Concurrent orchestrated agents (`/orchestrate` dispatches up to `orchestration.max_concurrent` agents, default 12) share this host's `/tmp` — a fixed filename collides across agents, and a second agent's write to the same path can silently corrupt the first agent's staged content before it is read. `mktemp` gives every caller a collision-free path at effectively zero cost. This applies to every `--body-file` caller across the pipeline (`commands/work-on.md`, `commands/work-on/decompose.md`, `commands/review-pr*.md`, `commands/orchestrate/phase-1-resolve.md`, and all others) — all current callers already comply; this is the stated contract that keeps it that way (forge#2198).
187
+ **`--body-file` integrity contract (MANDATORY for the caller)**: Do not use a generic name under shared `/tmp`, even with `mktemp`. Prefer the session scratchpad (or a repo-relative scratch directory) because a Windows-native `gh` may not resolve Git Bash `/tmp` as expected. Never hand-roll a temporary path or use a single-segment root path such as `/tmp_invbody_31076.txt`, which can require interactive deletion approval and hang unattended runs. Create the directory if needed, make the filename include the target issue or PR number and an agent-unique token, then let `mktemp` add its random suffix:
188
188
 
189
- **Fail-loud read ordering (the defensive mechanism against a clobbered body-file)**: `/issue` reads `$PROGRAMMATIC_BODY_FILE` exactly once, into memory, immediately below — it never re-reads the file later in the pipeline. This is deliberate: combined with a `mktemp`-unique path, it minimizes the window in which another process could overwrite the file between the caller's write and this read to effectively nil (no other agent has any reason to target a `mktemp`-generated path). If the file is missing or unreadable at read time, the check below fails loudly (`exit 1`) rather than silently proceeding with an empty or partial body — a clobbered/missing body-file is surfaced as an error, not pushed to GitHub.
189
+ ```bash
190
+ SCRATCHPAD="${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}"
191
+ AGENT_TOKEN="${AGENT_ID:-${HOSTNAME:-agent}-$$}"
192
+ mkdir -p "$SCRATCHPAD"
193
+ BODY_MARKER="FORGE:BODY-INTEGRITY:${PR_NUMBER}_review_${AGENT_TOKEN}"
194
+ BODY_FILE=$(mktemp "$SCRATCHPAD/${PR_NUMBER}_review_${AGENT_TOKEN}.XXXXXX.md")
195
+ printf '%s\n<!-- %s -->\n' "$BODY" "$BODY_MARKER" > "$BODY_FILE"
196
+ ```
197
+
198
+ The body MUST contain exactly one caller-chosen marker in the form `<!-- FORGE:BODY-INTEGRITY:<entity>_<role>_<agent-token> -->`. A unique path reduces collisions; it does not prove that the intended content reached GitHub. The caller must retain the marker until the write is verified. A collision can substitute plausible-looking content from another agent, so an eyeball check is not a sufficient backstop.
199
+
200
+ **Fail-loud read and verification**: `/issue` reads `$PROGRAMMATIC_BODY_FILE` once into memory below. For `--body-file`, it also extracts the required marker and, after `gh issue create`, re-reads the created issue and requires that exact marker. Missing, unreadable, absent, or ambiguous markers are hard errors; do not create or report success with unverified content.
190
201
 
191
202
  ```bash
192
203
  if [ "$PROGRAMMATIC_MODE" = "true" ]; then
@@ -208,6 +219,12 @@ if [ "$PROGRAMMATIC_MODE" = "true" ]; then
208
219
  exit 1
209
220
  fi
210
221
  PROGRAMMATIC_BODY=$(cat "$PROGRAMMATIC_BODY_FILE")
222
+ mapfile -t PROGRAMMATIC_BODY_MARKERS < <(printf '%s' "$PROGRAMMATIC_BODY" | grep -oE '<!-- FORGE:BODY-INTEGRITY:[^>]+ -->')
223
+ if [ "${#PROGRAMMATIC_BODY_MARKERS[@]}" -ne 1 ]; then
224
+ echo "ERROR: --body-file must contain exactly one FORGE:BODY-INTEGRITY marker"
225
+ exit 1
226
+ fi
227
+ PROGRAMMATIC_BODY_MARKER="${PROGRAMMATIC_BODY_MARKERS[0]}"
211
228
  fi
212
229
  fi
213
230
  ```
@@ -354,11 +371,14 @@ DEDUP_EXIT=$?
354
371
  if [ "$DEDUP_EXIT" -eq 1 ]; then
355
372
  echo "Near-duplicate detected: $DEDUP_RESULT"
356
373
  echo "Existing issue found — do NOT create a new one."
357
- # STOP here and report to user (see handling rules below)
374
+ DEDUP_NUMBER=$(printf '%s\n' "$DEDUP_RESULT" | grep -oE '#[0-9]+' | head -1 | tr -d '#')
375
+ [ -n "$DEDUP_NUMBER" ] || { echo "ERROR: dedup STOP returned no issue number." >&2; exit 2; }
376
+ echo "ISSUE_CREATE_RESULT:DEDUP number=${DEDUP_NUMBER}"
377
+ exit 1 # STOP here and report the explicit dedup result to the caller
358
378
  elif [ "$DEDUP_EXIT" -eq 2 ]; then
359
379
  echo "Dedup check usage error: $DEDUP_RESULT"
360
380
  echo "Do NOT proceed to issue creation — fix the invocation and retry."
361
- # STOP here — do not fall through to gh issue create
381
+ exit 2 # STOP here — do not fall through to issue creation
362
382
  fi
363
383
  ```
364
384
 
@@ -639,57 +659,97 @@ Re-run without --dry-run to create this issue.
639
659
 
640
660
  ### 4B: Create the issue
641
661
 
642
- ```bash
643
- gh issue create {GH_FLAG} \
644
- --title "{TITLE}" \
645
- --label "{PRIORITY_LABEL},{CATEGORY_LABEL}" \
646
- --body "$(cat <<'ISSUE_EOF'
647
- {FULL_BODY}
648
- ISSUE_EOF
649
- )"
650
- ```
662
+ **Never use `gh issue create` for this step.** GitHub CLI can report success with
663
+ empty output when GitHub rejects content creation with a secondary-rate-limit 403.
664
+ Use `gh api --method POST`, which preserves that API failure, and require a unique
665
+ token to survive a read-back before treating the issue as created. The token is
666
+ generated by this caller, not inferred from a title search, so a similarly titled
667
+ existing issue cannot be mistaken for the new one.
651
668
 
652
- If milestone was identified:
653
669
  ```bash
654
- gh issue create {GH_FLAG} \
655
- --title "{TITLE}" \
656
- --label "{PRIORITY_LABEL},{CATEGORY_LABEL}" \
657
- --milestone "{MILESTONE_TITLE}" \
658
- --body "$(cat <<'ISSUE_EOF'
659
- {FULL_BODY}
660
- ISSUE_EOF
661
- )"
670
+ CREATE_TOKEN="forge-create-$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM"
671
+ CREATE_BODY="${FULL_BODY}
672
+
673
+ <!-- issue-create-token:${CREATE_TOKEN} -->"
674
+ CREATE_RESPONSE=$(gh api "repos/${GH_REPO}/issues" --method POST \
675
+ -f title="{TITLE}" -f body="$CREATE_BODY" \
676
+ -f 'labels[]={PRIORITY_LABEL}' -f 'labels[]={CATEGORY_LABEL}') || {
677
+ echo "ERROR: GitHub rejected issue creation; no issue was created." >&2
678
+ exit 1
679
+ }
680
+ NEW_NUMBER=$(echo "$CREATE_RESPONSE" | jq -r '.number // empty')
681
+ [ -n "$NEW_NUMBER" ] || { echo "ERROR: issue creation returned no issue number." >&2; exit 1; }
682
+ CREATED_BODY=$(gh issue view "$NEW_NUMBER" {GH_FLAG} --json body --jq '.body') || exit 1
683
+ printf '%s' "$CREATED_BODY" | grep -qF "issue-create-token:${CREATE_TOKEN}" || {
684
+ echo "ERROR: issue #${NEW_NUMBER} did not contain this create token; refusing to report success." >&2
685
+ exit 1
686
+ }
687
+ if [ -n "${MILESTONE_TITLE:-}" ]; then
688
+ [ "${DRY_RUN:-false}" != "true" ] || { echo "ERROR: refusing milestone edit during dry run." >&2; exit 1; }
689
+ gh issue edit "$NEW_NUMBER" {GH_FLAG} --milestone "$MILESTONE_TITLE" || exit 1
690
+ fi
662
691
  ```
663
692
 
664
- **Programmatic mode variable mapping**: `{TITLE}` = `$PROGRAMMATIC_TITLE`; `{FULL_BODY}` = `$PROGRAMMATIC_BODY` (post-Phase-3F); the milestone branch is used iff `$PROGRAMMATIC_MILESTONE` is non-empty, with `{MILESTONE_TITLE}` = `$PROGRAMMATIC_MILESTONE`. This is the same `gh issue create` call the interactive path uses — no new command surface, only a new source for the variables.
693
+ **Programmatic mode variable mapping**: `{TITLE}` = `$PROGRAMMATIC_TITLE`; `{FULL_BODY}` = `$PROGRAMMATIC_BODY` (post-Phase-3F); the milestone branch is used iff `$PROGRAMMATIC_MILESTONE` is non-empty, with `{MILESTONE_TITLE}` = `$PROGRAMMATIC_MILESTONE`. This is the same verified REST-create path the interactive mode uses — no new command surface, only a new source for the variables.
665
694
 
666
- `{PRIORITY_LABEL},{CATEGORY_LABEL}` in the interactive-mode template above stands for `PROGRAMMATIC_LABELS[@]`, but the actual `--label` flag(s) passed to `gh issue create` are built as one repeatable `--label` per array element — NOT a single comma-joined string. `gh issue create --label` itself comma-splits its argument, so joining `PROGRAMMATIC_LABELS[@]` with `,` and passing it as one value would incorrectly fragment any individual label that contains a literal comma (see forge#2097). `--label` is a repeatable flag — passing it multiple times accumulates labels without any join/split ambiguity. Zero labels is valid and must omit `--label` entirely (an empty `--label ""` would fail):
695
+ `{PRIORITY_LABEL},{CATEGORY_LABEL}` in the interactive-mode template above stands for `PROGRAMMATIC_LABELS[@]`. REST API labels are passed as repeated `labels[]` fields, so labels remain distinct and zero labels need no field:
667
696
 
668
697
  ```bash
669
- LABEL_FLAG=()
698
+ CREATE_TOKEN="forge-create-$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM"
699
+ CREATE_BODY="${PROGRAMMATIC_BODY}
700
+
701
+ <!-- issue-create-token:${CREATE_TOKEN} -->"
702
+ API_LABELS=()
670
703
  for label in "${PROGRAMMATIC_LABELS[@]}"; do
671
- LABEL_FLAG+=(--label "$label")
704
+ API_LABELS+=(-f "labels[]=$label")
672
705
  done
673
-
706
+ CREATE_RESPONSE=$(gh api "repos/${GH_REPO}/issues" --method POST \
707
+ -f title="$PROGRAMMATIC_TITLE" -f body="$CREATE_BODY" "${API_LABELS[@]}") || exit 1
708
+ NEW_NUMBER=$(echo "$CREATE_RESPONSE" | jq -r '.number // empty')
709
+ [ -n "$NEW_NUMBER" ] || { echo "ERROR: issue creation returned no issue number." >&2; exit 1; }
710
+ CREATED_BODY=$(gh issue view "$NEW_NUMBER" {GH_FLAG} --json body --jq '.body') || exit 1
711
+ printf '%s' "$CREATED_BODY" | grep -qF "issue-create-token:${CREATE_TOKEN}" || {
712
+ echo "ERROR: issue #${NEW_NUMBER} did not contain this create token; refusing to report success." >&2
713
+ exit 1
714
+ }
674
715
  if [ -n "$PROGRAMMATIC_MILESTONE" ]; then
675
- gh issue create {GH_FLAG} --title "$PROGRAMMATIC_TITLE" "${LABEL_FLAG[@]}" \
676
- --milestone "$PROGRAMMATIC_MILESTONE" --body "$PROGRAMMATIC_BODY"
677
- else
678
- gh issue create {GH_FLAG} --title "$PROGRAMMATIC_TITLE" "${LABEL_FLAG[@]}" \
679
- --body "$PROGRAMMATIC_BODY"
716
+ [ "${DRY_RUN:-false}" != "true" ] || { echo "ERROR: refusing milestone edit during dry run." >&2; exit 1; }
717
+ gh issue edit "$NEW_NUMBER" {GH_FLAG} --milestone "$PROGRAMMATIC_MILESTONE" || exit 1
680
718
  fi
681
719
  ```
682
720
 
683
721
  ### 4C: Post-creation verification
684
722
 
685
723
  ```bash
686
- # Verify the issue was created correctly
687
- ISSUE_URL=$(gh issue view {NEW_NUMBER} {GH_FLAG} --json url --jq '.url')
724
+ # The mandatory create-token read-back in 4B is the success gate. This read
725
+ # obtains report metadata only; it must not replace that gate with a title search.
726
+ ISSUE_URL=$(gh issue view "$NEW_NUMBER" {GH_FLAG} --json url --jq '.url')
688
727
  LABELS=$(gh issue view {NEW_NUMBER} {GH_FLAG} --json labels --jq '[.labels[].name] | join(", ")')
689
728
  echo "Created: ${ISSUE_URL}"
690
729
  echo "Labels: ${LABELS}"
730
+ echo "ISSUE_CREATE_RESULT:CREATED number=${NEW_NUMBER} url=${ISSUE_URL}"
731
+ ```
732
+
733
+ **`--body-file` integrity read-back (MANDATORY)**: Immediately after every programmatic creation sourced from `--body-file`, re-read the issue body and assert the caller's exact marker is present. This is a hard error, not a warning: a missing marker means the write may have received another agent's plausible-looking body.
734
+
735
+ ```bash
736
+ if [ -n "$PROGRAMMATIC_BODY_FILE" ]; then
737
+ CREATED_BODY=$(gh issue view {NEW_NUMBER} {GH_FLAG} --json body --jq '.body') || exit 1
738
+ if ! printf '%s' "$CREATED_BODY" | grep -Fqx "$PROGRAMMATIC_BODY_MARKER"; then
739
+ echo "ERROR: created issue body is missing expected integrity marker $PROGRAMMATIC_BODY_MARKER" >&2
740
+ exit 1
741
+ fi
742
+ fi
691
743
  ```
692
744
 
745
+ ### Content-Creation Rule
746
+
747
+ Apply the same fail-closed pattern to `gh issue comment`, `gh pr comment`, and
748
+ `gh pr create`: use `gh api --method POST`, include a caller-generated token in
749
+ the submitted body, capture the returned object ID/number, then GET that exact
750
+ object and assert the token is present. Empty output, a missing ID, or a failed
751
+ read-back is a hard error, never a successful no-op or a search-indexing retry.
752
+
693
753
  ### 4C.5: Body Validation (MANDATORY)
694
754
 
695
755
  After creation, verify the issue body contains the three mandatory pipeline sections. If any are missing, add placeholder content so downstream pipeline agents have the correct scaffolding.