forgedock 1.7.0 → 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.
- package/README.md +2 -1
- package/bin/engine/admission.mjs +242 -7
- package/bin/engine/admission.test.mjs +183 -2
- package/bin/engine/phases.mjs +31 -22
- package/bin/engine/resolve.mjs +8 -1
- package/bin/engine/resolve.test.mjs +5 -0
- package/bin/engine-cli.mjs +2 -0
- package/bin/engine.mjs +26 -17
- package/bin/forgedock.mjs +124 -48
- package/bin/hooks/pre-tool-use.mjs +573 -2
- package/bin/journey.mjs +11 -7
- package/bin/labels.json +5 -0
- package/bin/opencode-adapter.mjs +652 -92
- package/bin/orchestrate-preflight.mjs +434 -0
- package/bin/runner.mjs +77 -9
- package/commands/audit-agents.md +75 -26
- package/commands/autopilot.md +4 -4
- package/commands/cleanup.md +10 -5
- package/commands/issue.md +95 -35
- package/commands/orchestrate/phase-1-resolve.md +70 -32
- package/commands/orchestrate/phase-3-dependency.md +167 -22
- package/commands/orchestrate/phase-4-execution.md +691 -62
- package/commands/orchestrate/phase-5-cleanup.md +5 -11
- package/commands/orchestrate/phase-6-report.md +27 -1
- package/commands/orchestrate/safety.md +1 -1
- package/commands/orchestrate.md +39 -1
- package/commands/quality-gate.md +30 -10
- package/commands/review-pr-agents/protocols.md +13 -0
- package/commands/review-pr-agents.md +8 -0
- package/commands/review-pr-staging.md +117 -17
- package/commands/review-pr.md +95 -23
- package/commands/work-on/build/validate.md +25 -4
- package/commands/work-on/close.md +40 -10
- package/commands/work-on/decompose.md +33 -1
- package/commands/work-on/investigate.md +33 -0
- package/commands/work-on/remediate.md +40 -5
- package/commands/work-on/review.md +27 -0
- package/commands/work-on.md +48 -5
- package/docs/CONFIG.md +51 -15
- package/docs/OPENCODE.md +128 -25
- package/docs/spec/review-protocol.md +7 -0
- package/package.json +1 -1
- package/scripts/check-admission-parity.mjs +1 -0
- package/scripts/check-command-side-effects.sh +14 -13
- package/scripts/check-shell-portability.sh +36 -0
- package/scripts/check-spec-markers.sh +21 -11
- package/scripts/checkbox-sections.test.sh +67 -0
- package/scripts/extract-affected-files.sh +85 -5
- package/scripts/extract-affected-files.test.sh +154 -2
package/commands/audit-agents.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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} (
|
|
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**:
|
|
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`.
|
package/commands/autopilot.md
CHANGED
|
@@ -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
|
package/commands/cleanup.md
CHANGED
|
@@ -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
|
-
#
|
|
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(
|
|
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(
|
|
429
|
-
| select(([.labels[].name] | any(. == "
|
|
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 \"$
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
{
|
|
660
|
-
|
|
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
|
|
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[@]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
676
|
-
|
|
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
|
-
#
|
|
687
|
-
|
|
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.
|