forgedock 1.7.1 → 1.8.1

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 (48) 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/upgrade-deps.md +1 -1
  31. package/commands/work-on/build/validate.md +25 -4
  32. package/commands/work-on/close.md +40 -10
  33. package/commands/work-on/decompose.md +33 -1
  34. package/commands/work-on/investigate.md +33 -0
  35. package/commands/work-on/remediate.md +40 -5
  36. package/commands/work-on/review.md +27 -0
  37. package/commands/work-on.md +48 -5
  38. package/docs/CONFIG.md +45 -10
  39. package/docs/OPENCODE.md +21 -6
  40. package/docs/spec/review-protocol.md +7 -0
  41. package/package.json +1 -1
  42. package/scripts/check-admission-parity.mjs +1 -0
  43. package/scripts/check-command-side-effects.sh +14 -13
  44. package/scripts/check-shell-portability.sh +36 -0
  45. package/scripts/check-spec-markers.sh +21 -11
  46. package/scripts/checkbox-sections.test.sh +67 -0
  47. package/scripts/extract-affected-files.sh +85 -5
  48. package/scripts/extract-affected-files.test.sh +154 -2
@@ -273,7 +273,7 @@ If detected, dispatch immediately and STOP — do NOT fall through to Phase 0B's
273
273
  Skill(skill="work-on/remediate", args="${REMEDIATE_PR_NUMBER} ${REMEDIATE_ISSUE_FLAG} --repo {GH_REPO} --gh-flag {GH_FLAG}")
274
274
  ```
275
275
 
276
- **After `REMEDIATE_RESULT` returns, STOP unconditionally** — do not run any further Phase 0–7 logic in this file. `work-on/remediate.md` is self-contained: when `re_gate_outcome: AUTO-LANDED`, it drives its own close phase internally (Phase M8 invokes `Skill("work-on:close", ...)` directly) before returning. For every other outcome (`HELD-AWAITING-MERGE`, `RE-ESCALATED`, `UNFIXABLE`, `BLOCKED`, `ALREADY_DONE`), the issue is already at a terminal state (`workflow:awaiting-merge` or `needs-human`, or already closed) per the Universal Phase Dispatcher — nothing further to do.
276
+ **After `REMEDIATE_RESULT` returns, STOP unconditionally** — do not run any further Phase 0–7 logic in this file. `work-on/remediate.md` is self-contained: a FIXABLE remediation replaces `needs-human` with the active `workflow:in-review` state only while it is running, then ends at `workflow:merged`, `workflow:awaiting-merge`, or a newly asserted `needs-human` label. When `re_gate_outcome: AUTO-LANDED`, it drives its own close phase internally (Phase M8 invokes `Skill("work-on:close", ...)` directly) before returning. For every other outcome (`HELD-AWAITING-MERGE`, `RE-ESCALATED`, `UNFIXABLE`, `BLOCKED`, `ALREADY_DONE`), the issue is already at a terminal state (`workflow:awaiting-merge` or `needs-human`, or already closed) per the Universal Phase Dispatcher — nothing further to do.
277
277
 
278
278
  This mode is reachable both standalone (a human or script running `/work-on <pr> --remediate` directly) and via the orchestrator (`commands/orchestrate/phase-4-execution.md` item 6.4 auto-dispatches the identical `Skill(skill='work-on', args='{PR} --remediate --issue {N} ...')` invocation against a `needs-human`-gated predecessor's own PR).
279
279
 
@@ -358,9 +358,21 @@ fi
358
358
 
359
359
  **Batch issue pipeline rules** (when `IS_BATCH=true`):
360
360
  - Build phases execute exactly as normal (the batch issue body IS the spec for what to fix)
361
- - After successful merge, auto-close all member issues with a cross-reference:
361
+ - Batch members are referenced in the PR body with `Refs #N`, never `Closes #N`; the batch issue is the only issue the PR may close.
362
+ - After successful merge, re-read every member's live state and labels before closing it. A member that is `needs-human`, `blocked`, or `operator-only` remains open and is reported as a split outcome:
362
363
  ```bash
363
364
  for MEMBER in "${BATCH_MEMBERS[@]}"; do
365
+ MEMBER_SNAPSHOT=$(gh issue view "$MEMBER" {GH_FLAG} --json state,labels \
366
+ --jq '{state: .state, labels: [.labels[].name]}' 2>/dev/null) || {
367
+ echo "WARNING: could not verify batch member #${MEMBER}; leaving it open"
368
+ continue
369
+ }
370
+ MEMBER_GATED=$(echo "$MEMBER_SNAPSHOT" | jq -r \
371
+ '(.state != "OPEN") or ([.labels[] | select(. == "needs-human" or . == "blocked" or . == "operator-only")] | length > 0)')
372
+ if [ "$MEMBER_GATED" = "true" ]; then
373
+ echo "SPLIT OUTCOME: #${MEMBER} remains open because it requires a human or operator action."
374
+ continue
375
+ fi
364
376
  gh issue close "$MEMBER" {GH_FLAG} \
365
377
  --comment "Resolved as part of batch PR #{PR_NUMBER} (#{ISSUE_NUMBER}). See batch issue for details."
366
378
  gh issue edit "$MEMBER" {GH_FLAG} --add-label "workflow:merged" 2>/dev/null || true
@@ -2081,11 +2093,42 @@ gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:CHECKPOINT -->
2081
2093
 
2082
2094
  ```bash
2083
2095
  BODY=$(gh issue view {NUMBER} {GH_FLAG} --json body --jq '.body')
2084
- REMAINING_BEFORE=$(echo "$BODY" | grep -c '^- \[ \]' || true)
2085
- HAS_PHASE_HEADINGS=$(echo "$BODY" | grep -cP '^#{2,3} ' || true)
2096
+ REMAINING_BEFORE=$(printf '%s\n' "$BODY" | grep -cE '^[-*+] \[ \]' || true)
2097
+
2098
+ # Structural test: count heading-delimited sections that contain checkbox items.
2099
+ # Multi-phase == 2+ checkbox-bearing sections. A single checkbox group is
2100
+ # single-phase regardless of how many prose headings surround it.
2101
+ #
2102
+ # - Fenced code blocks are stripped first: issue bodies routinely embed fenced
2103
+ # blocks whose lines start with '#' or contain a literal '- [ ]'. An
2104
+ # unterminated fence keeps the original body so later work is never hidden.
2105
+ # - ATX headings and setext underlines both delimit sections. The ATX pattern
2106
+ # avoids awk interval-quantifier variance across awk implementations.
2107
+ # - grep -E / awk only — no PCRE. '^#+ ' needs none.
2108
+ FENCE_COUNT=$(printf '%s\n' "$BODY" | grep -cE '^(```+|~~~+)' || true)
2109
+ if [ $(( ${FENCE_COUNT:-0} % 2 )) -ne 0 ]; then
2110
+ BODY_STRIPPED="$BODY"
2111
+ else
2112
+ BODY_STRIPPED=$(printf '%s\n' "$BODY" | awk '/^(```+|~~~+)/{f=!f; next} !f')
2113
+ fi
2114
+
2115
+ CHECKBOX_SECTIONS=$(printf '%s\n' "$BODY_STRIPPED" | awk '
2116
+ /^#+ / { if (in_section && has) n++; in_section=1; has=0; previous=""; next }
2117
+ /^(=+|-+)$/ && previous != "" { if (in_section && has) n++; in_section=1; has=0; previous=""; next }
2118
+ { if (in_section && /^[-*+] \[[ xX]\]/) has=1; previous=$0 }
2119
+ END { if (in_section && has) n++; print n+0 }
2120
+ ')
2121
+
2122
+ # Sub-issue-tracker guard: a decompose parent whose only checkbox group is
2123
+ # '## Sub-Issue Tracker' counts 1 section. Checking those off would mark open
2124
+ # sub-issues done and close the tracker, so any unchecked GFM task item for an
2125
+ # issue forces multi-phase.
2126
+ SUBISSUE_ITEMS=$(printf '%s\n' "$BODY_STRIPPED" | grep -cE '^[-*+] \[ \] #[0-9]+' || true)
2086
2127
  ```
2087
2128
 
2088
- If multi-phase (`HAS_PHASE_HEADINGS > 0` AND `REMAINING_BEFORE > 0`): do NOT check off future phase items. Add PR reference only.
2129
+ **Sync invariant:** Keep the structural computation above and its consuming multi-phase guard in `commands/work-on/close.md` Phase C1 synchronized. The guard is `CHECKBOX_SECTIONS >= 2 OR SUBISSUE_ITEMS > 0`; this Phase 6A path is reached only when `REMAINING_BEFORE > 0`. <!-- Fixed: forge#2840, #2874 -->
2130
+
2131
+ If multi-phase (`CHECKBOX_SECTIONS >= 2` OR a `- [ ] #NNN` sub-issue item is present): do NOT check off future phase items. Add PR reference only. A heading count is **not** the test — every templated issue carries `## Problem`/`## Evidence`/`## Context`, so it is `> 0` universally.
2089
2132
 
2090
2133
  If single-phase or final phase: check off all `[ ]` items, add PR reference.
2091
2134
 
package/docs/CONFIG.md CHANGED
@@ -298,9 +298,13 @@ Concurrency limits and cascade admission policy for `/orchestrate`'s batch engin
298
298
 
299
299
  ```yaml
300
300
  orchestration:
301
- # Maximum number of /work-on sub-agents dispatched concurrently. Guards against
302
- # saturating the Anthropic API rate limit in one burst on a large ready set.
303
- # Default: 12.
301
+ # Maximum number of top-level /work-on agents dispatched concurrently. Each
302
+ # worker normally fans out to about 8 total subagent spawns (build, quality
303
+ # gate, and review included), so set this to no more than session_budget / 8.
304
+ # Default: 12; use 25 or fewer for a 200-spawn session budget. This limits
305
+ # in-flight workers only: under cascade.policy: all, raising it increases
306
+ # both completion and finding-spawn throughput and does not itself bound or
307
+ # shrink cascade queue growth.
304
308
  max_concurrent: 12
305
309
 
306
310
  cascade:
@@ -324,13 +328,26 @@ orchestration:
324
328
  # spawned from a review-finding. Accepts a positive integer or the literal
325
329
  # string "unlimited". Governs Phase 1 resolve-time admission only (the
326
330
  # `cascade`/`review-findings`/`findings` resolution in phase-1-resolve.md)
327
- # — it does NOT relax Step 4C's absolute autonomous-cascade defer, which
328
- # stays hardcoded regardless of this value (see phase-4-execution.md Step
329
- # 4C rule 1). This is what makes "admit gen-2, stop at gen-3" expressible,
330
- # which the pre-#2234 --allow-gen2 flag (all-or-nothing) could not say.
331
+ # — it does NOT individually dispatch deferred generation-2+ findings in
332
+ # Step 4C. Bounded P3 batching is the sole automated exception: it may
333
+ # aggregate a finite generation-2 member set under batch_max_generation,
334
+ # as implemented by #2849. This is what makes "admit gen-2, stop at gen-3"
335
+ # expressible, which the pre-#2234 --allow-gen2 flag could not say.
331
336
  # Default: 1 (from the "balanced" preset); "unlimited" under "all".
332
337
  max_generation: 1
333
338
 
339
+ # Maximum generation a P3 batch may aggregate. Batching is a sanctioned,
340
+ # auditable exception to Step 4C's autonomous cascade cap: it can fold
341
+ # several deferred low-priority findings into one reviewed unit, but it
342
+ # must never make recursion unbounded. Every batch records the maximum
343
+ # member generation in FORGE:BATCH_MAX_GENERATION. This value is always
344
+ # finite, even under policy: all. Default: 2.
345
+ batch_max_generation: 2
346
+
347
+ # Permit closure only of mechanically identical bot/app alerts: same
348
+ # normalized title, generator, and trigger condition, while retaining one
349
+ # canonical issue. Human reports are never included. Default: false.
350
+ dedup_automated: false
334
351
  # Per-batch token ceiling for Step 4C's review-finding cascade dispatch.
335
352
  # New home for the same lever previously read only as
336
353
  # `pipeline.token_budget_per_batch` (kept working as a deprecated alias —
@@ -353,17 +370,30 @@ orchestration:
353
370
  # (avoids serializing agents on same-file predecessor edges). Default:
354
371
  # true (from "balanced"); false under "all".
355
372
  p3_same_file_defer: true
373
+
374
+ # Running findings-spawned / merged-units ceiling. "off" (the default)
375
+ # only reports amplification; a positive decimal defers further
376
+ # same-lineage refinements to the completion sweep once exceeded.
377
+ max_amplification: "off"
378
+
379
+ # Number of consecutive merged-unit observations at ratio >= 1.0 before
380
+ # printing a convergence warning. Default: 3.
381
+ convergence_window: 3
356
382
  ```
357
383
 
358
384
  | Field | Type | Required | Description |
359
385
  |-------|------|----------|-------------|
360
- | `max_concurrent` | integer | No | Max concurrent `/work-on` sub-agent dispatch. Default: 12 |
386
+ | `max_concurrent` | integer | No | Max concurrent top-level `/work-on` dispatches. It does not bound cascade growth; under `policy: all` it raises drain and spawn throughput together. Default: 12. |
361
387
  | `cascade.policy` | string | No | `all` \| `balanced` \| `conservative`. Default: `balanced` |
362
388
  | `cascade.max_generation` | integer or `"unlimited"` | No | Max cascade generation admitted at Phase 1 resolve time. Default: 1 |
389
+ | `cascade.batch_max_generation` | integer | No | Finite maximum member generation that an automated P3 batch may aggregate. Default: 2 |
390
+ | `cascade.dedup_automated` | boolean | No | Allow only mechanically identical bot/app alerts to be closed with a retained canonical issue. Default: `false` |
363
391
  | `cascade.token_budget` | integer or `"unlimited"` | No | Per-batch token ceiling for Step 4C cascade dispatch. Default: 900000 (deprecated alias: `pipeline.token_budget_per_batch`) |
364
392
  | `cascade.defer_on_batch_gated` | boolean | No | Suppress cascade dispatch when the original batch is fully human-gated. Default: `true` |
365
393
  | `cascade.keyword_heuristic` | boolean | No | Defer P3-and-below comment/typo-titled findings. Default: `true` |
366
394
  | `cascade.p3_same_file_defer` | boolean | No | Defer P3 findings sharing a file with the active batch. Default: `true` |
395
+ | `cascade.max_amplification` | positive number or `"off"` | No | Opt-in ceiling for same-lineage refinement dispatch. Default: `"off"` |
396
+ | `cascade.convergence_window` | positive integer | No | Consecutive ratio observations at or above 1.0 before warning. Default: 3 |
367
397
 
368
398
  **Hard invariant — not configurable**: safety exclusions (findings whose `## Problem` section indicates security/billing/anti-bot/auth concerns) are never batched and never auto-admitted by any `cascade.policy`, including `all`. That exclusion is enforced upstream of this section (the P3 batching eligibility check) and has no corresponding key here by design.
369
399
 
@@ -375,10 +405,14 @@ orchestration:
375
405
 
376
406
  ## `pipeline` (OPTIONAL)
377
407
 
378
- Tuning knobs for `/orchestrate`'s batch engine — stall detection, narration verbosity, and (deprecated alias only, see `orchestration.cascade` above) the review-finding cascade token budget. All keys optional; sane defaults apply when omitted.
408
+ Tuning knobs for the CLI backend and `/orchestrate`'s batch engine — invocation and stall timeouts, narration verbosity, and (deprecated alias only, see `orchestration.cascade` above) the review-finding cascade token budget. All keys optional; sane defaults apply when omitted.
379
409
 
380
410
  ```yaml
381
411
  pipeline:
412
+ # Wall-clock limit for one CLI backend invocation. The environment variable
413
+ # FORGEDOCK_CLI_TIMEOUT_MS (milliseconds) takes precedence. Default: 60.
414
+ cli_timeout_minutes: 60
415
+
382
416
  # Minutes an agent may sit idle (no FORGE:HEARTBEAT / label change) before
383
417
  # /orchestrate's stall detector treats it as stuck and attempts an
384
418
  # auto-resume. Default: 15.
@@ -401,12 +435,13 @@ pipeline:
401
435
 
402
436
  | Field | Type | Required | Description |
403
437
  |-------|------|----------|-------------|
438
+ | `cli_timeout_minutes` | integer | No | Wall-clock minutes for one CLI backend invocation. Default: 60. `FORGEDOCK_CLI_TIMEOUT_MS` (milliseconds) takes precedence. |
404
439
  | `stall_timeout_minutes` | integer | No | Idle minutes before the stall detector attempts auto-resume. Default: 15 |
405
440
  | `token_budget_per_batch` | integer | No | **Deprecated** — use `orchestration.cascade.token_budget`. Default: 900000 |
406
441
  | `token_estimate_per_finding` | integer | No | Estimated tokens per P3-or-lower cascade finding. Default: 150000 |
407
442
  | `narration` | string | No | `terse` \| `verbose`. Default: `terse` |
408
443
 
409
- **Commands that use this section**: `orchestrate`
444
+ **Commands that use this section**: CLI backend (`cli_timeout_minutes`); `orchestrate` (remaining keys)
410
445
 
411
446
  ---
412
447
 
package/docs/OPENCODE.md CHANGED
@@ -64,8 +64,8 @@ The generated plugin has no prompt text. It:
64
64
  preserving explicit lower limits;
65
65
  - grants the built-in `general` subagent permission to invoke native `task`
66
66
  unless the user explicitly configured a task permission;
67
- - opts into OpenCode background subagents by default so each completed issue can
68
- wake the parent orchestrator independently; set
67
+ - opts into OpenCode background subagents for the orchestrator's explicit
68
+ ready-issue dispatches so each completed issue can wake the parent independently; set
69
69
  `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=false` to opt out;
70
70
  - selects Git Bash on Windows only when the user has not explicitly configured
71
71
  an OpenCode shell, because the shared workflows and helper scripts use Bash.
@@ -104,6 +104,14 @@ The adapter follows these rules:
104
104
  - Orchestration dispatches independent issues with `task(background=true)` and
105
105
  processes each injected task-result event immediately; it does not wait for a
106
106
  wave or the slowest sibling.
107
+ - The `/orchestrate` entrypoint reads only runtime config, then runs
108
+ `bin/orchestrate-preflight.mjs` before loading the phase specs. The helper batches the initial GitHub
109
+ snapshot and emits only the compact ready queue needed for the first native task.
110
+ Interactive runs keep the confirmation gate; `--auto` or `--confirm` is the
111
+ explicit headless authorization.
112
+ - The full Phase 3/4 prose is reserved for investigations, unsupported or multi-repo
113
+ queries, explicit deep planning, and recovery after a task-result event. It remains
114
+ authoritative for cascade handling, leases, cleanup, and reporting.
107
115
  - GitHub state and the durable engine remain the recovery source instead of
108
116
  replaying prior prompt context.
109
117
 
@@ -115,12 +123,12 @@ specs evaluate Claude's literal `Task`/`Agent` names, an OpenCode runtime marker
115
123
  selects `DISPATCH_TOOL=task`; the absence of those Claude names must not produce
116
124
  a false `FORGE:REVIEW_BLOCKED` result.
117
125
 
118
- Every isolated review dispatch uses the explicit native argument shape. Every
126
+ Every isolated review dispatch uses a foreground native task. Every
119
127
  native task call must include the schema-required `subagent_type` at the top
120
128
  level alongside its `description` and `prompt`:
121
129
 
122
130
  ```js
123
- { description: "...", prompt: "...", subagent_type: "general", background: true }
131
+ { description: "...", prompt: "...", subagent_type: "general", background: false }
124
132
  ```
125
133
 
126
134
  Implementation and review work uses `general`; read-only discovery uses
@@ -131,8 +139,9 @@ generated adapter safely defaults it to `general`; unsupported types stop with
131
139
  If the native `task` capability itself is unavailable, the workflow posts
132
140
  `FORGE:REVIEW_BLOCKED` and stops rather than falling back to inline review or
133
141
  another pipeline controller.
134
- The generated plugin adds `background: true` unless
135
- `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=false` is explicitly set.
142
+ The generated plugin defaults omitted `background` to `false`. Only the
143
+ orchestrator's ready-issue dispatcher sets `background: true`; setting
144
+ `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=false` forces foreground execution.
136
145
 
137
146
  `commands/work-on.md` is still a large entry dispatcher and is loaded in full,
138
147
  matching the Claude Code path. The adapter prevents additional eager loading,
@@ -218,6 +227,12 @@ Commands that inspect Claude-specific transcripts or Claude installation state
218
227
  remain runtime-specific and should not be represented as portable until they
219
228
  receive dedicated implementations.
220
229
 
230
+ The preflight is intentionally bounded. It handles explicit issue sets and the
231
+ common single-repository `fast-lane`, `milestone`, `next`, `priority`, and
232
+ `no:milestone` queries, plus explicit dependencies and scoped issue-body file
233
+ overlap. Unsupported or complex inputs fall back to the shared phase specs rather
234
+ than silently weakening their safety rules.
235
+
221
236
  ## Orchestration Runtime
222
237
 
223
238
  OpenCode orchestration uses the native OpenCode `task` tool for isolated issue
@@ -122,6 +122,12 @@ Every finding must include:
122
122
 
123
123
  **All review agents MUST include a machine-readable findings block at the end of their PR comment.** This is NON-OPTIONAL. Without structured findings, the review system cannot create GitHub issues, and findings die as unread PR comments. Every finding that doesn't become a GitHub issue is a finding that will never be addressed.
124
124
 
125
+ ### Persist Before Post
126
+
127
+ GitHub is a delivery channel, not the sole record of a review. Before the first `gh pr comment` attempt, write the finalized complete review body, including its `<!-- FORGE:REVIEW-AGENT:{domain} -->` marker and structured findings block, to a uniquely named durable file such as `${TMPDIR:-/tmp}/forge-review-${PR_NUMBER}-${DOMAIN}-$$.md`. Use `gh pr comment --body-file "$REVIEW_BODY_PATH"`; do not construct a retry loop around a failed write.
128
+
129
+ Every agent MUST return its verdict, finding count, and one line per finding to the orchestrator even when posting succeeds. If the post fails, return the durable body path and the same finding summary, then stop. A 403 or other write failure is a failed delivery, not a clean review and not a reason to retry in the background.
130
+
125
131
  ### Format
126
132
 
127
133
  Append this block at the very end of your comment (after the `---` footer line, still inside the EOF heredoc). It uses HTML comments so it's invisible in rendered markdown:
@@ -140,6 +146,7 @@ Append this block at the very end of your comment (after the `---` footer line,
140
146
  6. **Summary**: Concise one-line description (no pipe `|` characters in summary)
141
147
  7. **Empty block**: If no findings at all, include just the START/END markers
142
148
  8. **HTML comments**: The block is invisible in rendered markdown but parseable by the review system
149
+ 9. **Agent marker**: Include exactly one `<!-- FORGE:REVIEW-AGENT:{domain} -->` marker in the persisted body, where `{domain}` is the lowercase dispatched domain.
143
150
 
144
151
  ### Domain Prefixes
145
152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.7.1",
3
+ "version": "1.8.1",
4
4
  "description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"
@@ -47,6 +47,7 @@ function parseArmBody(body) {
47
47
  };
48
48
  return {
49
49
  maxGeneration: get("PRESET_MAX_GEN"),
50
+ batchMaxGeneration: get("PRESET_BATCH_MAX_GEN"),
50
51
  tokenBudget: get("PRESET_TOKEN_BUDGET"),
51
52
  deferOnBatchGated: get("PRESET_DEFER_GATED"),
52
53
  keywordHeuristic: get("PRESET_KEYWORD"),
@@ -120,7 +120,7 @@ class_a_scan() {
120
120
  # accumulated violation silently. (forge#2289 added the equivalent
121
121
  # end-of-file flush for Class B only; this closes the same gap in Class A.)
122
122
  flush_a_block() {
123
- if [ "$HAS_GIST" -eq 1 ] && echo "$BLOCK_LINES" | grep -qE '^[[:space:]]*--public([[:space:]]|$)'; then
123
+ if [ "$HAS_GIST" -eq 1 ] && [[ "$BLOCK_LINES" =~ (^|$'\n')[[:space:]]*--public([[:space:]]|$) ]]; then
124
124
  # Find the line number of --public within the block
125
125
  PUBLIC_LN=$(echo "$BLOCK_LINES" | grep -n '^[[:space:]]*--public' | head -1 | cut -d: -f1)
126
126
  ACTUAL_LINE=$((BLOCK_START + PUBLIC_LN))
@@ -149,9 +149,9 @@ class_a_scan() {
149
149
  # content; forge#2288 adds true depth so 2+ nesting levels close correctly.)
150
150
  IS_FENCE=0
151
151
  IS_BARE=0
152
- if echo "$line" | grep -qE "^[[:space:]]*${FENCE}"; then
152
+ if [[ "$line" =~ ^[[:space:]]*${FENCE} ]]; then
153
153
  IS_FENCE=1
154
- if echo "$line" | grep -qE "^[[:space:]]*${FENCE}[[:space:]]*\$"; then
154
+ if [[ "$line" =~ ^[[:space:]]*${FENCE}[[:space:]]*$ ]]; then
155
155
  IS_BARE=1
156
156
  fi
157
157
  fi
@@ -188,7 +188,7 @@ class_a_scan() {
188
188
  BLOCK_LINES="${BLOCK_LINES}${line}
189
189
  "
190
190
  # Check if this line has gh gist create/edit (even with line continuation \)
191
- if echo "$line" | grep -qE 'gh[[:space:]]+gist[[:space:]]+(create|edit)'; then
191
+ if [[ "$line" =~ gh[[:space:]]+gist[[:space:]]+(create|edit) ]]; then
192
192
  HAS_GIST=1
193
193
  fi
194
194
  done < "$file"
@@ -296,9 +296,9 @@ else
296
296
  # 2+ nesting levels close correctly.)
297
297
  IS_FENCE=0
298
298
  IS_BARE=0
299
- if echo "$line" | grep -qE "^[[:space:]]*${FENCE}"; then
299
+ if [[ "$line" =~ ^[[:space:]]*${FENCE} ]]; then
300
300
  IS_FENCE=1
301
- if echo "$line" | grep -qE "^[[:space:]]*${FENCE}[[:space:]]*\$"; then
301
+ if [[ "$line" =~ ^[[:space:]]*${FENCE}[[:space:]]*$ ]]; then
302
302
  IS_BARE=1
303
303
  fi
304
304
  fi
@@ -314,8 +314,9 @@ else
314
314
  # (forge#2289) and is always already at rest here, since this branch
315
315
  # is only reached when IN_CB is 0 — i.e. any block that was open has
316
316
  # already been closed and flushed at its own fence-close below.
317
- if echo "$line" | grep -qE '^#{1,6}[[:space:]]+'; then
318
- heading=$(echo "$line" | sed 's/^#*[[:space:]]*//' | sed 's/[[:space:]]*$//')
317
+ if [[ "$line" =~ ^#{1,6}[[:space:]]+(.*)$ ]]; then
318
+ heading="${BASH_REMATCH[1]}"
319
+ heading="${heading%"${heading##*[![:space:]]}"}"
319
320
  SECTION="$heading"
320
321
  continue
321
322
  fi
@@ -338,18 +339,18 @@ else
338
339
  fi
339
340
 
340
341
  # Content line while inside (at any depth).
341
- if echo "$line" | grep -qF "$ALLOWLIST_TOKEN"; then continue; fi
342
+ if [[ "$line" == *"$ALLOWLIST_TOKEN"* ]]; then continue; fi
342
343
 
343
344
  # Check for guard — scoped to THIS code block only (forge#2289)
344
- echo "$line" | grep -qE "$GUARD_PATTERN" && BLOCK_HAS_GUARD=1
345
+ [[ "$line" =~ $GUARD_PATTERN ]] && BLOCK_HAS_GUARD=1
345
346
 
346
347
  # Check if this line is in the diff's added lines AND has a side-effect verb
347
- if [ "$BLOCK_HAS_ADDED_SE" -eq 0 ] && echo "$line" | grep -qE "$SIDE_EFFECT_PATTERN"; then
348
+ if [ "$BLOCK_HAS_ADDED_SE" -eq 0 ] && [[ "$line" =~ $SIDE_EFFECT_PATTERN ]]; then
348
349
  # Is this specific line in the added content?
349
- if echo "$ADDED_CONTENT" | grep -qF "${line:0:80}" 2>/dev/null; then
350
+ if [[ "$ADDED_CONTENT" == *"${line:0:80}"* ]]; then
350
351
  BLOCK_HAS_ADDED_SE=1
351
352
  BLOCK_SE_LINE=$LN
352
- BLOCK_SE_VERB=$(echo "$line" | grep -oE "$SIDE_EFFECT_PATTERN" | head -1 || echo "side-effect")
353
+ BLOCK_SE_VERB="${BASH_REMATCH[0]:-side-effect}"
353
354
  fi
354
355
  fi
355
356
  done < "$file"
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # check-shell-portability.sh — Verify shipped shell scripts have an explicit
6
+ # Bash interpreter and parse successfully before a quality gate executes them.
7
+
8
+ set -euo pipefail
9
+
10
+ SCRIPTS_DIR="${1:-./scripts}"
11
+
12
+ if [ ! -d "$SCRIPTS_DIR" ]; then
13
+ echo "ERROR: scripts directory not found: $SCRIPTS_DIR" >&2
14
+ exit 2
15
+ fi
16
+
17
+ violations=0
18
+ while IFS= read -r -d '' script; do
19
+ first_line=$(IFS= read -r line < "$script" || true; printf '%s' "$line")
20
+ if [ "$first_line" != "#!/usr/bin/env bash" ]; then
21
+ echo "HIGH | $script | shell scripts must declare #!/usr/bin/env bash" >&2
22
+ violations=$((violations + 1))
23
+ continue
24
+ fi
25
+ if ! bash -n "$script"; then
26
+ echo "HIGH | $script | shell syntax validation failed" >&2
27
+ violations=$((violations + 1))
28
+ fi
29
+ done < <(find "$SCRIPTS_DIR" -type f -name '*.sh' -print0 | sort -z)
30
+
31
+ if [ "$violations" -gt 0 ]; then
32
+ echo "check-shell-portability: $violations violation(s) found. See stderr for details." >&2
33
+ exit 1
34
+ fi
35
+
36
+ echo "OK: Shell scripts declare Bash and pass syntax validation"
@@ -87,9 +87,11 @@ MARKER_REGISTRY="
87
87
  BATCH_MEMBERS
88
88
  BENCH_SCORECARD
89
89
  BLOCKED_ON_HUMAN_MERGE
90
+ BODY
90
91
  BUILDER
91
92
  CALIBRATION_CHECK
92
93
  CARD
94
+ CLASS
93
95
  CHECKPOINT
94
96
  CLAIM
95
97
  CLAIM_RELEASED
@@ -100,6 +102,7 @@ MARKER_REGISTRY="
100
102
  DECISION_RECORD
101
103
  DECOMPOSED
102
104
  DESIGN
105
+ DISPATCH
103
106
  DISPATCHER
104
107
  DOSSIER_UPDATED
105
108
  ENGINE_FALLBACK
@@ -187,28 +190,29 @@ while IFS= read -r file; do
187
190
  content="${markerline#*:}"
188
191
 
189
192
  # Skip allowlisted lines
190
- if echo "$content" | grep -qF "$ALLOWLIST_TOKEN"; then
193
+ if [[ "$content" == *"$ALLOWLIST_TOKEN"* ]]; then
191
194
  continue
192
195
  fi
193
196
 
194
197
  # Skip markers that are inside code spans (backtick-quoted) — those are docs examples
195
198
  # Heuristic: if the FORGE:TYPE appears between backticks on the same line, skip it
196
199
  # We check for backtick on either side of the FORGE: marker
197
- if echo "$content" | grep -qE "\`[^\`]*FORGE:[A-Z_]"; then
200
+ if [[ "$content" =~ \`[^\`]*FORGE:[A-Z_] ]]; then
198
201
  continue
199
202
  fi
200
203
 
201
204
  # Extract the full marker string after FORGE: — everything up to a space, -->, or end
202
205
  # This captures compound markers like REVIEW-AGENT:material-change
203
- full_marker=$(echo "$content" | grep -oE 'FORGE:[A-Z][A-Z0-9_:-]*' | head -1 | sed 's/^FORGE://')
206
+ [[ "$content" =~ FORGE:([A-Z][A-Z0-9_:-]*) ]] || continue
207
+ full_marker="${BASH_REMATCH[1]}"
204
208
 
205
209
  [ -z "$full_marker" ] && continue
206
210
 
207
211
  # Extract PRIMARY type: part before the first - or : separator
208
- primary_type=$(echo "$full_marker" | sed 's/[-:].*//')
212
+ primary_type="${full_marker%%[-:]*}"
209
213
 
210
214
  # Validate primary type against registry
211
- if ! echo "$primary_type" | grep -qE "$REGISTRY_PATTERN"; then
215
+ if ! [[ "$primary_type" =~ $REGISTRY_PATTERN ]]; then
212
216
  echo "HIGH | $file:$lineno | unknown FORGE: marker 'FORGE:${full_marker}' (primary type '${primary_type}') — not in registry" >&2
213
217
  VIOLATIONS=$((VIOLATIONS + 1))
214
218
  fi
@@ -242,21 +246,27 @@ if [ -d "$WORKFLOWS_DIR" ]; then
242
246
  LINENO=$((LINENO + 1))
243
247
 
244
248
  # Skip allowlisted lines
245
- if echo "$line" | grep -qF "$ALLOWLIST_TOKEN"; then
249
+ if [[ "$line" == *"$ALLOWLIST_TOKEN"* ]]; then
246
250
  continue
247
251
  fi
248
252
 
249
253
  # Skip comment lines
250
- if echo "$line" | grep -qE '^[[:space:]]*#'; then
254
+ if [[ "$line" =~ ^[[:space:]]*# ]]; then
251
255
  continue
252
256
  fi
253
257
 
254
258
  # Strip bash ${VAR} and $VAR patterns before checking for template placeholders
255
259
  # so that ELAPSED in "${ELAPSED}s" doesn't false-positive as {ELAPSED}
256
- stripped=$(echo "$line" | sed 's/\${\([^}]*\)}/BASH_VAR/g; s/\$[A-Za-z_][A-Za-z0-9_]*/BASH_VAR/g')
257
-
258
- if echo "$stripped" | grep -qE "$PLACEHOLDER_PATTERN"; then
259
- placeholder=$(echo "$stripped" | grep -oE "$PLACEHOLDER_PATTERN" | head -1)
260
+ stripped="$line"
261
+ while [[ "$stripped" =~ \$\{[^}]*\} ]]; do
262
+ stripped="${stripped/"${BASH_REMATCH[0]}"/BASH_VAR}"
263
+ done
264
+ while [[ "$stripped" =~ \$[A-Za-z_][A-Za-z0-9_]* ]]; do
265
+ stripped="${stripped/"${BASH_REMATCH[0]}"/BASH_VAR}"
266
+ done
267
+
268
+ if [[ "$stripped" =~ $PLACEHOLDER_PATTERN ]]; then
269
+ placeholder="${BASH_REMATCH[0]}"
260
270
  echo "HIGH | $wf_file:$LINENO | unsubstituted placeholder '$placeholder' in workflow" >&2
261
271
  VIOLATIONS=$((VIOLATIONS + 1))
262
272
  fi
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env bash
2
+ # checkbox-sections.test.sh — Regression tests for the close-path classifier.
3
+ #
4
+ # Usage: bash scripts/checkbox-sections.test.sh
5
+
6
+ set -euo pipefail
7
+
8
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
9
+
10
+ extract_block() {
11
+ awk '/^FENCE_COUNT=/{printing=1} printing{print} /^SUBISSUE_ITEMS=/{exit}' "$1"
12
+ }
13
+
14
+ extract_classifier() {
15
+ awk '
16
+ /^CHECKBOX_SECTIONS=.*awk .$/ { printing=1; next }
17
+ printing && /^\047\)$/ { exit }
18
+ printing { print }
19
+ ' "$1"
20
+ }
21
+
22
+ CLOSE_BLOCK=$(extract_block "$ROOT/commands/work-on/close.md")
23
+ INLINE_BLOCK=$(extract_block "$ROOT/commands/work-on.md")
24
+ [[ "$CLOSE_BLOCK" == "$INLINE_BLOCK" ]] || {
25
+ printf 'FAIL: mirrored classifier blocks differ\n' >&2
26
+ exit 1
27
+ }
28
+
29
+ CLOSE_CLASSIFIER=$(extract_classifier "$ROOT/commands/work-on/close.md")
30
+ INLINE_CLASSIFIER=$(extract_classifier "$ROOT/commands/work-on.md")
31
+ [[ "$CLOSE_CLASSIFIER" == "$INLINE_CLASSIFIER" ]] || {
32
+ printf 'FAIL: mirrored classifier implementations differ\n' >&2
33
+ exit 1
34
+ }
35
+
36
+ if grep -q 'echo "\$BODY\|echo "\$BODY_STRIPPED' \
37
+ "$ROOT/commands/work-on/close.md" "$ROOT/commands/work-on.md"; then
38
+ printf 'FAIL: arbitrary issue bodies must be piped with printf, not echo\n' >&2
39
+ exit 1
40
+ fi
41
+
42
+ grep -Fq 'Keep the structural computation above and its consuming multi-phase guard' "$ROOT/commands/work-on.md" || {
43
+ printf 'FAIL: Phase 6A sync invariant must cover the consuming guard\n' >&2
44
+ exit 1
45
+ }
46
+ grep -Fq 'The guard is `CHECKBOX_SECTIONS >= 2 OR SUBISSUE_ITEMS > 0`' "$ROOT/commands/work-on.md" || {
47
+ printf 'FAIL: Phase 6A must document the Phase C1 guard\n' >&2
48
+ exit 1
49
+ }
50
+ grep -Fq 'if [ "${CHECKBOX_SECTIONS:-0}" -ge 2 ] || [ "${SUBISSUE_ITEMS:-0}" -gt 0 ]; then' "$ROOT/commands/work-on/close.md" || {
51
+ printf 'FAIL: Phase C1 multi-phase guard changed unexpectedly\n' >&2
52
+ exit 1
53
+ }
54
+
55
+ SETEXT_BODY=$'Phase One\n=========\n- [x] complete\n\nPhase Two\n---------\n- [ ] remaining'
56
+ [[ "$(printf '%s\n' "$SETEXT_BODY" | awk "$CLOSE_CLASSIFIER")" == "2" ]] || {
57
+ printf 'FAIL: setext phases must count as two sections\n' >&2
58
+ exit 1
59
+ }
60
+
61
+ SINGLE_BODY=$'## Acceptance Criteria\n- [ ] complete this work'
62
+ [[ "$(printf '%s\n' "$SINGLE_BODY" | awk "$CLOSE_CLASSIFIER")" == "1" ]] || {
63
+ printf 'FAIL: one checkbox-bearing section must remain single-phase\n' >&2
64
+ exit 1
65
+ }
66
+
67
+ printf 'PASS: mirrored classifier supports setext section boundaries\n'