forgedock 1.35.0 → 1.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +78 -324
  2. package/bin/bot-token-status.mjs +26 -0
  3. package/bin/engine/events.mjs +161 -0
  4. package/bin/engine/events.test.mjs +142 -0
  5. package/bin/engine/outcome-codes.mjs +39 -1
  6. package/bin/engine/phases.mjs +65 -5
  7. package/bin/engine/telemetry-seam.mjs +5 -4
  8. package/bin/engine/telemetry-seam.test.mjs +4 -0
  9. package/bin/engine-cli.mjs +372 -46
  10. package/bin/engine.mjs +3 -1
  11. package/bin/experiment.mjs +55 -0
  12. package/bin/forgedock.mjs +108 -5
  13. package/bin/hooks/reread-nudge.mjs +15 -4
  14. package/bin/labels.json +50 -0
  15. package/bin/lean/batch.mjs +52 -6
  16. package/bin/lean/batch.test.mjs +176 -2
  17. package/bin/lean/conformance.e2e.test.mjs +1 -1
  18. package/bin/lean/conformance.mjs +136 -11
  19. package/bin/lean/conformance.test.mjs +148 -1
  20. package/bin/lean/decisions.mjs +181 -0
  21. package/bin/lean/decisions.test.mjs +71 -0
  22. package/bin/lean/effects.mjs +32 -14
  23. package/bin/lean/execution.mjs +62 -16
  24. package/bin/lean/execution.test.mjs +59 -2
  25. package/bin/lean/gatewatch.mjs +31 -0
  26. package/bin/lean/gatewatch.test.mjs +59 -0
  27. package/bin/lean/guardgate.mjs +170 -0
  28. package/bin/lean/guardgate.test.mjs +153 -0
  29. package/bin/lean/guards.mjs +243 -0
  30. package/bin/lean/guards.test.mjs +177 -0
  31. package/bin/lean/heavy.mjs +54 -8
  32. package/bin/lean/heavy.test.mjs +74 -1
  33. package/bin/lean/hostkill.mjs +1 -1
  34. package/bin/lean/i18n.mjs +65 -0
  35. package/bin/lean/i18n.test.mjs +47 -0
  36. package/bin/lean/integration.mjs +276 -0
  37. package/bin/lean/integration.test.mjs +189 -0
  38. package/bin/lean/lane.mjs +5 -2
  39. package/bin/lean/lean.mjs +169 -30
  40. package/bin/lean/lean.test.mjs +360 -24
  41. package/bin/lean/lifecycle.mjs +129 -0
  42. package/bin/lean/lifecycle.test.mjs +118 -0
  43. package/bin/lean/light.mjs +1 -1
  44. package/bin/lean/milestonesync.mjs +130 -0
  45. package/bin/lean/milestonesync.test.mjs +133 -0
  46. package/bin/lean/orchestrate.mjs +132 -20
  47. package/bin/lean/parked-sweep.mjs +45 -0
  48. package/bin/lean/parked-sweep.test.mjs +71 -0
  49. package/bin/lean/postdeploy-check.mjs +181 -0
  50. package/bin/lean/postdeploy-check.test.mjs +206 -0
  51. package/bin/lean/postdeploy-monitor.mjs +97 -0
  52. package/bin/lean/postdeploy-monitor.test.mjs +147 -0
  53. package/bin/lean/precheck.mjs +6 -4
  54. package/bin/lean/precheck.test.mjs +17 -2
  55. package/bin/lean/prefilter.mjs +106 -21
  56. package/bin/lean/prefilter.test.mjs +86 -6
  57. package/bin/lean/probe.mjs +4 -3
  58. package/bin/lean/probe.test.mjs +1 -1
  59. package/bin/lean/prompts.mjs +23 -9
  60. package/bin/lean/resume.test.mjs +1 -1
  61. package/bin/lean/retry.mjs +28 -0
  62. package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
  63. package/bin/lean/sandbox-remote.mjs +11 -4
  64. package/bin/lean/schedd.mjs +77 -13
  65. package/bin/lean/schedd.test.mjs +131 -5
  66. package/bin/lean/sequence.mjs +24 -0
  67. package/bin/lean/sequence.test.mjs +31 -1
  68. package/bin/lean/signoff.mjs +66 -0
  69. package/bin/lean/signoff.test.mjs +102 -0
  70. package/bin/lean/train.mjs +177 -0
  71. package/bin/lean/train.test.mjs +113 -0
  72. package/bin/pool/cli.mjs +2 -0
  73. package/bin/pool/health.mjs +30 -0
  74. package/bin/review-context.mjs +298 -0
  75. package/bin/runner.mjs +26 -3
  76. package/bin/stats/aggregate.mjs +1 -1
  77. package/bin/stats/compounding.mjs +157 -0
  78. package/bin/stats/compounding.test.mjs +74 -0
  79. package/bin/stats/experiment.mjs +44 -0
  80. package/bin/stats/github-source.mjs +2 -1
  81. package/bin/stats/index.mjs +62 -3
  82. package/bin/stats/release.mjs +75 -0
  83. package/bin/stats/release.test.mjs +68 -0
  84. package/commands/bootstrap.md +10 -8
  85. package/commands/deploy-info.md +3 -0
  86. package/commands/issue.md +2 -0
  87. package/commands/milestone.md +43 -54
  88. package/commands/orchestrate/phase-4-execution.md +2 -0
  89. package/commands/orchestrate.md +15 -3
  90. package/commands/pipeline-status.md +20 -0
  91. package/commands/review-pr.md +1 -0
  92. package/commands/work-on/build/implement.md +1 -1
  93. package/commands/work-on/build.md +4 -2
  94. package/commands/work-on/investigate.md +3 -1
  95. package/commands/work-on/remediate.md +1 -1
  96. package/docs/CONFIG.md +120 -2
  97. package/docs/EVENTS.md +65 -0
  98. package/docs/OUTCOME-CODES.md +9 -1
  99. package/docs/STATS.md +51 -0
  100. package/docs/TELEMETRY.md +3 -1
  101. package/docs/site/install.md +89 -0
  102. package/docs/site/start-a-new-project.md +1 -1
  103. package/docs/spec/label-state-machine.md +6 -0
  104. package/package.json +1 -1
  105. package/pool/install/install-agent.sh +4 -2
  106. package/pool/install/install-router.sh +4 -2
  107. package/pool/install/install-schedd.sh +28 -0
  108. package/pool/install/unit-hardening.sh +34 -0
  109. package/scripts/acceptance-red-green.sh +19 -2
  110. package/scripts/acceptance-red-green.test.sh +2 -0
  111. package/scripts/bench-lightlane.mjs +2 -1
  112. package/scripts/bootstrap-create.mjs +3 -2
  113. package/scripts/bootstrap-validate.mjs +31 -4
  114. package/scripts/eval-gate-scorecard.mjs +7 -0
  115. package/scripts/file-deferred-followups.sh +286 -77
  116. package/scripts/file-deferred-followups.test.sh +117 -2
  117. package/scripts/hotspots.sh +23 -2
  118. package/scripts/hotspots.test.sh +9 -0
  119. package/scripts/plan-validate-live.mjs +111 -0
  120. package/scripts/readiness.mjs +141 -0
  121. package/scripts/review-prepare.sh +17 -1
  122. package/scripts/review-prepare.test.sh +17 -0
  123. package/scripts/settle-scope.mjs +74 -0
  124. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  125. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  126. package/scripts/wait-ci-green.sh +14 -3
  127. package/scripts/wait-ci-green.test.sh +3 -1
  128. package/templates/bootstrap/needs-human-issue.md +4 -1
  129. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
package/docs/TELEMETRY.md CHANGED
@@ -5,6 +5,8 @@ counts can be measured without re-reading comments with a model. GitHub is the s
5
5
  by code (never by a model) into a `FORGE:TELEMETRY` comment, and everything can be rebuilt from the GitHub API alone.
6
6
  Local files are only a cache.
7
7
 
8
+ For a stable, versioned stream aimed at outside consumers (JSONL sink, signed webhook) see [EVENTS.md](EVENTS.md).
9
+
8
10
  ## Where records live
9
11
 
10
12
  - One `<!-- FORGE:TELEMETRY v1 -->` comment per issue, or per PR for review-side events (`review_panel`,
@@ -41,7 +43,7 @@ is dropped. Payload keys are `[A-Za-z0-9_]+` and cannot shadow the shared keys;
41
43
  | `ci_local_check` | `scripts/ci-local.sh run`, one per scoped, widened or executed check (at most 20 per run) | `job`, `selector`, `selected`, `total`, `cache` = `hit` \| `miss` \| `dedup` \| `na`, `duration_ms`, `wait_ms` (job slot wait), `widen` (reason or `none`). A remote-CI failure at merge on a check recorded here as narrowed is a selector bug |
42
44
  | `liveness` | `scripts/remediate-liveness.sh` | `decision` = `PROCEED` \| `DEFER` \| `EXHAUSTED`, `why` |
43
45
  | `phase_attempt` | the engine (`bin/engine/telemetry-seam.mjs`, one per phase attempt; `phase` = the phase id) | `attempt`, `trigger` (#4362: why the attempt ran, one of `plan`, `build`, `block_correction`, `contract_correction`, `precheck`, `structure`, `docs`, `enumerating`, `sequence`, `ci_unavailable`, `ci_red`, `conformance`, `review`, `merge_ci`, `timeout_restart`, `recovery`; `restart_of` = the trigger a `timeout_restart` replaced), `model`, `backend` (`cli`/`api`), `turns`, `duration_ms`, `cost_usd` (the raw reported value: a running total when the attempt resumed a CLI session), `cost_usd_delta` (#4407: this attempt's cost; consumers sum it when present, else `cost_usd`), `cost_usd_session` (the CLI session's running total), `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens` (any value the runner did not report is omitted) |
44
- | `run_terminal` | the engine (`terminate()`, one per run segment) | `reason` (terminal reason), `code` (see `docs/OUTCOME-CODES.md`), `wall_ms` (this engine process, start to terminal) |
46
+ | `run_terminal` | the engine (`terminate()`, one per run segment) | `experiment` and `arm` (#4629, only when forge.yaml declares an `experiment:`; also on `phase_attempt`), `reason` (terminal reason), `code` (see `docs/OUTCOME-CODES.md`), `wall_ms` (this engine process, start to terminal) |
45
47
 
46
48
  The engine records carry `run` = `r_<issue>_<start epoch seconds>` (one per `forgedock run-issue` process) so `forgedock stats`
47
49
  (`docs/STATS.md`) can group the attempts of a run and tell a finished run from an unfinished one. They are the GitHub-side
@@ -0,0 +1,89 @@
1
+ # Install
2
+
3
+ Everything you need to install ForgeDock and keep it up to date. For a first run, `npx forgedock demo` needs no setup beyond the requirements below.
4
+
5
+ **Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai/) · [GitHub CLI](https://cli.github.com/) (authenticated) · Node.js ≥ 18.
6
+
7
+ ```bash
8
+ npx forgedock # checks your environment, installs commands into ~/.claude/commands/ (available in every Claude Code session on this machine), detects your repo, and hands you a reviewed forge.yaml
9
+ ```
10
+
11
+ For OpenCode, install namespaced native commands without changing your provider
12
+ or user-owned settings. When migrating an older ForgeDock adapter, it removes
13
+ only exact ForgeDock-managed legacy entries from `opencode.json`:
14
+
15
+ ```bash
16
+ npx forgedock opencode install
17
+ # restart OpenCode, then: /forge/work-on <issue>
18
+ ```
19
+
20
+ See [OpenCode support](../OPENCODE.md) for architecture, token-loading rules,
21
+ current parity boundaries, and lifecycle commands.
22
+
23
+ **Install is always global**, to `~/.claude/commands/`. `--global` is still accepted on the command line for backward compatibility but has no effect — there's no other install location to opt out of.
24
+
25
+ > **Want engine-mode dispatch?** `npx forgedock` is transient — the `forgedock` binary isn't persisted in PATH after install. `/orchestrate` and `/autopilot` use agent dispatch mode by default, which is fully functional. To enable engine-mode dispatch (`forgedock run-issue`) with its durable phase table and fail-closed review gate, run `npm install -g forgedock` instead.
26
+
27
+ One command does everything: it checks your environment, installs the slash commands into Claude Code, detects your repo (owner, branches, paths), and hands you a single annotated `forge.yaml` to review — press Enter to accept. Run `npx forgedock init` any time afterward to re-generate the config only.
28
+
29
+ Installing also registers a SessionStart hook, so every Claude Code session
30
+ in a forge-managed directory starts already knowing ForgeDock runs it.
31
+ Per-directory control: `npx forgedock enable` / `disable` / `status`.
32
+
33
+ Then just open Claude Code and run `/work-on <issue>`.
34
+
35
+ > **Cost:** ForgeDock is free and open-source. It orchestrates sessions on **your** Claude account — no compute resold, no per-task markup. A typical `/work-on` run on a straightforward bug costs about what a 15–20 minute manual Claude Code session does.
36
+
37
+ <details>
38
+ <summary><strong>Other install options & commands</strong></summary>
39
+
40
+ **Claude Code plugin marketplace** (Claude Code v2.1.143+):
41
+
42
+ ```
43
+ /plugin marketplace add RapierCraftStudios/ForgeDock
44
+ /plugin install forgedock@forgedock
45
+ ```
46
+
47
+ Commands then appear as `/forgedock:work-on`, etc. You still run `npx forgedock init` to generate `forge.yaml`.
48
+
49
+ **Headless / CI:** the pipeline also runs outside Claude Code. `npx forgedock run work-on <issue> --dry-run` previews the assembled prompt and tool plan. `npx forgedock run` picks an execution backend automatically (`--backend auto`, the default): if the Claude Code CLI (`claude`) is installed and already authenticated (Pro/Max subscription or a CLI-managed key), it drives the command through that — **no separate `ANTHROPIC_API_KEY` required**. It never falls back to the paid Anthropic API implicitly: without a CLI it fails with `CLI_BACKEND_UNAVAILABLE` unless the owner opted in (forge.yaml `execution.agent_backend: api` plus `FORGEDOCK_ALLOW_API_BACKEND=1`; see [CONFIG.md](../CONFIG.md#execution-optional)). Force either path explicitly with `--backend cli` / `--backend api` (or `FORGEDOCK_BACKEND=cli|api`) — the API backend is what CI environments without an interactive `claude` login should use. `npx forgedock run-issue <issue>` executes the same command specs on the durable engine (event-sourced run log, leases, crash-safe resume).
50
+
51
+ **Brand-new project?** `/bootstrap "<idea>"` (or `npx forgedock bootstrap "<idea>" --dry-run` to preview) turns an idea into a validated, milestone-ordered GitHub backlog that `/orchestrate` can build. See [Start a New Project with ForgeDock](start-a-new-project.md).
52
+
53
+ **Explicit install command:**
54
+
55
+ ```bash
56
+ npx forgedock install # installs into ~/.claude/commands/
57
+ npx forgedock install --global # same thing — --global is accepted but is a no-op
58
+ ```
59
+
60
+ **Maintenance:**
61
+
62
+ ```bash
63
+ npx forgedock update # relink commands + refresh the SessionStart hook
64
+ npx forgedock enable # turn ForgeDock on for this directory
65
+ npx forgedock disable # turn ForgeDock off for this directory
66
+ npx forgedock status # show ForgeDock's state for this directory
67
+ npx forgedock doctor # installation health check with fix hints
68
+ npx forgedock report # 30-day pipeline impact receipts (--md for Markdown, --json for scripting)
69
+ npx forgedock uninstall # remove commands, the hook, and tracked copies
70
+ npx forgedock help # show everything
71
+ ```
72
+
73
+ > Running `npx forgedock` from *inside* this repo uses the local working tree. From your own project, use `npx forgedock@latest` to pin the published release.
74
+
75
+ </details>
76
+
77
+ <details>
78
+ <summary><strong>A note on install location</strong></summary>
79
+
80
+ ForgeDock briefly experimented with a project-scoped-by-default install mode. It was backed out after causing a "split-brain" bug (`detect` assumed project-scoped while the installer still wrote globally — [#1589](https://github.com/RapierCraftStudios/ForgeDock/issues/1589)), so every version you'd realistically install today only ever writes to `~/.claude/commands/`. `--global` is still accepted as a flag for old scripts/muscle memory, but it changes nothing.
81
+
82
+ If you're carrying an older `--global` habit in scripts or CI, it's harmless to leave it — `npx forgedock --global` and `npx forgedock` do exactly the same thing.
83
+
84
+ </details>
85
+
86
+ **After upgrading**: run `npx forgedock labels setup` once per repo. Repos created before the `workflow:remediating` state (forge#3541) lack that label; the pipeline now creates it on demand (best-effort), but the setup command is the supported way to sync all labels.
87
+
88
+
89
+
@@ -256,7 +256,7 @@ Two milestones, a spike, four build issues and one needs-human issue. `INVITES`
256
256
  ],
257
257
  "deps": [],
258
258
  "needsHuman": true,
259
- "body": "## Problem\n\nCreate the Resend API key that #{{INVITES}} uses to send email.\n\n### Setup steps\n\n1. Create an API key at https://resend.com/api-keys with sending access only.\n2. Store it as the `RESEND_API_KEY` deploy secret.\n\n### Environment variables\n\n- `RESEND_API_KEY` — Resend sending key\n\n### Secret handling\n\nNever commit the key or paste it into issues. Until it exists, dev and CI use the mock mailer.\n\n## Root Cause (if known)\n\nGreenfield: not built yet.\n\n## Affected Files\n\n1. `docs/setup/resend.md` — new\n\n## Acceptance Criteria\n\n- [ ] Key stored as a deploy secret [type:manual]\n- [ ] The real mailer replaces the mock in staging [type:manual]\n\n## Context\n\n**Parent**: #{{SPIKE}}\n\n## Dependencies\n\nNone.\n"
259
+ "body": "## Problem\n\ngates: preview\nowner: maintainer\n\nCreate the Resend API key that #{{INVITES}} uses to send email.\n\n### Setup steps\n\n1. Create an API key at https://resend.com/api-keys with sending access only.\n2. Store it as the `RESEND_API_KEY` deploy secret.\n\n### Environment variables\n\n- `RESEND_API_KEY` — Resend sending key\n\n### Secret handling\n\nNever commit the key or paste it into issues. Until it exists, dev and CI use the mock mailer.\n\n## Root Cause (if known)\n\nGreenfield: not built yet.\n\n## Affected Files\n\n1. `docs/setup/resend.md` — new\n\n## Acceptance Criteria\n\n- [ ] Key stored as a deploy secret [type:manual]\n- [ ] The real mailer replaces the mock in staging [type:manual]\n\n## Context\n\n**Parent**: #{{SPIKE}}\n\n## Dependencies\n\nNone.\n"
260
260
  }
261
261
  ]
262
262
  }
@@ -22,6 +22,12 @@ See also: [FORGE Annotation Protocol §6](forge-protocol-v1.md#6-label-state-mac
22
22
  | `needs-human` | Pipeline blocked, human intervention required | Any agent on error |
23
23
  | `workflow:engine-error` | Pipeline blocked by the engine/tool itself breaking (CLI crash, missing SDK/API key, exhausted retries with no attempt ever reaching a real outcome) — not a genuine content-level judgment call | `terminate()` in `bin/engine.mjs` (also removes a stacked `workflow:remediating`, and adds `needs-human` when the run was remediating) |
24
24
 
25
+ `needs-human:external` vs `parked:<code>` (forge#4656): bare `needs-human` conflated two states. `needs-human:external` is a real external prerequisite (credential, account, sign-off); its structured comment must name an owner and an unblock condition. `parked:<terminal-code>` (plus `remediation-queue`) is a mechanical park (`ci.red` after the last round, `conformance.*`, `review_incomplete`): the comment carries the code and the PR, the batch runner still sees the issue, and once recovery attempts run out it waits in the single `/work-on:remediate` queue instead of the human queue. Migrating a legacy `needs-human` issue: `classifyLegacyNeedsHuman` reads its last FORGE terminal annotation; an unclassifiable one defaults to `external`. A gate (hold list, settle, `/milestone ship` pre-flight) that waits on an issue the scheduler will not admit emits `GATE_DEADLOCK issue=#N gate=G age=Nm` (`bin/lean/gatewatch.mjs`) within one pass.
26
+
27
+ Milestone lifecycle (forge#4657): `building -> shipped:staging -> deployed:default -> closed`, recorded as `<!-- FORGE:LIFECYCLE {json} -->` in the milestone description (`bin/lean/lifecycle.mjs`); forge.yaml `milestones.close_on` (`staging|default`, default `default`) picks where it closes, and a milestone with open issues never closes. `/milestone ship` requires every open issue to be closed or carried over with a target milestone and a class (`engineering|external|post-deploy|wont-do`); `needs-human:external`, flag/legal-gated and off-base-dependent issues are never demoted to fast lane. `pipeline-status` lists `SHIPPED_WITH_OPEN` and `EMPTY_BUT_OPEN`.
28
+
29
+ `gates:` (forge#4660): a `needs-human:external` issue names what it blocks in its body, `gates: preview|staging-deploy|prod-deploy|feature-flag:<name>` (comma separated), with an `owner:` line; `Depends on #N` between prerequisites orders the digest. `scripts/readiness.mjs` turns open prerequisites into the `pipeline-status` Readiness section (per gate: prerequisites with owner and age, code milestones needed), a dependency-ordered human-action digest, and the `deploy-info` verdict: a target (preview, staging, prod) is NOT DEPLOYABLE while its gate has open prerequisites. A `feature-flag:<name>` gate stays red while open but never blocks merging flag-gated features or any deploy target.
30
+
25
31
  `workflow:remediating` vs `needs-human` (forge#3541): `needs-human` is reserved for genuine human decisions and external actions (an exhausted remediation bound, an unresolvable conflict, a policy gate, spend, real-environment validation). Work the pipeline fixes by itself (a pure base-sync, a red CI gate, an in-PR finding round) is marked `workflow:remediating` instead, so operators are not alerted and orchestrate does not hold DAG dependents as GATED. `work-on:remediate` accepts either label (a bare `needs-human` from an older run still works), swaps it for `workflow:in-review` on a FIXABLE run, and on every BLOCKED/UNFIXABLE exit clears `workflow:remediating` and asserts `needs-human`.
26
32
 
27
33
  `workflow:awaiting-merge` vs `needs-human`: both are terminal (the pipeline stops advancing the issue automatically), but they mean different things. `needs-human` means the pipeline hit a condition it cannot resolve on its own (genuinely blocked — conflicting PR, failed verdict, calibration/trust escalation, etc.) and a human must diagnose and act. `workflow:awaiting-merge` means the opposite: the PR was previously escalated to `needs-human`, has since been remediated and re-reviewed to a clean `APPROVED` verdict with no mergeability blockers, but does not yet meet the automated auto-land bar (see forge#1809 Q1) — a human only needs to click merge, not diagnose a problem. `scripts/transition-label.sh` only clears `needs-human` (best-effort) when the target state is `workflow:awaiting-merge` — every other forward transition leaves a pre-existing `needs-human` label untouched, preserving its sticky/terminal semantics.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.35.0",
3
+ "version": "1.37.0",
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"
@@ -72,16 +72,17 @@ log "building lane image (first run takes several minutes)"
72
72
  FD_VERSION="$(jq -r .version /opt/computeshare/package.json)"
73
73
  docker build -q -t "forgedock-lane:${FD_VERSION}" --build-arg "FORGEDOCK_VERSION=${FD_VERSION}" /opt/computeshare/pool/image >/dev/null
74
74
 
75
+ . /opt/computeshare/pool/install/unit-hardening.sh
75
76
  cat > /etc/systemd/system/computeshare-agent.service <<EOF
76
77
  [Unit]
77
78
  Description=computeshare agent
78
79
  After=docker.service network-online.target
79
80
  Requires=docker.service
81
+ $(unit_start_limit)
80
82
 
81
83
  [Service]
82
84
  ExecStart=/opt/node/bin/node /opt/computeshare/bin/pool/cli.mjs agent --config /etc/computeshare/agent.json
83
- Restart=always
84
- RestartSec=5
85
+ $(unit_hardening system)
85
86
 
86
87
  [Install]
87
88
  WantedBy=multi-user.target
@@ -89,6 +90,7 @@ EOF
89
90
  systemctl daemon-reload
90
91
  systemctl enable computeshare-agent >/dev/null
91
92
  systemctl restart computeshare-agent
93
+ unit_oom_hint forgedock-pool-agent
92
94
 
93
95
  for i in $(seq 1 20); do
94
96
  if curl -fsS "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; then
@@ -10,6 +10,7 @@ PORT="${ROUTER_PORT:-7710}"
10
10
  TS_IP="$(tailscale ip -4 | head -1)"
11
11
  [[ "$TS_IP" == 100.* ]] || { echo "no Tailscale IPv4 found" >&2; exit 1; }
12
12
  NODE="$(command -v node)"
13
+ . "$REPO_ROOT/pool/install/unit-hardening.sh"
13
14
 
14
15
  mkdir -p "$CONF_DIR" && chmod 700 "$CONF_DIR"
15
16
  umask 077
@@ -37,11 +38,11 @@ cat > "$HOME/.config/systemd/user/computeshare-router.service" <<EOF
37
38
  [Unit]
38
39
  Description=computeshare router
39
40
  After=network-online.target tailscaled.service
41
+ $(unit_start_limit)
40
42
 
41
43
  [Service]
42
44
  ExecStart=$NODE $REPO_ROOT/bin/pool/cli.mjs router --config $CONF_DIR/router.json
43
- Restart=always
44
- RestartSec=5
45
+ $(unit_hardening user)
45
46
 
46
47
  [Install]
47
48
  WantedBy=default.target
@@ -49,6 +50,7 @@ EOF
49
50
  systemctl --user daemon-reload
50
51
  systemctl --user enable --now computeshare-router >/dev/null
51
52
  systemctl --user restart computeshare-router
53
+ unit_oom_hint forgedock-pool-router
52
54
  loginctl enable-linger "$USER" 2>/dev/null || true
53
55
 
54
56
  # ForgeDock reads execution.token_env from the env, then the OS keychain (service forgedock, account = the name).
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ # Installs `forgedock schedd` (the host scheduler daemon) as a systemd --user service with the shared OOM/restart hardening.
5
+ set -euo pipefail
6
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
7
+ NODE="$(command -v node)"
8
+ . "$REPO_ROOT/pool/install/unit-hardening.sh"
9
+
10
+ mkdir -p "$HOME/.config/systemd/user"
11
+ cat > "$HOME/.config/systemd/user/forgedock-schedd.service" <<UNIT
12
+ [Unit]
13
+ Description=forgedock schedd
14
+ $(unit_start_limit)
15
+
16
+ [Service]
17
+ ExecStart=$NODE $REPO_ROOT/bin/forgedock.mjs schedd
18
+ $(unit_hardening user)
19
+
20
+ [Install]
21
+ WantedBy=default.target
22
+ UNIT
23
+ systemctl --user daemon-reload
24
+ systemctl --user enable --now forgedock-schedd >/dev/null
25
+ systemctl --user restart forgedock-schedd
26
+ unit_oom_hint forgedock-schedd
27
+ loginctl enable-linger "$USER" 2>/dev/null || true
28
+ echo "[forgedock] schedd unit installed"
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ # Shared [Service] hardening for the long-lived ForgeDock units (router, agent, schedd). Source it; it defines functions only.
5
+ # A kill of these drops every in-flight lane, so they get the lowest OOM score the manager allows and survive repeated SIGTERMs.
6
+
7
+ # unit_oom_score_adj user|system: root may go negative; an unprivileged user manager cannot go below its own value.
8
+ unit_oom_score_adj() {
9
+ if [ "$1" = system ]; then echo -500; return; fi
10
+ local pid v
11
+ pid="$(ps -o pid= -C systemd -U "$(id -u)" 2>/dev/null | head -1 | tr -d ' ')"
12
+ v="$(cat "/proc/${pid:-self}/oom_score_adj" 2>/dev/null || echo 0)"
13
+ echo "${v:-0}"
14
+ }
15
+
16
+ # unit_hardening user|system: the [Service] lines (no section header), for use inside a unit heredoc.
17
+ unit_hardening() {
18
+ printf 'OOMScoreAdjust=%s\nRestart=always\nRestartSec=5\n' "$(unit_oom_score_adj "${1:-user}")"
19
+ }
20
+
21
+ # unit_start_limit: the [Unit] line that disables the start rate limit. systemd honours StartLimitIntervalSec only in [Unit], never in [Service].
22
+ unit_start_limit() {
23
+ printf 'StartLimitIntervalSec=0\n'
24
+ }
25
+
26
+ # unit_oom_hint <process title>: one line when a userspace OOM killer runs. Never edits any OOM config.
27
+ unit_oom_hint() {
28
+ if pgrep -x earlyoom >/dev/null 2>&1; then
29
+ echo "[forgedock] earlyoom is running: exempt this service by name with: --avoid '(^|/)$1\$' (earlyoom -h)"
30
+ elif pgrep -x systemd-oomd >/dev/null 2>&1; then
31
+ echo "[forgedock] systemd-oomd is running: add ManagedOOMPreference=omit to the unit via a drop-in to exempt $1"
32
+ fi
33
+ return 0
34
+ }
@@ -12,7 +12,8 @@
12
12
  # FAILED the run executed and failed
13
13
  # RAN the run executed and passed
14
14
  # RAN_NOTHING the runner exercised nothing
15
- # UNRUNNABLE could not execute (timeout 124, not executable 126/127, worktree/overlay/classifier failure)
15
+ # UNRUNNABLE could not execute (timeout 124, not executable 126/127, worktree/overlay/classifier failure, or head failed
16
+ # only on a missing host module/command such as `No module named pytest`; forge#4655)
16
17
  # SKIPPED base was not run because head did not PASS (never a missing file: head's new test files are overlaid)
17
18
  # Never prints a verdict on success of cleanup: the base worktree is removed on every exit path (trap + explicit remove).
18
19
  # Callers map: HEAD=RAN BASE=FAILED -> proof; BASE=RAN -> passes on base (gate FAIL); anything else that is not a proof -> DEGRADED/FAIL.
@@ -54,7 +55,23 @@ run_in() {
54
55
  else ( cd "$dir" && bash -c "$CMD" ) >"$out" 2>&1; rc=$?; fi
55
56
  case "$rc" in 124|126|127) echo UNRUNNABLE; return ;; esac
56
57
  cls=$(bash "$CLS" "$rc" "$out" 2>/dev/null)
57
- case "$cls" in FAILED|RAN|RAN_NOTHING) echo "$cls" ;; *) echo UNRUNNABLE ;; esac
58
+ case "$cls" in FAILED|RAN|RAN_NOTHING) ;; *) echo UNRUNNABLE; return ;; esac
59
+ # forge#4655: a head run that failed only because the host lacks an interpreter module or command (No module named
60
+ # pytest, X: command not found) is the environment, not the change: UNRUNNABLE. A module the repo itself tracks stays FAILED.
61
+ if [ "$cls" = "FAILED" ] && [ "$dir" = "$REPO" ] && host_missing "$out"; then echo UNRUNNABLE; return; fi
62
+ echo "$cls"
63
+ }
64
+
65
+ # host_missing <output_file>: true when the output names a missing third-party python module or a missing command
66
+ host_missing() {
67
+ local f="$1" name
68
+ name=$(sed -n "s/.*No module named '\{0,1\}\([A-Za-z0-9_-]\{1,\}\).*/\1/p" "$f" | head -1)
69
+ if [ -n "$name" ]; then
70
+ git -C "$REPO" ls-files 2>/dev/null | tr '/' '\n' | grep -qxE "$name(\.py)?" && return 1
71
+ return 0
72
+ fi
73
+ name=$(sed -n 's/.*[ :]\([A-Za-z0-9_.-][A-Za-z0-9_.-]*\): command not found.*/\1/p' "$f" | head -1)
74
+ [ -n "$name" ]
58
75
  }
59
76
 
60
77
  HEAD_RES=$(run_in "$REPO")
@@ -38,6 +38,8 @@ mkdir -p "$R2/tests"; printf 'echo "1 passed"\n' > "$R2/tests/t.sh"; git -C "$R2
38
38
  check "passes on base" "BASE=RAN HEAD=RAN" "$R2" "bash tests/t.sh"
39
39
 
40
40
  # 3. head fails: base is not run
41
+ check "head missing host module is UNRUNNABLE, not a failure (#4655)" "BASE=SKIPPED HEAD=UNRUNNABLE" "$R1" "echo \"/usr/bin/python3: No module named 'pytest_nonexistent_x'\" >&2; exit 1"
42
+ check "head unquoted No module named pytest is UNRUNNABLE (#4655)" "BASE=SKIPPED HEAD=UNRUNNABLE" "$R1" "echo '/usr/bin/python3: No module named pytest' >&2; exit 1"
41
43
  check "head failure skips base" "BASE=SKIPPED HEAD=FAILED" "$R1" "bash app.sh; exit 1"
42
44
 
43
45
  # 4. RAN_NOTHING on head
@@ -31,6 +31,7 @@ import { tmpdir } from "node:os";
31
31
  import { join } from "node:path";
32
32
  import { pathToFileURL } from "node:url";
33
33
  import { detectApiShape } from "../bin/lean/apishape.mjs";
34
+ import { benchRecord } from "../bin/experiment.mjs";
34
35
 
35
36
  export const ARMS = ["light", "full", "control"];
36
37
  export const TARGETS = Object.freeze({ cost_vs_control: 1.2, wall_vs_model: 1.3 });
@@ -225,7 +226,7 @@ export function aggregate(rows) {
225
226
  false_fix_le_control: t(L.n && C.n ? L.false_fix_rate <= C.false_fix_rate : null, L.false_fix_rate, C.false_fix_rate),
226
227
  zero_undetected_api_shape: t(L.n ? L.undetected_api_shape === 0 : null, L.undetected_api_shape, 0),
227
228
  };
228
- return { arms, light_eligible: { ...le, control_cost_usd: ce.cost_usd.sum, ids: [...eligIds].sort() }, targets };
229
+ return { arms, light_eligible: { ...le, control_cost_usd: ce.cost_usd.sum, ids: [...eligIds].sort() }, targets, record: benchRecord({ source: "bench-lightlane", arms, targets }) }; // #4629
229
230
  }
230
231
 
231
232
  /** JSONL (or a JSON array) -> rows. */
@@ -218,7 +218,8 @@ export function createBacklog(plan, { io, repo, config = {}, base }) {
218
218
 
219
219
  // Labels: create only the missing ones (never overwrite an existing label's colour or description).
220
220
  const haveLabels = new Set((json(["label", "list", "-R", repo, "--limit", "1000", "--json", "name"]) ?? []).map((l) => l.name));
221
- const wanted = new Set(plan.issues.flatMap((it) => [...(it.labels ?? []), priorityLabel(it)]).filter(Boolean));
221
+ const issueLabels = (it) => [...(it.labels ?? []), priorityLabel(it), ...(isNeedsHuman(it) ? ["needs-human:external"] : [])].filter(Boolean); // #4660
222
+ const wanted = new Set(plan.issues.flatMap(issueLabels));
222
223
  for (const l of wanted) if (!haveLabels.has(l)) gh(["label", "create", l, "-R", repo]);
223
224
 
224
225
  // Milestones in build order, each with its lane branch.
@@ -253,7 +254,7 @@ export function createBacklog(plan, { io, repo, config = {}, base }) {
253
254
  return;
254
255
  }
255
256
  const args = ["issue", "create", "-R", repo, "--title", it.title, "--body-file", "-", "--milestone", milestoneTitle.get(it.milestone)];
256
- for (const l of [...(it.labels ?? []), priorityLabel(it)].filter(Boolean)) args.push("--label", l);
257
+ for (const l of new Set(issueLabels(it))) args.push("--label", l);
257
258
  const body = finalBody(it);
258
259
  const out = gh(args, body);
259
260
  const number = parseIssueNumber(out);
@@ -19,6 +19,8 @@
19
19
  * missing-section, missing-type-tag, missing-parent, missing-labels, unknown-label, code-depends-on-needs-human,
20
20
  * missing-design-spec (a `ui` issue lacks a design-spec item), hero-not-routed (a `hero` issue never names `/design`),
21
21
  * missing-needs-human-section (a needs-human issue lacks setup steps, env var names or secret handling) (#4176).
22
+ * Warns (never fails; exit stays 0): deploy-last (#4665: every milestone holding deploy workflows comes after the feature
23
+ * milestones, so nothing can be previewed or deployed until they all ship).
22
24
  * `Depends on` lines are read with the engine's own parser (bin/engine/phases.mjs parseDependsOn) after placeholder
23
25
  * resolution, so the plan and engine admission agree on what blocks what.
24
26
  *
@@ -35,8 +37,10 @@
35
37
  import { readFileSync } from "node:fs";
36
38
  import { fileURLToPath } from "node:url";
37
39
  import { parseDependsOn } from "../bin/engine/phases.mjs";
40
+ import { parseGates } from "./readiness.mjs";
38
41
 
39
42
  const NEEDS_HUMAN_LABEL = "needs-human";
43
+ const EXTERNAL_LABEL = "needs-human:external"; // #4660: external prerequisite; its `gates:` line names what it blocks
40
44
  const TYPE_TAG_RE = /\[type:(?:api|unit|e2e|manual)\]/;
41
45
  const CHECKBOX_RE = /^[ \t]*[-*+][ \t]+\[[ xX]\]/;
42
46
  const PLACEHOLDER_RE = /#\{\{([^{}]*)\}\}/g;
@@ -66,13 +70,17 @@ const NEEDS_HUMAN_PARTS = [
66
70
  ["env var name (e.g. `API_KEY`)", /`[A-Z][A-Z0-9]*_[A-Z0-9_]+`/],
67
71
  ];
68
72
 
73
+ // #4665: an issue holds deploy work when its title or body names a deploy workflow or a staging environment definition.
74
+ const DEPLOY_RE = /\.github\/workflows\/[^\s`'")]*deploy|\bdeploy(?:ment)? workflow\b|\bstaging environment\b/i;
75
+ const isDeployIssue = (it) => DEPLOY_RE.test(`${it?.title ?? ""}\n${it?.body ?? ""}`);
76
+
69
77
  const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
70
78
  const isNonEmptyString = (v) => typeof v === "string" && v.trim() !== "";
71
79
  const normHeading = (h) => h.replace(/\([^)]*\)\s*$/, "").trim().toLowerCase();
72
80
 
73
81
  /** True when the issue is a needs-human issue (credential, account or external input). */
74
82
  export function isNeedsHuman(issue) {
75
- return issue?.needsHuman === true || (Array.isArray(issue?.labels) && issue.labels.includes(NEEDS_HUMAN_LABEL));
83
+ return issue?.needsHuman === true || (Array.isArray(issue?.labels) && (issue.labels.includes(NEEDS_HUMAN_LABEL) || issue.labels.includes(EXTERNAL_LABEL)));
76
84
  }
77
85
 
78
86
  /**
@@ -175,7 +183,7 @@ function findCycles(issues, byKey) {
175
183
 
176
184
  /**
177
185
  * Validate a backlog plan. Never throws on bad input; structural problems come back as `invalid-plan` errors.
178
- * @returns {{ ok: boolean, errors: Array<{ issue: string|null, code: string, message: string }> }}
186
+ * @returns {{ ok: boolean, errors: Array<{ issue: string|null, code: string, message: string }>, warnings?: Array<{ issue: string|null, code: string, message: string }> }}
179
187
  */
180
188
  export function validatePlan(plan) {
181
189
  const structural = checkStructure(plan);
@@ -275,12 +283,30 @@ export function validatePlan(plan) {
275
283
  if (isNeedsHuman(it)) {
276
284
  const lacking = NEEDS_HUMAN_PARTS.filter(([, re]) => !re.test(it.body)).map(([name]) => name);
277
285
  if (lacking.length) err(it.key, "missing-needs-human-section", `needs-human issue lacks: ${lacking.join(", ")}`);
286
+ if (!parseGates(it.body).length) {
287
+ err(it.key, "missing-gates", "needs-human issue has no valid `gates:` line (preview|staging-deploy|prod-deploy|feature-flag:<name>)");
288
+ }
278
289
  }
279
290
  }
280
- return { ok: errors.length === 0, errors };
291
+ return { ok: errors.length === 0, errors, warnings: deployWarnings(issues, milestones) };
292
+ }
293
+
294
+ /** #4665: `deploy-last` when deploy work exists but none of it sits in milestones 1-2 (needs >= 3 milestones). */
295
+ function deployWarnings(issues, milestones) {
296
+ const index = new Map(milestones.map((m, i) => [m.key, i]));
297
+ const deploy = issues.filter(isDeployIssue);
298
+ if (!deploy.length || milestones.length < 3) return [];
299
+ const at = deploy.map((it) => index.get(it.milestone));
300
+ if (at.some((i) => i <= 1)) return [];
301
+ const where = [...new Set(at.map((i) => milestones[i]?.key).filter(Boolean))].join(", ");
302
+ return [{
303
+ issue: deploy[0].key, code: "deploy-last",
304
+ message: `deploy workflows are only in ${where ? `milestone ${where}` : "later milestones"}, none in the first two; put a thin deployable slice (CI deploy workflow, staging environment, seed) in the first or second milestone`,
305
+ }];
281
306
  }
282
307
 
283
308
  /** One `<KEY>: [code] message` line per error; plan-level errors use `(plan)`. */
309
+ export const formatWarnings = (warnings) => formatErrors(warnings);
284
310
  export function formatErrors(errors) {
285
311
  return errors.map((e) => `${e.issue ?? "(plan)"}: [${e.code}] ${e.message}`).join("\n");
286
312
  }
@@ -305,7 +331,8 @@ function main(argv) {
305
331
  process.stderr.write(`bootstrap-validate: invalid JSON: ${e.message}\n`);
306
332
  return 2;
307
333
  }
308
- const { ok, errors } = validatePlan(plan);
334
+ const { ok, errors, warnings = [] } = validatePlan(plan);
335
+ if (warnings.length) process.stderr.write(formatErrors(warnings).replace(/^/gm, "warning: ") + "\n");
309
336
  if (!ok) {
310
337
  process.stderr.write(formatErrors(errors) + "\n");
311
338
  process.stderr.write(`bootstrap-validate: ${errors.length} error(s)\n`);
@@ -30,6 +30,7 @@
30
30
  import { readFileSync, writeFileSync } from "node:fs";
31
31
  import { dirname, join } from "node:path";
32
32
  import { fileURLToPath } from "node:url";
33
+ import { benchRecord } from "../bin/experiment.mjs";
33
34
 
34
35
  /** Fields the fresh scorecard must have (matches eval-scorecard-aggregator.mjs's output). */
35
36
  export const REQUIRED_FIELDS = [
@@ -137,6 +138,12 @@ export function evaluateGate(
137
138
  fresh_run_id: fresh.run_id ?? null,
138
139
  baseline_run_id: baseline.run_id ?? null,
139
140
  reason,
141
+ // #4629: the shared result record; the target is null (never a pass) when the rate assertion was skipped
142
+ record: benchRecord({
143
+ source: "eval-gate",
144
+ arms: { baseline: { success_rate_pct: baseline.success_rate_pct }, fresh: { n: freshIssuesRun, success_rate_pct: fresh.success_rate_pct } },
145
+ targets: { success_rate_within_threshold: { pass: rateAssertionSkipped ? null : pass, value: dropPp, limit: thresholdPp } },
146
+ }),
140
147
  };
141
148
  }
142
149