forgedock 1.0.22 → 1.1.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 (46) hide show
  1. package/README.md +217 -218
  2. package/bin/cinema.mjs +320 -0
  3. package/bin/engine/phases.mjs +148 -0
  4. package/bin/engine/projector.mjs +32 -0
  5. package/bin/engine/reconcile.mjs +11 -0
  6. package/bin/engine/runlog.mjs +79 -0
  7. package/bin/engine/state.mjs +27 -0
  8. package/bin/engine-cli.mjs +50 -0
  9. package/bin/engine.mjs +138 -0
  10. package/bin/forgedock.mjs +464 -1491
  11. package/bin/journey.mjs +783 -0
  12. package/bin/runner.mjs +80 -13
  13. package/bin/settings-hook.mjs +112 -0
  14. package/bin/tui.mjs +6 -3
  15. package/commands/autopilot.md +4 -4
  16. package/commands/deploy-info.md +5 -3
  17. package/commands/design-bench.md +1 -0
  18. package/commands/design-render-critique-loop.md +1 -0
  19. package/commands/design.md +1 -0
  20. package/commands/forge-stats.md +1 -0
  21. package/commands/issue.md +1 -1
  22. package/commands/orchestrate.md +27 -9
  23. package/commands/quality-gate.md +80 -34
  24. package/commands/review-pr-agents.md +7 -7
  25. package/commands/review-pr.md +21 -4
  26. package/commands/work-on/build/implement.md +16 -4
  27. package/commands/work-on/build/validate.md +38 -1
  28. package/commands/work-on/build.md +27 -9
  29. package/commands/work-on/close.md +2 -2
  30. package/commands/work-on/review.md +24 -0
  31. package/commands/work-on-monolithic.md +1 -0
  32. package/commands/work-on.md +45 -3
  33. package/docs/BRAND.md +6 -2
  34. package/docs/CONFIG.md +39 -34
  35. package/docs/site/.vitepress/config.mjs +85 -0
  36. package/docs/site/getting-started.md +15 -8
  37. package/docs/site/index.md +1 -1
  38. package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
  39. package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
  40. package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
  41. package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
  42. package/package.json +10 -4
  43. package/scripts/verify-env-vars.sh +18 -2
  44. package/scripts/verify-host-headers.sh +4 -4
  45. package/scripts/verify-route-registration.sh +2 -2
  46. package/scripts/worktree-lifecycle.sh +202 -0
package/bin/runner.mjs CHANGED
@@ -338,6 +338,31 @@ function resolveConfinedPath(cwd, p) {
338
338
  );
339
339
  }
340
340
 
341
+ // Residual TOCTOU assumption: we return the lexical `resolved` path rather
342
+ // than `realResolved` so that callers (readFileSync / writeFileSync) work
343
+ // correctly with not-yet-existing paths (where realResolved === resolved
344
+ // anyway, since there is nothing to dereference). The check above verified
345
+ // confinement on the realpath of the nearest *existing* ancestor, but a
346
+ // concurrent actor that mutates the filesystem between this check and the
347
+ // caller's open() call could in theory introduce a symlink that evades the
348
+ // guard — a classic time-of-check / time-of-use (TOCTOU) window.
349
+ //
350
+ // This is acceptable under the current threat model because:
351
+ // 1. Tool calls are executed in a strictly sequential, synchronous
352
+ // `for...of` loop (see the runLive tool-use loop). There is no async
353
+ // gap and no concurrent tool execution within the runner itself.
354
+ // 2. A prompt-injected model therefore cannot create this race — it would
355
+ // need to schedule two tool calls simultaneously, which the loop
356
+ // prevents.
357
+ // 3. Only an independent, external OS process running concurrently could
358
+ // exploit the window, which is outside the stated single-shot
359
+ // prompt-injection threat model.
360
+ //
361
+ // If the runner is ever refactored to execute tool calls concurrently or
362
+ // asynchronously, this assumption MUST be revisited. Mitigations to
363
+ // consider at that point include: operating on `realResolved` directly
364
+ // (eliminating the lexical/realpath split), or opening the file with an
365
+ // O_NOFOLLOW-equivalent flag so the kernel rejects a symlink at open time.
341
366
  return resolved;
342
367
  }
343
368
 
@@ -539,31 +564,48 @@ export function getToolHandlers(cwd) {
539
564
  // available. `shell: shell || true` preserves execSync's implicit
540
565
  // "always run via a shell" behavior for compound commands (&&, pipes,
541
566
  // heredocs) when resolveBashShell() returns undefined.
567
+ // maxBuffer defaults to 50 MB. FORGEDOCK_MAX_BUFFER_BYTES overrides it so
568
+ // tests can trigger ENOBUFS with a small value without generating 50 MB of
569
+ // output. Non-positive or non-finite values fall back to the default.
570
+ const rawMaxBuffer = parseInt(process.env.FORGEDOCK_MAX_BUFFER_BYTES, 10);
571
+ const maxBuffer =
572
+ Number.isFinite(rawMaxBuffer) && rawMaxBuffer > 0
573
+ ? rawMaxBuffer
574
+ : 50 * 1024 * 1024;
542
575
  const result = spawnSync(command, {
543
576
  cwd,
544
577
  encoding: "utf-8",
545
- maxBuffer: 50 * 1024 * 1024,
578
+ maxBuffer,
546
579
  timeout: timeoutMs,
547
580
  env: childEnv,
548
581
  shell: shell || true,
549
582
  });
550
583
  const stdout = result.stdout ? String(result.stdout) : "";
551
584
  const stderr = result.stderr ? String(result.stderr) : "";
552
- // Detect timeout: result.error.code === "ETIMEDOUT" and/or
553
- // result.signal === "SIGTERM" are the primary indicators (set by
554
- // Node.js when the timeout fires and the process is killed), but on
555
- // Windows signal reporting may not be reliable. Fall back to elapsed
556
- // wall time — if we spent at least as long as the timeout, the timer
557
- // must have fired, since a voluntarily-exiting process would have
558
- // returned before then. Gate all of this on `result.status === null`
559
- // so a legitimately-completed process near the timeout boundary is
560
- // never misreported as timed out.
585
+ // Detect timeout. Two reliable indicators:
586
+ // 1. result.error.code === "ETIMEDOUT" — Node sets this on the error
587
+ // object specifically when spawnSync's own `timeout` option fires
588
+ // and it kills the child. This is the authoritative Node-initiated
589
+ // kill signal.
590
+ // 2. elapsedMs >= timeoutMs — elapsed-wall-time fallback for platforms
591
+ // (primarily Windows) where the ETIMEDOUT error code is unreliable.
592
+ // If we spent at least as long as the timeout, the timer must have
593
+ // fired, since a voluntarily-exiting process would have returned
594
+ // before then.
595
+ //
596
+ // NOTE: `result.signal === "SIGTERM"` is intentionally NOT included.
597
+ // spawnSync sets result.signal for ANY signal that terminated the child —
598
+ // including SIGTERM from external sources (supervisors, orchestrators,
599
+ // `timeout(1)` wrappers, or the child self-signaling) that are entirely
600
+ // unrelated to this runner's timeout. Including it caused any externally-
601
+ // sent SIGTERM to be misreported as a ForgeDock timeout (issue #1240).
602
+ //
603
+ // Gate all of this on `result.status === null` so a legitimately-
604
+ // completed process near the timeout boundary is never misreported.
561
605
  const elapsedMs = Date.now() - startMs;
562
606
  const timedOut =
563
607
  result.status === null &&
564
- (result.error?.code === "ETIMEDOUT" ||
565
- result.signal === "SIGTERM" ||
566
- elapsedMs >= timeoutMs);
608
+ (result.error?.code === "ETIMEDOUT" || elapsedMs >= timeoutMs);
567
609
  if (timedOut) {
568
610
  const timeoutSecs = Math.round(timeoutMs / 1000);
569
611
  const partial = (stdout + stderr).trim();
@@ -573,6 +615,18 @@ export function getToolHandlers(cwd) {
573
615
  (partial ? `\nPartial output:\n${partial}` : ""),
574
616
  );
575
617
  }
618
+ if (result.error?.code === "ENOBUFS") {
619
+ // The process ran but produced more output than maxBuffer allows.
620
+ // spawnSync populates result.stdout/stderr up to the limit before
621
+ // setting result.error — surface what was captured so the agent has
622
+ // actionable context rather than a misleading "failed to start" error.
623
+ const partial = (stdout + stderr).trim();
624
+ const bufMB = Math.round(maxBuffer / (1024 * 1024));
625
+ throw new Error(
626
+ `Command output exceeded ${bufMB}MB buffer limit (output truncated).` +
627
+ (partial ? `\nPartial output:\n${partial}` : ""),
628
+ );
629
+ }
576
630
  if (result.error) {
577
631
  // spawnSync-level failure to even launch the process (e.g. ENOENT
578
632
  // for a missing shell binary) — distinct from a nonzero exit status
@@ -701,6 +755,19 @@ export async function runCommand(opts = {}) {
701
755
  }
702
756
 
703
757
  const client = new Anthropic({ apiKey });
758
+ // NOTE on /proc/$PPID/environ (Linux): deleting from process.env calls
759
+ // unsetenv() at the C level, which does NOT update the kernel's
760
+ // /proc/<pid>/environ snapshot (that is fixed at execve() time). A
761
+ // run_bash payload can still read /proc/$PPID/environ and recover the
762
+ // key if it was set in the shell environment before the node process
763
+ // started. The only effective JavaScript-layer mitigation is the
764
+ // child-env scrub in run_bash: `childEnv = {...process.env}; delete
765
+ // childEnv.ANTHROPIC_API_KEY` — this prevents the child from inheriting
766
+ // the key in its own process.env, which is sufficient for
767
+ // `process.env.ANTHROPIC_API_KEY` access. The /proc/$PPID/environ vector
768
+ // is a known Linux limitation at the OS level; closing it would require
769
+ // a native module (e.g. prctl PR_SET_DUMPABLE) or process isolation.
770
+ // See: issue #1370 for tracking this known limitation.
704
771
  const handlers = getToolHandlers(cwd);
705
772
  const messages = [{ role: "user", content: userMessage }];
706
773
 
@@ -0,0 +1,112 @@
1
+ /**
2
+ * bin/settings-hook.mjs — Idempotent SessionStart hook registration in
3
+ * ~/.claude/settings.json.
4
+ *
5
+ * Contract (spec: "Hook wiring"):
6
+ * - Read-modify-write; unrelated keys and hooks preserved verbatim
7
+ * - Our entry identified by HOOK_MARKER in its command string
8
+ * - Malformed JSON → skip and report, NEVER overwrite the file
9
+ * - Absolute hook script path baked in at install time
10
+ */
11
+
12
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "fs";
13
+ import { dirname } from "path";
14
+
15
+ /** Substring identifying ForgeDock's own hook entry. */
16
+ export const HOOK_MARKER = "session-start.mjs";
17
+
18
+ function isOurs(entry) {
19
+ return JSON.stringify(entry).includes(HOOK_MARKER);
20
+ }
21
+
22
+ function readSettings(settingsPath) {
23
+ if (!existsSync(settingsPath)) return { settings: {}, fresh: true };
24
+ try {
25
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
26
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
27
+ return { settings: parsed, fresh: false };
28
+ } catch {
29
+ return null; // malformed — caller must not write
30
+ }
31
+ }
32
+
33
+ function writeSettings(settingsPath, settings) {
34
+ mkdirSync(dirname(settingsPath), { recursive: true });
35
+ const tmp = settingsPath + ".tmp";
36
+ writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n", "utf-8");
37
+ renameSync(tmp, settingsPath);
38
+ }
39
+
40
+ /**
41
+ * Merge ForgeDock's SessionStart hook into settings.json.
42
+ * @param {string} settingsPath - Absolute path to ~/.claude/settings.json
43
+ * @param {string} hookScriptPath - Absolute path to bin/hooks/session-start.mjs
44
+ * @returns {{ status: 'installed'|'already'|'skipped-malformed' }}
45
+ */
46
+ export function installSessionStartHook(settingsPath, hookScriptPath) {
47
+ const read = readSettings(settingsPath);
48
+ if (read === null) return { status: "skipped-malformed" };
49
+ const { settings } = read;
50
+
51
+ // Validate hooks shape: must be a plain object (not array, string, etc.)
52
+ if (settings.hooks !== undefined && settings.hooks !== null) {
53
+ if (Array.isArray(settings.hooks) || typeof settings.hooks !== "object") {
54
+ return { status: "skipped-malformed" };
55
+ }
56
+ // Validate SessionStart if present: must be an array
57
+ if (settings.hooks.SessionStart !== undefined && !Array.isArray(settings.hooks.SessionStart)) {
58
+ return { status: "skipped-malformed" };
59
+ }
60
+ } else {
61
+ // hooks missing or null → initialize to fresh object
62
+ settings.hooks = {};
63
+ }
64
+
65
+ const list = Array.isArray(settings.hooks.SessionStart) ? settings.hooks.SessionStart : [];
66
+ if (list.some(isOurs)) return { status: "already" };
67
+
68
+ list.push({
69
+ hooks: [
70
+ {
71
+ type: "command",
72
+ command: `node "${hookScriptPath}"`,
73
+ },
74
+ ],
75
+ });
76
+ settings.hooks.SessionStart = list;
77
+ writeSettings(settingsPath, settings);
78
+ return { status: "installed" };
79
+ }
80
+
81
+ /**
82
+ * Remove ForgeDock's SessionStart hook entry; leaves everything else alone.
83
+ * @returns {{ status: 'removed'|'absent'|'skipped-malformed' }}
84
+ */
85
+ export function removeSessionStartHook(settingsPath) {
86
+ const read = readSettings(settingsPath);
87
+ if (read === null) return { status: "skipped-malformed" };
88
+ const { settings, fresh } = read;
89
+ if (fresh) return { status: "absent" };
90
+
91
+ // Validate hooks shape: must be a plain object (not array, string, etc.)
92
+ if (settings.hooks !== undefined && settings.hooks !== null) {
93
+ if (Array.isArray(settings.hooks) || typeof settings.hooks !== "object") {
94
+ return { status: "skipped-malformed" };
95
+ }
96
+ }
97
+
98
+ // Validate SessionStart shape if present: must be an array
99
+ if (settings.hooks && settings.hooks.SessionStart !== undefined && !Array.isArray(settings.hooks.SessionStart)) {
100
+ return { status: "skipped-malformed" };
101
+ }
102
+
103
+ const list = settings.hooks && Array.isArray(settings.hooks.SessionStart)
104
+ ? settings.hooks.SessionStart
105
+ : [];
106
+ const kept = list.filter((e) => !isOurs(e));
107
+ if (kept.length === list.length) return { status: "absent" };
108
+
109
+ settings.hooks.SessionStart = kept;
110
+ writeSettings(settingsPath, settings);
111
+ return { status: "removed" };
112
+ }
package/bin/tui.mjs CHANGED
@@ -837,7 +837,7 @@ const REVIEW_FIELDS = [
837
837
  */
838
838
  export async function annotatedReviewScreen(
839
839
  draft,
840
- { hasExistingConfig = false, existingContent = "", showSources = false } = {},
840
+ { hasExistingConfig = false, existingContent = "", showSources = false, extraFields = {} } = {},
841
841
  ) {
842
842
  // Helper — pull a field from the draft by path, or return a low-confidence placeholder.
843
843
  function getField(draftPath) {
@@ -862,7 +862,10 @@ export async function annotatedReviewScreen(
862
862
  const whys = {};
863
863
 
864
864
  for (const fd of REVIEW_FIELDS) {
865
- const field = getField(fd.draftPath);
865
+ const field =
866
+ fd.draftPath === null && extraFields[fd.key]
867
+ ? extraFields[fd.key]
868
+ : getField(fd.draftPath);
866
869
  values[fd.key] = field.value;
867
870
  confidences[fd.key] = field.confidence;
868
871
  sources[fd.key] = field.source;
@@ -987,7 +990,7 @@ export async function annotatedReviewScreen(
987
990
  }
988
991
 
989
992
  process.stdout.write(
990
- ` ${dim("Press")} ${bold("Enter")} ${dim("to accept all values, or enter a field number to edit it.")}\n\n`,
993
+ ` ${dim("Press")} ${bold("Enter")} ${dim("to forge, or a number to edit a field")}\n\n`,
991
994
  );
992
995
  }
993
996
 
@@ -10,7 +10,7 @@ argument-hint: [--fix | --recon-only | --fix --limit 5 | --dry-run]
10
10
  **Config variables used by this command** (set in `forge.yaml`):
11
11
  - `{CREDENTIALS_FILE}` ← `paths.credentials.file` (optional) — path to credentials YAML for analytics APIs
12
12
  - `{SERVER_SSH}` ← `services.server_ssh` (optional) — SSH target for production server health checks (e.g., `ubuntu@1.2.3.4`)
13
- - `{EMEMO_PATH}` ← `services.ememo_path` (optional) — path on production server to open eMemo files
13
+ - `{OPS_INBOX_PATH}` ← `services.ops_inbox_path` (optional) — path on production server to a directory of open work-item files (e.g. `/srv/ops/inbox`)
14
14
  - `{BILLING_ENABLED}` ← `billing.enabled` (optional, default `false`) — set to `true` to enable Stripe data collection in the Analytics Snapshot agent
15
15
 
16
16
  Resolve `{BILLING_ENABLED}` from `forge.yaml` before executing any phase:
@@ -112,7 +112,7 @@ Spawn these as **background agents** (all `run_in_background=true`, all `model="
112
112
  **Agent: Production Health**
113
113
  ```
114
114
  Check production system health:
115
- 1. SSH to check for open eMemos: if {SERVER_SSH} is configured, run: ssh {SERVER_SSH} "ls {EMEMO_PATH}/*-open-* 2>/dev/null" — skip this step if SERVER_SSH or EMEMO_PATH is not set in forge.yaml
115
+ 1. SSH to check for open ops-inbox items: if {SERVER_SSH} is configured, run: ssh {SERVER_SSH} "ls {OPS_INBOX_PATH}/*-open-* 2>/dev/null" — skip this step if SERVER_SSH or OPS_INBOX_PATH is not set in forge.yaml
116
116
  2. If any exist, cat each one and summarize
117
117
  3. Check container health via MCP: get_production_status, run_production_health_check
118
118
  4. Check recent error logs: get_production_logs for api and worker (last 100 lines), grep for ERROR/CRITICAL
@@ -197,7 +197,7 @@ For each finding from RECON_DATA, assign:
197
197
 
198
198
  | Signal | Priority | Type |
199
199
  |--------|----------|------|
200
- | Production eMemo (open) | P1 | bug |
200
+ | Open ops-inbox item | P1 | bug |
201
201
  | Container unhealthy | P0 | bug |
202
202
  | Error spike (>5x normal) | P1 | bug |
203
203
  | Tier degradation (>20% drop) | P2 | bug |
@@ -286,7 +286,7 @@ Print a structured report:
286
286
 
287
287
  ### Production Health
288
288
  - Containers: {all healthy / N unhealthy}
289
- - Open eMemos: {count}
289
+ - Open ops-inbox items: {count}
290
290
  - Error patterns: {summary or "none"}
291
291
 
292
292
  ### Scraping Performance
@@ -156,7 +156,7 @@ done
156
156
  # Full file diff summary
157
157
  git diff --stat origin/$TARGET..origin/$SOURCE
158
158
 
159
- # Read layout paths from forge.yaml, fall back to AlterLab defaults
159
+ # Read layout paths from forge.yaml, fall back to forge.yaml defaults
160
160
  LAYOUT_API=$(yq '.review.layout.api // "services/api"' forge.yaml 2>/dev/null || echo "services/api")
161
161
  LAYOUT_WORKER=$(yq '.review.layout.worker // "services/worker"' forge.yaml 2>/dev/null || echo "services/worker")
162
162
  LAYOUT_PAGES=$(yq '.review.layout.pages // "web/src/app"' forge.yaml 2>/dev/null || echo "web/src/app")
@@ -231,8 +231,10 @@ if [ -n "$NEW_ENV" ]; then
231
231
  # If your project uses a different backend, skip this block and verify manually.
232
232
  if [ "{DEPLOY_SECRETS_BACKEND}" = "sops" ]; then
233
233
  for var in $(echo "$NEW_ENV" | grep -oE '^\+[A-Z_]+' | sed 's/^\+//'); do
234
- if ! grep -q "$var" scripts/decrypt-secrets.sh 2>/dev/null; then
235
- echo " ❌ $var NOT in decrypt-secrets.sh ENV_MAPPING"
234
+ if [ -f "{REPO_PATH}/scripts/decrypt-secrets.sh" ]; then
235
+ grep -q "$var" "{REPO_PATH}/scripts/decrypt-secrets.sh" 2>/dev/null || echo " ❌ $var NOT in decrypt-secrets.sh ENV_MAPPING"
236
+ else
237
+ echo " ℹ️ scripts/decrypt-secrets.sh not found — skipping ENV_MAPPING cross-check for $var. Verify manually in your SOPS chain."
236
238
  fi
237
239
  done
238
240
  else
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Run the ABC benchmark — reference URL → design-blind brief → render arms A/B/C → blind judge → scorecard with win-rates over n runs
3
3
  argument-hint: [<brief-name>|all] [--n <runs>] [--corpus-version <ver>]
4
+ install: internal
4
5
  ---
5
6
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
6
7
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Render a design from its FORGE:DESIGN_SPEC, critique the rendered pixels against the perceptual negatives, and iterate until it passes or the budget is spent — the missing reward signal.
3
3
  argument-hint: [<issue-number>|<spec-path>] [--max-iters <n>] [--viewports desktop,mobile]
4
+ install: internal
4
5
  ---
5
6
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
6
7
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Route a design GitHub issue through the UI Taste Harness — investigate → architect → implement → critique loop → user-feedback loop → close — accumulating FORGE:DESIGN_* annotations and driving the design:* label state machine to design:shipped.
3
3
  argument-hint: <issue-number> | <brief-name>
4
+ install: internal
4
5
  ---
5
6
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
6
7
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Track command sizes, detect bloat, compare against baselines and thresholds
3
3
  argument-hint: [diff | baseline | full]
4
+ install: internal
4
5
  ---
5
6
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
6
7
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
package/commands/issue.md CHANGED
@@ -495,7 +495,7 @@ Create all {N} issues? (yes / adjust #N / cancel)
495
495
 
496
496
  | Bad Issue | Why It Fails | Good Version |
497
497
  |-----------|-------------|-------------|
498
- | "Fix docker mount" | Which compose file? Which mount? | "fix: `.gemini` volume mount in docker-compose.prod.yml missing rw flag" |
498
+ | "Fix docker mount" | Which compose file? Which mount? | "fix: `logs` volume in docker-compose.yml missing `rw` flag — container writes fail at runtime" |
499
499
  | "Update billing" | What about billing? What's broken? | "fix: payment_processor.charge() raises ZeroDivisionError when amount == 0" |
500
500
  | "Investigate performance" | Investigate what? Where? | "investigate: API p95 latency doubled since last deploy — trace hot paths in job processing routers" |
501
501
  | No affected files listed | Investigator guesses wrong files | List every file with what needs to change |
@@ -519,9 +519,11 @@ When two issues modify different files that **import from the same utility/init
519
519
  | Pattern | Inference | Action |
520
520
  |---------|-----------|--------|
521
521
  | Issue A modifies `routers/billing.py`, Issue B modifies `routers/auth.py` | Both likely import from `routers/__init__.py` or `dependencies.py` | Flag as possible conflict if same service |
522
- | Issue A modifies `models/user.py`, Issue B modifies `models/subscription.py` | Both likely modify `models/__init__.py` (SQLAlchemy model registry) | Flag as probable conflict → serialize |
522
+ | Issue A modifies `models/user.py`, Issue B modifies `models/subscription.py` *(Python/SQLAlchemy example)* | Both likely modify `models/__init__.py` (model registry) | Flag as probable conflict → serialize |
523
+ | Issue A modifies `routes/users.js`, Issue B modifies `routes/billing.js` *(Node.js example)* | Both likely import from `routes/index.js` barrel | Flag as possible conflict if same service |
524
+ | Issue A modifies `internal/user/handler.go`, Issue B modifies `internal/billing/handler.go` *(Go example)* | Both likely register in `internal/router/router.go` | Flag as possible conflict if same service |
523
525
  | Issue A modifies `web/src/components/X.tsx`, Issue B modifies `web/src/components/Y.tsx` | May share `index.ts` barrel export | Flag only if same parent directory |
524
- | Issue A modifies `services/api/app/main.py` | `main.py` is a high-fan-in file (router registration, middleware) | Serialize with ANY other API issue |
526
+ | Issue A modifies the app entrypoint (e.g. `main.py`, `main.go`, `server.js`, `app.ts`) | Entrypoint is a high-fan-in file (router registration, middleware) — set the path via `forge.yaml → review.layout.api_main` | Serialize with ANY other same-service issue |
525
527
  | Issue A modifies `docker-compose.yml` or `docker-compose.prod.yml` | Global config — any concurrent modification conflicts | Serialize with ALL other issues that touch infra |
526
528
 
527
529
  **Apply inferences:**
@@ -529,14 +531,14 @@ When two issues modify different files that **import from the same utility/init
529
531
  # High-fan-in files — if ANY issue touches these, serialize it with all same-service issues.
530
532
  # Read layout paths from forge.yaml review.layout; fall back to sensible generic defaults.
531
533
  # Example (pseudo-code — adapt to your forge.yaml parsing method):
532
- # API_MAIN = forge_yaml.review.layout.api_main ?? "services/api/app/main.py"
533
- # WORKER_MAIN = forge_yaml.review.layout.worker_main ?? "services/worker/worker/main.py"
534
- # PAGES_ROOT = forge_yaml.review.layout.pages ?? "web/src/app"
534
+ # API_MAIN = forge_yaml.review.layout.api_main ?? "src/main.py" # set in forge.yaml; no stack-specific default
535
+ # WORKER_MAIN = forge_yaml.review.layout.worker_main ?? "src/worker.py" # set in forge.yaml; no stack-specific default
536
+ # PAGES_ROOT = forge_yaml.review.layout.pages ?? "web/src/app" # Next.js default; override in forge.yaml for other frameworks
535
537
 
536
538
  HIGH_FAN_IN = [
537
- API_MAIN, # e.g. "services/api/app/main.py" — router/middleware registration
538
- WORKER_MAIN, # e.g. "services/worker/worker/main.py" — worker entrypoint (set forge.yaml review.layout.worker_main)
539
- PAGES_ROOT + "/layout.tsx", # e.g. "web/src/app/layout.tsx" — root layout for all pages
539
+ API_MAIN, # app entrypoint — router/middleware registration (set forge.yaml review.layout.api_main)
540
+ WORKER_MAIN, # worker entrypoint (set forge.yaml review.layout.worker_main)
541
+ PAGES_ROOT + "/layout.tsx", # root layout for all pages (Next.js; adapt for your framework)
540
542
  "docker-compose.yml",
541
543
  "docker-compose.prod.yml",
542
544
  ".env.example"
@@ -570,13 +572,17 @@ Layers 1-4 infer conflict risk from structure — path overlap, directory nestin
570
572
  # argument below — a plain string here would be word-split and glob-expanded
571
573
  # by the shell when handed to `git log --`, silently mangling or dropping any
572
574
  # path containing a space or glob metacharacter.
573
- mapfile -t ALL_AFFECTED_FILES < <(printf '%s\n' "${LAYER1_FILES[@]}" | sort -u)
575
+ mapfile -t ALL_AFFECTED_FILES < <(printf '%s\n' "${LAYER1_FILES[@]}" | sort -u | grep -v '^$')
574
576
 
575
577
  # Guard: an empty array expands to nothing after `--`, which git interprets as
576
578
  # "no pathspec restriction" (i.e. the whole repo) rather than "match nothing".
577
579
  # Skip the query entirely in that case instead of letting it silently widen to
578
580
  # a full-repo scan — this can happen when upstream file-extraction (Layer 1)
579
581
  # yields zero paths for every issue in the batch.
582
+ # NOTE: `grep -v '^$'` above is required — without it, `printf '%s\n'` on an
583
+ # empty array emits one blank line (printf runs its format string once even
584
+ # with zero variadic args), which `mapfile` captures as a 1-element array of
585
+ # [""], making this guard evaluate to false and causing `git log -- ""` to run.
580
586
  if [ "${#ALL_AFFECTED_FILES[@]}" -eq 0 ]; then
581
587
  echo "Layer 5: no affected files extracted by Layer 1 for this batch — skipping co-change query, falling back to Layers 1-4."
582
588
  else
@@ -834,6 +840,18 @@ Proceed? (yes / adjust / pick specific issues)
834
840
 
835
841
  ---
836
842
 
843
+ ## Engine mode (headless)
844
+
845
+ When running headless/CI, dispatch each issue via the durable execution engine instead of spawning prose agents:
846
+
847
+ ```bash
848
+ forgedock run-issue <issue> --lane <staging|milestone/slug>
849
+ ```
850
+
851
+ The engine drives every phase transition deterministically, mirrors state to the `FORGE:STATE` block on the issue, and holds a lease. To recover stalls, scan in-flight issues' `FORGE:STATE`; any issue with an expired lease and a non-terminal state is re-dispatched with the same `forgedock run-issue <issue>` command — it resumes from the last committed phase (idempotent). This replaces the label-heuristic "already in progress" check and the resume-with-nagging loop.
852
+
853
+ ---
854
+
837
855
  ## Phase 4: Streaming DAG Execution
838
856
 
839
857
  ### Step 4A-pre: Staging baseline tracking (MANDATORY — continuous)