From 6a94f0428f1313f3468a1ce42a6938b179fda572 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Wed, 6 May 2026 08:19:53 +0000 Subject: [PATCH 1/4] docs: realign local runbook branch roles (#208) --- .../developing-devclaw-with-openclaw.md | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/dev/runbooks/developing-devclaw-with-openclaw.md b/dev/runbooks/developing-devclaw-with-openclaw.md index 8391568..5e03314 100644 --- a/dev/runbooks/developing-devclaw-with-openclaw.md +++ b/dev/runbooks/developing-devclaw-with-openclaw.md @@ -7,9 +7,10 @@ It lives under `/dev` because these rules are first-class local operating docs a Treat these branch roles as the working contract: -- `devclaw-local-current`: local truth and day-to-day working lane +- `devclaw-local-dev`: ordinary implementation base branch for normal issue work +- `devclaw-local-current`: local truth, promotion, deploy, and local operational documentation lane - `devclaw-local-stable`: local fallback lane when `devclaw-local-current` is too noisy or risky -- `issue/*`: local implementation branches for scoped work +- `issue/*`: local implementation branches for scoped work, normally created from `devclaw-local-dev` - `review/*`: local review branches opened against `devclaw-local-current` - `pr/*`: export branches prepared for upstream review @@ -18,11 +19,12 @@ Upstream `main` is a reference point and export target. It is not the normal day ## Operating model 1. Keep local docs and operator runbooks on `devclaw-local-current`. -2. Start implementation from `devclaw-local-current` into an `issue/*` branch when you need isolated task work. -3. Land validated work back onto `devclaw-local-current` so local truth stays complete. -4. When work needs to go upstream, export it onto a matching `pr/*` branch. -5. Preserve the `/dev/` documentation changes on `devclaw-local-current` even when the upstream export omits local-only material. -6. Push runbook and workflow changes to the Git remote that tracks `devclaw-local-current` so the policy is not left only in a local checkout or an unknown branch. +2. Start ordinary implementation from `devclaw-local-dev` into an `issue/*` branch when you need isolated task work. +3. Use the ordinary implementation PR into `devclaw-local-dev` as the normal developer completion lane. +4. Land validated release-worthy work back onto `devclaw-local-current` through the promotion/review flow so local truth stays complete. +5. When work needs to go upstream, export it onto a matching `pr/*` branch. +6. Preserve the `/dev/` documentation changes on `devclaw-local-current` even when the upstream export omits local-only material. +7. Push runbook and workflow changes to the Git remote that tracks `devclaw-local-current` so the policy is not left only in a local checkout or an unknown branch. ## Mandatory compliance rule @@ -102,7 +104,11 @@ Upstream review material should be prepared from `pr/*`, while `devclaw-local-cu ### Promotion ownership and close-gate rule -The orchestrator owns the promotion lane end-to-end. Do not treat worker completion as promotion completion. +The orchestrator owns the **promotion lane** end-to-end. Do not treat worker completion as promotion completion. + +This section applies to explicit promotion/export work, for example `UP:` issues or tasks that are intentionally preparing `review/*` and `pr/*` branches. +It does **not** mean the orchestrator owns the ordinary implementation PR for normal issue work. +For normal implementation tasks, the developer should still open and maintain the issue PR into the active implementation base branch. Worker-owned steps may include implementation, review, or testing sub-results such as: From 15846cfabbe8e7c20efcc8d5e454f313bf8f5b76 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Wed, 6 May 2026 09:11:01 +0000 Subject: [PATCH 2/4] docs: clarify dev live-validation lane (#210) --- .../developing-devclaw-with-openclaw.md | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/dev/runbooks/developing-devclaw-with-openclaw.md b/dev/runbooks/developing-devclaw-with-openclaw.md index 5e03314..e9d4fd4 100644 --- a/dev/runbooks/developing-devclaw-with-openclaw.md +++ b/dev/runbooks/developing-devclaw-with-openclaw.md @@ -7,8 +7,8 @@ It lives under `/dev` because these rules are first-class local operating docs a Treat these branch roles as the working contract: -- `devclaw-local-dev`: ordinary implementation base branch for normal issue work -- `devclaw-local-current`: local truth, promotion, deploy, and local operational documentation lane +- `devclaw-local-dev`: ordinary implementation base branch and the live pre-promotion validation lane for normal issue work +- `devclaw-local-current`: accepted local truth, promotion, deploy, and local operational documentation lane - `devclaw-local-stable`: local fallback lane when `devclaw-local-current` is too noisy or risky - `issue/*`: local implementation branches for scoped work, normally created from `devclaw-local-dev` - `review/*`: local review branches opened against `devclaw-local-current` @@ -21,10 +21,11 @@ Upstream `main` is a reference point and export target. It is not the normal day 1. Keep local docs and operator runbooks on `devclaw-local-current`. 2. Start ordinary implementation from `devclaw-local-dev` into an `issue/*` branch when you need isolated task work. 3. Use the ordinary implementation PR into `devclaw-local-dev` as the normal developer completion lane. -4. Land validated release-worthy work back onto `devclaw-local-current` through the promotion/review flow so local truth stays complete. -5. When work needs to go upstream, export it onto a matching `pr/*` branch. -6. Preserve the `/dev/` documentation changes on `devclaw-local-current` even when the upstream export omits local-only material. -7. Push runbook and workflow changes to the Git remote that tracks `devclaw-local-current` so the policy is not left only in a local checkout or an unknown branch. +4. Make `devclaw-local-dev` the live validation lane for ordinary implementation work when live testing is required, and verify the fix there before promoting it further. +5. Only after the fix is accepted on `devclaw-local-dev`, promote it into `devclaw-local-current` through the `review/*` local-truth lane. +6. After the accepted package is present on `devclaw-local-current`, export the upstreamable change set onto a matching `pr/*` branch. +7. Preserve the `/dev/` documentation changes on `devclaw-local-current` even when the upstream export omits local-only material. +8. Push runbook and workflow changes to the Git remote that tracks `devclaw-local-current` so the policy is not left only in a local checkout or an unknown branch. ## Mandatory compliance rule @@ -88,17 +89,19 @@ Where: Typical flow: -1. implement and validate locally -2. open or update the local promotion issue that owns the full upstream-promotion workflow -3. create or refresh `review/-` from `devclaw-local-current` -4. apply the exact accepted fix onto the `review/*` branch -5. push the `review/*` branch to the fork remote -6. autonomously open or refresh the fork PR from `review/*` into `devclaw-local-current` -7. use that PR for local-truth review, merge, and testing on `devclaw-local-current` -8. after the change is merged and validated on `devclaw-local-current`, create or refresh `pr/-` from `upstream/main` -9. apply the same upstreamable commit set onto the `pr/*` branch -10. push the `pr/*` branch to the fork remote -11. prepare the final compare/diff URL and PR body for DevClaw official +1. implement on `issue/*` from `devclaw-local-dev` +2. land the ordinary implementation PR into `devclaw-local-dev` +3. make `devclaw-local-dev` live when needed for development validation and test the accepted fix there +4. open or update the local promotion issue that owns the full upstream-promotion workflow +5. create or refresh `review/-` from `devclaw-local-current` +6. cherry-pick or otherwise apply the exact accepted fix onto the `review/*` branch +7. push the `review/*` branch to the fork remote +8. autonomously open or refresh the fork PR from `review/*` into `devclaw-local-current` +9. use that PR for local-truth review, merge, and accepted-lane validation on `devclaw-local-current` +10. after the change is merged and validated on `devclaw-local-current`, create or refresh `pr/-` from `upstream/main` +11. cherry-pick or otherwise apply the same upstreamable commit set onto the `pr/*` branch +12. push the `pr/*` branch to the fork remote +13. prepare the final compare/diff URL and PR body for DevClaw official Upstream review material should be prepared from `pr/*`, while `devclaw-local-current` remains the complete local operating branch. @@ -121,6 +124,7 @@ Those results are evidence and inputs to the promotion lane. They do **not** by For local-first release work, the orchestrator-owned responsibilities include: - keeping the promotion issue current as the canonical lane record +- making the `devclaw-local-dev` live-validation checkpoint explicit when the lane requires ordinary implementation validation before promotion - creating or refreshing the `review/*` branch and local-truth PR - driving the required human checkpoint on that PR - ensuring the accepted change actually lands in `devclaw-local-current` From 5b739aa3e52cc83c277264d5d8f91dfb8f34c919 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Wed, 6 May 2026 09:32:32 +0000 Subject: [PATCH 3/4] Revert "Merge pull request #206 from yaqub0r/issue/203-live-orchestrator-intervention" This reverts commit f9db54ce64ebeca23299b0106fb8e7de33071351, reversing changes made to c3627211458232a5d1d24c710663ec884758b93d. --- lib/orchestrator-intervention/engine.ts | 6 +- package-lock.json | 120 ------------------------ 2 files changed, 1 insertion(+), 125 deletions(-) diff --git a/lib/orchestrator-intervention/engine.ts b/lib/orchestrator-intervention/engine.ts index 9cfe893..c0d716c 100644 --- a/lib/orchestrator-intervention/engine.ts +++ b/lib/orchestrator-intervention/engine.ts @@ -243,16 +243,12 @@ function renderTemplate(template: string | undefined, event: OrchestratorInterve function findPlanningLabel(workflow: InterventionRuntimeContext["workflow"]): string { const planningLabel = getInitialStateLabel(workflow); const planning = findStateByLabel(workflow, planningLabel); - if (!planning || !isHoldState(planning.type)) { + if (!planning || planning.type !== StateType.HOLD) { throw new Error(`workflow initial state ${planningLabel} is not a HOLD state`); } return planningLabel; } -function isHoldState(type: string | undefined): boolean { - return (type ?? "").toLowerCase() === StateType.HOLD; -} - async function wakeOrchestrator( ctx: InterventionRuntimeContext, event: OrchestratorInterventionEvent, diff --git a/package-lock.json b/package-lock.json index d7daeab..92ec5aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1851,9 +1851,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1871,9 +1868,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1891,9 +1885,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1911,9 +1902,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1931,9 +1919,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1951,9 +1936,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1971,9 +1953,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1991,9 +1970,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2011,9 +1987,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2037,9 +2010,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2063,9 +2033,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2089,9 +2056,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2115,9 +2079,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2141,9 +2102,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2167,9 +2125,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2193,9 +2148,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2633,9 +2585,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2653,9 +2602,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2673,9 +2619,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2693,9 +2636,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2713,9 +2653,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3019,9 +2956,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3043,9 +2977,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3067,9 +2998,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3091,9 +3019,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3115,9 +3040,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3199,9 +3121,6 @@ "arm64", "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3220,9 +3139,6 @@ "arm", "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3240,9 +3156,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3260,9 +3173,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3280,9 +3190,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3300,9 +3207,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3955,9 +3859,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3975,9 +3876,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3995,9 +3893,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4015,9 +3910,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5022,9 +4914,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5042,9 +4931,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5062,9 +4948,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5082,9 +4965,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ From a5a9e5f9cbaf3cbbca9fb44609242c2ddb723470 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Sat, 9 May 2026 09:38:28 +0000 Subject: [PATCH 4/4] feat: implement Deployer role and delivery workflow alignment --- README.md | 22 ++- defaults/AGENTS.md | 2 +- defaults/devclaw/prompts/deployer.md | 86 +++++++++ defaults/devclaw/workflow.yaml | 61 ++++++- dev/design/deployer-contract.md | 156 +++++++++++++++++ .../developing-devclaw-with-openclaw.md | 54 +++--- docs/ARCHITECTURE.md | 4 +- docs/CONFIGURATION.md | 35 +++- docs/ONBOARDING.md | 2 +- docs/REQUIREMENTS.md | 2 +- docs/ROADMAP.md | 2 +- docs/WORKFLOW.md | 66 ++++++- docs/exploratory/CONTROL-LAYER.md | 32 ++++ lib/config/loader.ts | 10 ++ lib/config/merge.ts | 12 ++ lib/config/schema.test.ts | 28 +++ lib/config/schema.ts | 34 +++- lib/dispatch/bootstrap-hook.test.ts | 20 +++ lib/dispatch/index.ts | 22 ++- lib/orchestrator-intervention/engine.ts | 6 +- lib/roles/registry.test.ts | 9 + lib/roles/registry.ts | 19 ++ lib/services/delivery-phases.test.ts | 163 ++++++++++++++++++ lib/services/heartbeat/delivery.ts | 92 ++++++++++ lib/services/heartbeat/index.ts | 1 + lib/services/heartbeat/passes.ts | 21 ++- lib/services/heartbeat/tick-runner.ts | 10 ++ lib/services/pipeline-delivery.test.ts | 42 +++++ lib/services/pipeline.ts | 40 ++++- lib/services/tick.ts | 71 +++++--- lib/setup/templates.ts | 2 + lib/testing/harness.ts | 1 + lib/tools/admin/project-register.ts | 6 +- lib/tools/admin/project-status.ts | 4 + lib/tools/admin/workflow-guide.ts | 61 ++++++- lib/tools/worker/work-finish.ts | 8 +- lib/workflow/candidate-provenance.ts | 112 ++++++++++++ lib/workflow/completion.ts | 25 ++- lib/workflow/defaults.ts | 57 +++++- lib/workflow/index.ts | 1 + lib/workflow/labels.ts | 19 +- lib/workflow/queries.ts | 78 ++++++++- lib/workflow/types.ts | 29 ++++ package-lock.json | 120 +++++++++++++ 44 files changed, 1546 insertions(+), 101 deletions(-) create mode 100644 defaults/devclaw/prompts/deployer.md create mode 100644 dev/design/deployer-contract.md create mode 100644 lib/config/schema.test.ts create mode 100644 lib/services/delivery-phases.test.ts create mode 100644 lib/services/heartbeat/delivery.ts create mode 100644 lib/services/pipeline-delivery.test.ts create mode 100644 lib/workflow/candidate-provenance.ts diff --git a/README.md b/README.md index e511c4b..3678d5f 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Each project is fully isolated — own queue, workers, sessions, and state. Work - **[Scheduling engine](#automatic-scheduling)** — `work_heartbeat` continuously scans queues, dispatches workers, and drives DEV → review → DEV [feedback loops](#how-tasks-flow-between-roles) - **[Project isolation](#execution-modes)** — parallel workers per project, parallel projects across the system -- **[Role instructions](#custom-instructions-per-project)** — per-project, per-role prompts injected at dispatch time +- **[Role instructions](#custom-instructions-per-project)** — per-project, per-role prompts injected via the bootstrap hook ### Process enforcement @@ -364,28 +364,40 @@ Workers can also comment during work — QA leaves review feedback, DEV posts im ### Custom instructions per project -Each project gets instruction files that workers receive with every task they pick up: +Each project gets instruction files that worker sessions load via the `agent:bootstrap` hook: ``` devclaw/ ├── workflow.yaml (workspace-level workflow overrides) ├── prompts/ (workspace defaults — fallback) │ ├── developer.md +│ ├── reviewer.md │ ├── tester.md +│ ├── deployer.md │ └── architect.md └── projects/ ├── my-webapp/ │ ├── workflow.yaml (project-specific workflow overrides) │ └── prompts/ │ ├── developer.md "Run npm test before committing. Deploy URL: staging.example.com" - │ └── tester.md "Check OAuth flow. Verify mobile responsiveness." + │ ├── reviewer.md "Code review rules and PR acceptance policy." + │ ├── tester.md "Check OAuth flow. Verify mobile responsiveness." + │ ├── deployer.md "Promotion steps, lane checks, proof-of-release requirements." + │ └── architect.md "Research alternatives and create implementation-ready tasks." └── my-api/ └── prompts/ ├── developer.md "Run cargo test. Follow REST conventions in CONTRIBUTING.md" - └── tester.md "Verify all endpoints return correct status codes." + ├── reviewer.md "Review API changes and PR quality." + ├── tester.md "Verify all endpoints return correct status codes." + ├── deployer.md "Promote approved builds between lanes and record evidence." + └── architect.md "Research architecture tradeoffs before implementation." ``` -Deployment steps, test commands, coding standards, acceptance criteria — all injected at dispatch time, per project, per role. +Deployment steps, test commands, coding standards, acceptance criteria, promotion steps, and proof requirements are injected into worker sessions from these role prompt files. + +The Deployer uses `deployer.md` as its dedicated prompt surface. + +Release policy, lane semantics, and proof requirements still belong in workflow/config and runbooks, not only in prompts. --- diff --git a/defaults/AGENTS.md b/defaults/AGENTS.md index 8c70881..32ff9d1 100644 --- a/defaults/AGENTS.md +++ b/defaults/AGENTS.md @@ -135,7 +135,7 @@ If the test phase is enabled in workflow.yaml: ### Prompt Instructions -Workers receive role-specific instructions appended to their task message. These are loaded from `devclaw/projects//prompts/.md` in the workspace, falling back to `devclaw/prompts/.md` if no project-specific file exists. `project_register` scaffolds these files automatically — edit them to customize worker behavior per project. +Workers receive role-specific instructions via the bootstrap hook, not by appending them to the task message. These are loaded from `devclaw/projects//prompts/.md` in the workspace, falling back to `devclaw/prompts/.md` if no project-specific file exists. `project_register` scaffolds these files automatically — edit them to customize worker behavior per project. ### Heartbeats diff --git a/defaults/devclaw/prompts/deployer.md b/defaults/devclaw/prompts/deployer.md new file mode 100644 index 0000000..127e84e --- /dev/null +++ b/defaults/devclaw/prompts/deployer.md @@ -0,0 +1,86 @@ +# DEPLOYER Worker Instructions + +You are the Deployer. Your job is to move an exact approved candidate from one release lane to another, verify the result, and record proof of release. + +## Context You Receive + +When you start work, you're given: + +- **Issue:** number, title, body, URL, labels, state +- **Comments:** full discussion thread on the issue +- **Project:** repo path, base branch, project name, projectSlug +- **Release context:** source lane, target lane, candidate identity, required evidence, and any project-specific runbook steps + +Read the issue body and comments carefully. Release work is evidence-sensitive. Do not guess at lane meaning, candidate identity, or acceptance rules. + +## Your Job + +1. **Understand the requested release step** + - Identify whether you are promoting, validating, accepting, or rolling back a candidate + - Confirm the source lane and target lane + - Confirm the exact candidate identity + +2. **Verify preconditions** + - Make sure the requested lane transition is allowed + - Make sure the candidate is the intended one + - Make sure any required approvals, checks, or prerequisites are satisfied before proceeding + +3. **Execute the release step** + - Follow the project runbook exactly + - Perform the required promotion, validation, acceptance, or rollback action + - Do not improvise a different release path because it seems close enough + +4. **Verify the result** + - Confirm the destination lane now contains the intended candidate + - Confirm the destination identity matches the requested promotion + - Confirm any required checks or validation evidence are collected + +5. **Record proof** + - Call `task_comment` with a release receipt that includes: + - source lane + - target lane + - candidate identity + - resulting destination identity or state + - verification evidence + - any relevant runbook notes + +6. **Escalate cleanly if blocked** + - If required evidence is missing, lane rules are unclear, or the release cannot be proven, stop and report the exact blocker + - Do not mark a release complete when proof is incomplete + +## Conventions + +- Treat workflow/config and project runbooks as the source of truth for lane definitions, allowed paths, and release policy +- Treat prompt instructions as execution guidance, not as a replacement for structural release rules +- Never guess at candidate identity +- Never claim success without proof +- Be explicit about what changed, where it changed, and how you verified it +- If a candidate must be demoted or rolled back, record that explicitly +- **Do NOT use closing keywords in PR/MR descriptions** (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X" instead + +## Filing Follow-Up Issues + +If you discover unrelated release-process gaps, environment drift, or missing tooling, call `task_create`: + +`task_create({ projectSlug: "", title: "Release: ...", description: "..." })` + +## Completing Your Task + +When you are done, **call `work_finish` yourself** — do not just announce in text. + +Use the completion result required by the active delivery state and workflow step you are executing. + +Your summary should include: +- the lane transition attempted +- the candidate identity +- the resulting destination state +- whether proof was successfully recorded + +If blocked, say exactly what proof, approval, environment access, or lane rule is missing. + +The `projectSlug` is included in your task message. + +## Tools You Should NOT Use + +These are orchestrator-only tools. Do not call them: +- `task_start`, `tasks_status`, `health`, `project_register` diff --git a/defaults/devclaw/workflow.yaml b/defaults/devclaw/workflow.yaml index 67090a9..5eee357 100644 --- a/defaults/devclaw/workflow.yaml +++ b/defaults/devclaw/workflow.yaml @@ -26,6 +26,10 @@ roles: models: junior: anthropic/claude-haiku-4-5 senior: anthropic/claude-sonnet-4-5 + deployer: + models: + junior: anthropic/claude-haiku-4-5 + senior: anthropic/claude-sonnet-4-5 workflow: initial: planning @@ -154,7 +158,47 @@ workflow: label: Testing color: "#9b59b6" on: - PASS: + PASS: toPromote + FAIL: + target: toImprove + actions: + - reopenIssue + REFINE: refining + BLOCKED: refining + toPromote: + type: queue + role: deployer + label: To Promote + color: "#1d76db" + priority: 2 + on: + PICKUP: promoting + SKIP: toAccept + PROMOTED: toAccept + FAIL: toImprove + DEMOTED: toImprove + BLOCKED: refining + promoting: + type: active + role: deployer + label: Promoting + color: "#6ea8fe" + on: + COMPLETE: toAccept + BLOCKED: refining + toAccept: + type: queue + role: deployer + label: To Accept + color: "#20c997" + priority: 2 + on: + PICKUP: accepting + SKIP: + target: done + actions: + - closeIssue + ACCEPTED: target: done actions: - closeIssue @@ -162,8 +206,23 @@ workflow: target: toImprove actions: - reopenIssue + DEMOTED: + target: toImprove + actions: + - reopenIssue REFINE: refining BLOCKED: refining + accepting: + type: active + role: deployer + label: Accepting + color: "#8ce0c4" + on: + COMPLETE: + target: done + actions: + - closeIssue + BLOCKED: refining done: type: terminal label: Done diff --git a/dev/design/deployer-contract.md b/dev/design/deployer-contract.md new file mode 100644 index 0000000..261a34a --- /dev/null +++ b/dev/design/deployer-contract.md @@ -0,0 +1,156 @@ +# Deployer contract + +This document describes the operator-facing contract for the DevClaw Deployer. + +Use it as the manual for how release promotion and acceptance are meant to work. + +## Core idea + +Release is a distinct process from implementation, review, and testing. + +- Development answers: was the change built correctly? +- Review answers: is the code acceptable? +- Testing answers: does it behave technically as expected? +- Release answers: should this exact candidate move from one lane to another, and can we prove that it did? + +Release initiation should be **policy-controlled**, not automatic. Like PR handling, it may be human-initiated or agent-initiated depending on project policy. + +## Flow + +```mermaid +flowchart TD + A[Candidate ready in source lane] --> B{Promotion initiated by policy?} + B -- no --> A + B -- human --> C[Promote candidate from source lane to target lane] + B -- agent --> C + C --> D[Record candidate identity and promotion receipt] + D --> E[Run lane-specific verification] + E --> F{Acceptance decision} + F -- accept --> G[Record acceptance receipt] + G --> H[Candidate accepted in target lane] + F -- reject --> I[Invalidate candidate] + I --> J[Demotion or rollback path] + F -- refine --> K[Return to refinement or improvement] + F -- blocked --> L[Pause for human decision] +``` + +## Required concepts + +### 1. Lanes are project-defined + +Projects define release lanes or environments structurally in config. + +Examples might be `dev`, `staging`, `production`, `local-current`, or something project-specific, but DevClaw core does not hardcode those names. + +### 2. Promotion is source to target + +Promotion means moving an exact candidate from one named lane to another named lane. + +A promotion request identifies at minimum: +- the candidate +- the source lane +- the target lane +- the promotion policy or type + +### Prompt surface + +The Deployer uses a dedicated `deployer.md` prompt surface. + +That prompt is where release-execution behavior belongs. It is not the source of truth for lanes, routing policy, allowed promotion paths, or proof requirements. Those remain structural workflow/config and runbook concerns. + +### 3. Candidate identity is mandatory + +A promoted candidate is tied to an exact identity, such as: +- commit SHA +- PR URL +- branch +- tag, version, build id, or artifact id when relevant + +### 4. Proof of release is mandatory + +The Deployer proves that it released the intended version. + +Minimum proof includes: +- source candidate identity +- source lane +- target lane +- resulting target identity or target state +- verification evidence that the destination matches the intended candidate + +Core rule: + +> Prove source identity, prove destination identity, prove they match the intended promotion. + +### 5. Acceptance is candidate-specific + +Acceptance applies to a specific promoted candidate, not the issue in general. + +Acceptance records: +- who accepted it +- where it was accepted +- what evidence was used +- what exact candidate was accepted + +### 6. Acceptance defaults should be strong but configurable + +Default acceptance criteria: +- candidate identity present +- source lane and target lane recorded +- proof of target state present +- required checks or evidence attached +- accepter identity recorded +- explicit outcome recorded + +Projects can override: +- who can accept +- required evidence +- required checks +- allowed outcomes +- per-lane rules + +### 7. Acceptance outcomes should be explicit + +Standard outcomes: +- `accept` +- `reject` +- `refine` +- `blocked` + +Rejecting acceptance invalidates the candidate, not just vaguely reopens the issue. + +### 8. Rollback and demotion must be explicit + +If a promoted candidate fails acceptance or later validation, the system explicitly marks it invalid and records the demotion or rollback path. + +### 9. Preconditions and repeat behavior must be defined + +The contract defines: +- what must already be true before promotion is allowed +- what should happen on repeated promotion attempts + - no-op + - retry + - replace candidate + - require explicit override + +## Config versus prompts + +This contract lives primarily in project config and workflow semantics, not only in prompts. + +Prompts can explain how a project uses the Deployer, but they are not the sole source of truth for: +- lane names +- allowed promotion paths +- acceptance authority +- required evidence +- lane-specific rules + +## Operator checklist + +A usable Deployer project setup defines at least: +- release lanes or environments +- allowed promotion paths between lanes +- candidate identity requirements +- proof-of-release requirements +- acceptance authority and outcomes +- rollback or demotion behavior +- preconditions for promotion +- retry and override behavior for repeated promotions diff --git a/dev/runbooks/developing-devclaw-with-openclaw.md b/dev/runbooks/developing-devclaw-with-openclaw.md index e9d4fd4..df7b55f 100644 --- a/dev/runbooks/developing-devclaw-with-openclaw.md +++ b/dev/runbooks/developing-devclaw-with-openclaw.md @@ -7,10 +7,9 @@ It lives under `/dev` because these rules are first-class local operating docs a Treat these branch roles as the working contract: -- `devclaw-local-dev`: ordinary implementation base branch and the live pre-promotion validation lane for normal issue work -- `devclaw-local-current`: accepted local truth, promotion, deploy, and local operational documentation lane +- `devclaw-local-current`: local truth and day-to-day working lane - `devclaw-local-stable`: local fallback lane when `devclaw-local-current` is too noisy or risky -- `issue/*`: local implementation branches for scoped work, normally created from `devclaw-local-dev` +- `issue/*`: local implementation branches for scoped work - `review/*`: local review branches opened against `devclaw-local-current` - `pr/*`: export branches prepared for upstream review @@ -19,13 +18,11 @@ Upstream `main` is a reference point and export target. It is not the normal day ## Operating model 1. Keep local docs and operator runbooks on `devclaw-local-current`. -2. Start ordinary implementation from `devclaw-local-dev` into an `issue/*` branch when you need isolated task work. -3. Use the ordinary implementation PR into `devclaw-local-dev` as the normal developer completion lane. -4. Make `devclaw-local-dev` the live validation lane for ordinary implementation work when live testing is required, and verify the fix there before promoting it further. -5. Only after the fix is accepted on `devclaw-local-dev`, promote it into `devclaw-local-current` through the `review/*` local-truth lane. -6. After the accepted package is present on `devclaw-local-current`, export the upstreamable change set onto a matching `pr/*` branch. -7. Preserve the `/dev/` documentation changes on `devclaw-local-current` even when the upstream export omits local-only material. -8. Push runbook and workflow changes to the Git remote that tracks `devclaw-local-current` so the policy is not left only in a local checkout or an unknown branch. +2. Start implementation from `devclaw-local-current` into an `issue/*` branch when you need isolated task work. +3. Land validated work back onto `devclaw-local-current` so local truth stays complete. +4. When work needs to go upstream, export it onto a matching `pr/*` branch. +5. Preserve the `/dev/` documentation changes on `devclaw-local-current` even when the upstream export omits local-only material. +6. Push runbook and workflow changes to the Git remote that tracks `devclaw-local-current` so the policy is not left only in a local checkout or an unknown branch. ## Mandatory compliance rule @@ -89,29 +86,23 @@ Where: Typical flow: -1. implement on `issue/*` from `devclaw-local-dev` -2. land the ordinary implementation PR into `devclaw-local-dev` -3. make `devclaw-local-dev` live when needed for development validation and test the accepted fix there -4. open or update the local promotion issue that owns the full upstream-promotion workflow -5. create or refresh `review/-` from `devclaw-local-current` -6. cherry-pick or otherwise apply the exact accepted fix onto the `review/*` branch -7. push the `review/*` branch to the fork remote -8. autonomously open or refresh the fork PR from `review/*` into `devclaw-local-current` -9. use that PR for local-truth review, merge, and accepted-lane validation on `devclaw-local-current` -10. after the change is merged and validated on `devclaw-local-current`, create or refresh `pr/-` from `upstream/main` -11. cherry-pick or otherwise apply the same upstreamable commit set onto the `pr/*` branch -12. push the `pr/*` branch to the fork remote -13. prepare the final compare/diff URL and PR body for DevClaw official +1. implement and validate locally +2. open or update the local promotion issue that owns the full upstream-promotion workflow +3. create or refresh `review/-` from `devclaw-local-current` +4. apply the exact accepted fix onto the `review/*` branch +5. push the `review/*` branch to the fork remote +6. autonomously open or refresh the fork PR from `review/*` into `devclaw-local-current` +7. use that PR for local-truth review, merge, and testing on `devclaw-local-current` +8. after the change is merged and validated on `devclaw-local-current`, create or refresh `pr/-` from `upstream/main` +9. apply the same upstreamable commit set onto the `pr/*` branch +10. push the `pr/*` branch to the fork remote +11. prepare the final compare/diff URL and PR body for DevClaw official Upstream review material should be prepared from `pr/*`, while `devclaw-local-current` remains the complete local operating branch. ### Promotion ownership and close-gate rule -The orchestrator owns the **promotion lane** end-to-end. Do not treat worker completion as promotion completion. - -This section applies to explicit promotion/export work, for example `UP:` issues or tasks that are intentionally preparing `review/*` and `pr/*` branches. -It does **not** mean the orchestrator owns the ordinary implementation PR for normal issue work. -For normal implementation tasks, the developer should still open and maintain the issue PR into the active implementation base branch. +The orchestrator owns the promotion lane end-to-end. Do not treat worker completion as promotion completion. Worker-owned steps may include implementation, review, or testing sub-results such as: @@ -124,7 +115,6 @@ Those results are evidence and inputs to the promotion lane. They do **not** by For local-first release work, the orchestrator-owned responsibilities include: - keeping the promotion issue current as the canonical lane record -- making the `devclaw-local-dev` live-validation checkpoint explicit when the lane requires ordinary implementation validation before promotion - creating or refreshing the `review/*` branch and local-truth PR - driving the required human checkpoint on that PR - ensuring the accepted change actually lands in `devclaw-local-current` @@ -174,6 +164,12 @@ The point of the export is to publish local truth, not replace it. ## Promotion issue requirement +Generic Deployer contract and terminology for promotion, acceptance, proof of release, rollback, and operator initiation now live in: + +- `dev/design/deployer-contract.md` + +Use that design doc as the generic model. This runbook remains the DevClaw-specific mapping of that model onto local lanes such as `devclaw-local-dev`, `devclaw-local-current`, live self-hosted validation, and upstream handoff. + Do not promote code to DevClaw official without a local issue that covers the full promotion from start to finish. That issue is not just "prep". It owns the entire promotion workflow. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 47ff387..250ec8b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -546,6 +546,8 @@ sequenceDiagram The source path is logged for production traceability: `Bootstrap hook: injected developer instructions for project "my-app" from /path/to/prompts/developer.md`. +The Deployer uses a dedicated `deployer.md` prompt surface. + ## Data flow map Every piece of data and where it lives: @@ -757,7 +759,7 @@ See [CONFIGURATION.md](CONFIGURATION.md) for the full reference. | Worker state | `/devclaw/projects.json` | Per-project worker state | | Workflow config (workspace) | `/devclaw/workflow.yaml` | Workspace-level role/workflow overrides | | Workflow config (project) | `/devclaw/projects//workflow.yaml` | Project-specific overrides | -| Default role instructions | `/devclaw/prompts/.md` | Default `developer.md`, `tester.md`, `architect.md` | +| Default role instructions | `/devclaw/prompts/.md` | Default `developer.md`, `reviewer.md`, `tester.md`, `deployer.md`, `architect.md` | | Project role instructions | `/devclaw/projects//prompts/.md` | Per-project role instruction overrides | | Audit log | `/devclaw/log/audit.log` | NDJSON event log | | Session transcripts | `~/.openclaw/agents//sessions/.jsonl` | Conversation history per session | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0546812..311aa23 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -82,9 +82,25 @@ roles: ### Workflow States -The workflow section defines the state machine for issue lifecycle — states, transitions, review policy, and the optional test phase. +The workflow section defines the state machine for issue lifecycle — states, transitions, review policy, the optional test phase, and optional delivery policies for promotion and acceptance. -See **[Workflow Reference](WORKFLOW.md)** for the full state machine documentation, including state types, built-in actions, review policy options, and how to enable the test phase. +See **[Workflow Reference](WORKFLOW.md)** for the full state machine documentation, including state types, built-in actions, review policy options, how to enable the test phase, and the current delivery-phase contract. + +### Release configuration + +Workflow config expresses at minimum: +- promotion policy (`skip`, `agent`, `human`) +- acceptance policy (`skip`, `agent`, `human`) +- the queue and active states used for those phases + +Release-agent configuration should also define: +- project-defined lane or environment names +- allowed source → target promotion paths +- shared default acceptance criteria +- required release evidence or proof receipts +- retry and override behavior for repeated promotions + +For the operator-facing contract, see [`../dev/design/deployer-contract.md`](../dev/design/deployer-contract.md). ### Timeouts @@ -362,19 +378,26 @@ Each role in the `workers` record has a `WorkerState` object: │ ├── workflow.yaml ← Workspace-level config overrides │ ├── prompts/ │ │ ├── developer.md ← Default developer instructions +│ │ ├── reviewer.md ← Default reviewer instructions │ │ ├── tester.md ← Default tester instructions +│ │ ├── deployer.md ← Default Deployer instructions │ │ └── architect.md ← Default architect instructions │ ├── projects/ │ │ ├── my-webapp/ │ │ │ ├── workflow.yaml ← Project-specific config overrides │ │ │ └── prompts/ │ │ │ ├── developer.md ← Project-specific developer instructions +│ │ │ ├── reviewer.md ← Project-specific reviewer instructions │ │ │ ├── tester.md ← Project-specific tester instructions +│ │ │ ├── deployer.md ← Project-specific Deployer instructions │ │ │ └── architect.md ← Project-specific architect instructions │ │ └── another-project/ │ │ └── prompts/ │ │ ├── developer.md -│ │ └── tester.md +│ │ ├── reviewer.md +│ │ ├── tester.md +│ │ ├── deployer.md +│ │ └── architect.md │ └── log/ │ └── audit.log ← NDJSON event log (auto-managed) ├── AGENTS.md ← Agent identity documentation @@ -385,7 +408,11 @@ Each role in the `workers` record has a `WorkerState` object: Role instructions are injected into worker sessions via the `agent:bootstrap` hook at session startup. The hook loads instructions from `devclaw/projects//prompts/.md`, falling back to `devclaw/prompts/.md`. -Edit to customize: deployment steps, test commands, acceptance criteria, coding standards. +Edit to customize: deployment steps, test commands, acceptance criteria, coding standards, promotion steps, and proof-of-release behavior. + +The Deployer uses `deployer.md` as its dedicated prompt surface. + +Release lanes, routing policy, and proof requirements belong in workflow/config and runbooks, not only in prompt text. **Source:** [`lib/dispatch/bootstrap-hook.ts`](../lib/dispatch/bootstrap-hook.ts) diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index 21f0660..5579ff2 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -155,7 +155,7 @@ Go to the Telegram/WhatsApp group for the project and tell the orchestrator agen The agent calls `project_register`, which atomically: - Validates the repo and auto-detects GitHub/GitLab from remote - Creates all state labels (idempotent) -- Scaffolds role instruction files (`devclaw/projects//prompts/developer.md`, `tester.md`, `architect.md`) +- Scaffolds role instruction files (`devclaw/projects//prompts/developer.md`, `reviewer.md`, `tester.md`, `deployer.md`, `architect.md`) - Adds the project entry to `projects.json` - Logs the registration event diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index ff037e6..656431a 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -30,7 +30,7 @@ DevClaw orchestrates AI development agents across GitHub and GitLab projects. Th **Layer 2 — Workflow State Machine** controls _when_ work moves. Label-driven state transitions on the issue tracker. Heartbeat scans queues, dispatches workers, handles PR lifecycle. Configured per-workspace or per-project in `workflow.yaml`. -**Layer 3 — Role Prompts** controls _how_ work is done. System-level instructions injected into worker sessions via the bootstrap hook. Per-role (`developer.md`, `tester.md`) and per-project overrides. +**Layer 3 — Role Prompts** controls _how_ work is done. System-level instructions injected into worker sessions via the bootstrap hook. Per-role (`developer.md`, `reviewer.md`, `tester.md`, `deployer.md`, `architect.md`) and per-project overrides. The Deployer uses `deployer.md` as its dedicated prompt surface. **Layer 4 — Task Instructions** controls _what_ work is done. Built from issue description, comments, PR feedback, attachments. Constructed fresh on each dispatch. Includes mandatory completion instructions (`work_finish` call). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a750832..f27331a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -19,7 +19,7 @@ Planning → To Do → Doing → To Review → [PR approved → auto-merge] → To Research → Researching → Planning (architect posts findings) ``` -States have types (`queue`, `active`, `hold`, `terminal`), transitions with actions (`gitPull`, `detectPr`, `mergePr`, `closeIssue`, `reopenIssue`), and review checks (`prMerged`, `prApproved`). The test phase (toTest, testing) can be enabled via `workflow.yaml` — see [Workflow](WORKFLOW.md#test-phase-optional). +States have types (`queue`, `active`, `hold`, `terminal`), transitions with actions (`gitPull`, `detectPr`, `mergePr`, `closeIssue`, `reopenIssue`), and review checks (`prMerged`, `prApproved`). The test phase (toTest, testing) and delivery phases (toPromote/promoting, toAccept/accepting) can be enabled or skipped via `workflow.yaml` — see [Workflow](WORKFLOW.md#test-phase-optional). ### Three-Layer Configuration diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 09ebd56..044d862 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -1,6 +1,6 @@ # DevClaw — Workflow Reference -The issue lifecycle in DevClaw is a configurable state machine defined in `workflow.yaml`. This document covers the default pipeline, all state types, review policies, and the optional test phase. +The issue lifecycle in DevClaw is a configurable state machine defined in `workflow.yaml`. This document covers the default pipeline, all state types, review policies, the optional test phase, and the optional delivery phases for candidate promotion and acceptance. For config file format and location, see [Configuration](CONFIGURATION.md). @@ -12,7 +12,7 @@ For config file format and location, see [Configuration](CONFIGURATION.md). Planning → To Do → Doing → To Review → PR approved → Done (auto-merge + close) ``` -Human review, no test phase. Approved PRs are auto-merged and the issue is closed. +Human review, no test phase, and delivery phases skipped by default. Approved PRs are auto-merged, test is auto-skipped, promotion is auto-skipped, acceptance is auto-skipped, and the issue is closed. ```mermaid stateDiagram-v2 @@ -196,6 +196,12 @@ Override the project-level policy for a single issue using labels: **Source:** [`lib/workflow/queries.ts`](../lib/workflow/queries.ts) — `resolveReviewRouting()` +### Reviewer Prompt Configuration + +Agent review uses the reviewer role prompt files: +- Default: `devclaw/prompts/reviewer.md` +- Per-project: `devclaw/projects//prompts/reviewer.md` + --- ## Test Phase (optional) @@ -366,6 +372,62 @@ These prompts should instruct the tester to always call `task_comment` before `w --- +## Delivery Phases (optional) + +Delivery extends the workflow after technical review and optional testing. + +The current built-in phases are: +- **To Promote / Promoting** +- **To Accept / Accepting** + +These phases are intentionally about **candidate promotion** and **candidate acceptance**, not generic extra testing. + +### Important current rule + +Release initiation should be **policy-controlled**. Like PR handling, a project may choose human or agent initiation, but promotion should not be treated as automatic forward motion just because implementation, review, or testing completed. + +### Delivery flow shape + +```mermaid +flowchart TD + A[Candidate ready in source lane] --> B{Promotion initiated by policy?} + B -- no --> A + B -- human --> C[Promote candidate from source lane to target lane] + B -- agent --> C + C --> D[Record candidate identity and promotion receipt] + D --> E[Run lane-specific verification] + E --> F{Acceptance decision} + F -- accept --> G[Record acceptance receipt] + G --> H[Candidate accepted in target lane] + F -- reject --> I[Invalidate candidate] + I --> J[Demotion or rollback path] + F -- refine --> K[Return to refinement or improvement] + F -- blocked --> L[Pause for human decision] +``` + +### Release-agent contract + +Delivery phases work together with the Deployer contract. + +Projects define: +- lanes or environments +- allowed source → target promotion paths +- proof-of-release receipts +- acceptance criteria and authority +- retry, repeat, and override behavior + +For the operator-facing contract, see [`dev/design/deployer-contract.md`](../dev/design/deployer-contract.md). + +### Prompt files for delivery phases + +The Deployer uses a dedicated `deployer.md` prompt surface: +- Default: `devclaw/prompts/deployer.md` +- Per-project: `devclaw/projects//prompts/deployer.md` + +Put release-execution instructions in that prompt surface. Put lane definitions, routing policy, and release proof requirements in workflow/config and project runbooks. + +--- + ## Customizing the Workflow ### Adding or Modifying States diff --git a/docs/exploratory/CONTROL-LAYER.md b/docs/exploratory/CONTROL-LAYER.md index 21fd33e..46b6f1b 100644 --- a/docs/exploratory/CONTROL-LAYER.md +++ b/docs/exploratory/CONTROL-LAYER.md @@ -31,6 +31,7 @@ Instructions injected into the LLM context. The agent *should* follow them but * | `devclaw/prompts/developer.md` | Bootstrap hook → `WORKER_INSTRUCTIONS.md` | Work in worktrees, don't merge PR, no closing keywords in PR description | | `devclaw/prompts/reviewer.md` | Bootstrap hook → `WORKER_INSTRUCTIONS.md` | Review diff only, call task_comment first, then approve/reject | | `devclaw/prompts/tester.md` | Bootstrap hook → `WORKER_INSTRUCTIONS.md` | Run tests, always call task_comment with findings | +| `devclaw/prompts/deployer.md` | Bootstrap hook → `WORKER_INSTRUCTIONS.md` | Promotion steps, lane checks, release evidence, rollback handling | | `AGENTS.md` | Workspace context file | Orchestrator must never write code, priority ordering, tool restrictions | | `SOUL.md` / `IDENTITY.md` | Workspace context file | Personality, communication style | | `buildTaskMessage()` | Appended to task message | Mandatory completion block: "you MUST call work_finish" with valid results | @@ -41,6 +42,8 @@ Role prompts are resolved per-project with fallback: 1. `devclaw/projects//prompts/.md` 2. `devclaw/prompts/.md` +The Deployer uses `deployer.md` as its dedicated prompt surface. + ### What can go wrong - Architect calls `work_finish(done)` without creating a task — **no code guard** @@ -115,6 +118,13 @@ Three-layer merge: **built-in defaults → workspace yaml → project yaml**. Va | Setting | Default | Effect | |---|---|---| | `workflow.reviewPolicy` | `human` | `human` / `agent` / `auto` — controls review routing | +| `workflow.testPolicy` | `skip` | `skip` / `agent` — controls test routing | +| `workflow.delivery.promotion.policy` | `skip` | `skip` / `agent` / `human` — controls promotion routing | +| `workflow.delivery.acceptance.policy` | `skip` | `skip` / `agent` / `human` — controls acceptance routing | +| `workflow.delivery.promotion.queueState` | `toPromote` | Queue state used for promotion | +| `workflow.delivery.promotion.activeState` | `promoting` | Active state used for promotion | +| `workflow.delivery.acceptance.queueState` | `toAccept` | Queue state used for acceptance | +| `workflow.delivery.acceptance.activeState` | `accepting` | Active state used for acceptance | | `roles..models` | Registry defaults | Which model runs at each level | | `roles..levels` | Registry defaults | Available level names | | `roles..completionResults` | Registry defaults | Valid results for `work_finish` | @@ -131,7 +141,14 @@ Three-layer merge: **built-in defaults → workspace yaml → project yaml**. Va | `review:human` | Force human PR review | | `review:agent` | Force agent PR review | | `review:skip` | Skip review | +| `test:agent` | Route through tester phase | | `test:skip` | Skip test phase | +| `promotion:human` | Route promotion through human-controlled delivery pass | +| `promotion:agent` | Route promotion through agent reviewer pickup | +| `promotion:skip` | Skip promotion and advance on heartbeat | +| `acceptance:human` | Route acceptance through human-controlled delivery pass | +| `acceptance:agent` | Route acceptance through agent tester pickup | +| `acceptance:skip` | Skip acceptance and close on heartbeat | --- @@ -158,6 +175,18 @@ For issues in review states with `review:human` + eyes marker: - Merge conflict → To Improve - Merge failure → To Improve +### Delivery pass — promotion and acceptance routing + +For issues in delivery queue states: +- `promotion:agent` → reviewer pickup path (`To Promote` → `Promoting`) +- `promotion:skip` → heartbeat advances promotion without reviewer pickup +- `promotion:human` → heartbeat advances only when a current candidate record exists with status `active` +- `acceptance:agent` → tester pickup path (`To Accept` → `Accepting`) +- `acceptance:skip` → heartbeat marks the candidate `accepted`, advances, and closes per workflow +- `acceptance:human` → heartbeat advances only when a current candidate record exists with status `accepted` + +The delivery pass uses the configured promotion and acceptance queue states, reads per-issue routing labels, and performs deterministic label transitions plus close/reopen actions from the workflow statechart. + ### Tick pass — queue scanning Fills free worker slots by priority. Respects: one worker per role, sequential mode, maxPickupsPerTick (default 4), review/test skip labels. @@ -190,7 +219,10 @@ GitHub/GitLab settings that DevClaw reads but does not configure. | Can't finish with wrong role:result pair | Code (`isValidResult`) | No | | Can't run two workers of same role | Code (slot check) | No | | Review routing (human/agent/auto) | Code (computed label) | No | +| Test routing (`test:agent` / `test:skip`) | Code (computed label) | No | +| Delivery routing (`promotion:*`, `acceptance:*`) | Code (computed label) | No | | Auto-merge only for managed issues | Code (eyes reaction filter) | No | | Stale worker cleanup | Heartbeat (autonomous) | N/A | | PR approval detection | Heartbeat (autonomous) | N/A | +| Delivery-phase advancement for skip/human routes | Heartbeat (autonomous) | N/A | | Branch protection | GitHub/GitLab | N/A | diff --git a/lib/config/loader.ts b/lib/config/loader.ts index 9bcc403..79aa050 100644 --- a/lib/config/loader.ts +++ b/lib/config/loader.ts @@ -178,6 +178,16 @@ function resolve(config: DevClawConfig): ResolvedConfig { initial: config.workflow?.initial ?? DEFAULT_WORKFLOW.initial, reviewPolicy: config.workflow?.reviewPolicy ?? DEFAULT_WORKFLOW.reviewPolicy, testPolicy: config.workflow?.testPolicy ?? DEFAULT_WORKFLOW.testPolicy, + delivery: { + promotion: { + ...DEFAULT_WORKFLOW.delivery?.promotion, + ...config.workflow?.delivery?.promotion, + }, + acceptance: { + ...DEFAULT_WORKFLOW.delivery?.acceptance, + ...config.workflow?.delivery?.acceptance, + }, + }, roleExecution: config.workflow?.roleExecution ?? DEFAULT_WORKFLOW.roleExecution, states: { ...DEFAULT_WORKFLOW.states, ...config.workflow?.states }, }; diff --git a/lib/config/merge.ts b/lib/config/merge.ts index e33a839..0095705 100644 --- a/lib/config/merge.ts +++ b/lib/config/merge.ts @@ -48,6 +48,18 @@ export function mergeConfig( initial: overlay.workflow?.initial ?? base.workflow?.initial, reviewPolicy: overlay.workflow?.reviewPolicy ?? base.workflow?.reviewPolicy, testPolicy: overlay.workflow?.testPolicy ?? base.workflow?.testPolicy, + delivery: base.workflow?.delivery || overlay.workflow?.delivery + ? { + promotion: { + ...base.workflow?.delivery?.promotion, + ...overlay.workflow?.delivery?.promotion, + }, + acceptance: { + ...base.workflow?.delivery?.acceptance, + ...overlay.workflow?.delivery?.acceptance, + }, + } + : undefined, roleExecution: overlay.workflow?.roleExecution ?? base.workflow?.roleExecution, maxWorkersPerLevel: overlay.workflow?.maxWorkersPerLevel ?? base.workflow?.maxWorkersPerLevel, states: { diff --git a/lib/config/schema.test.ts b/lib/config/schema.test.ts new file mode 100644 index 0000000..1ec0145 --- /dev/null +++ b/lib/config/schema.test.ts @@ -0,0 +1,28 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { validateWorkflowIntegrity } from "./schema.js"; +import { DEFAULT_WORKFLOW } from "../workflow/index.js"; + +describe("validateWorkflowIntegrity delivery role validation", () => { + it("rejects promotion states that are not deployer-owned", () => { + const workflow = structuredClone(DEFAULT_WORKFLOW); + workflow.delivery!.promotion!.queueState = "toTest"; + workflow.delivery!.promotion!.activeState = "testing"; + + const errors = validateWorkflowIntegrity(workflow); + + assert.ok(errors.includes("workflow.delivery.promotion.queueState must reference a deployer-owned state")); + assert.ok(errors.includes("workflow.delivery.promotion.activeState must reference a deployer-owned state")); + }); + + it("rejects acceptance states that are not deployer-owned", () => { + const workflow = structuredClone(DEFAULT_WORKFLOW); + workflow.delivery!.acceptance!.queueState = "toReview"; + workflow.delivery!.acceptance!.activeState = "testing"; + + const errors = validateWorkflowIntegrity(workflow); + + assert.ok(errors.includes("workflow.delivery.acceptance.queueState must reference a deployer-owned state")); + assert.ok(errors.includes("workflow.delivery.acceptance.activeState must reference a deployer-owned state")); + }); +}); diff --git a/lib/config/schema.ts b/lib/config/schema.ts index e18f47a..e5af175 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -30,10 +30,20 @@ const StateConfigSchema = z.object({ on: z.record(z.string(), TransitionTargetSchema).optional(), }); +const DeliveryPhaseSchema = z.object({ + policy: z.enum(["human", "agent", "skip"]).optional(), + queueState: z.string().optional(), + activeState: z.string().optional(), +}).optional(); + const WorkflowConfigSchema = z.object({ initial: z.string(), reviewPolicy: z.enum(["human", "agent", "skip"]).optional(), testPolicy: z.enum(["skip", "agent"]).optional(), + delivery: z.object({ + promotion: DeliveryPhaseSchema, + acceptance: DeliveryPhaseSchema, + }).optional(), roleExecution: z.enum(["parallel", "sequential"]).optional(), maxWorkersPerLevel: z.number().int().positive().optional(), states: z.record(z.string(), StateConfigSchema), @@ -95,7 +105,7 @@ export function validateConfig(raw: unknown): void { * - Terminal states have no outgoing transitions */ export function validateWorkflowIntegrity( - workflow: { initial: string; states: Record }> }, + workflow: { initial: string; delivery?: { promotion?: { queueState?: string; activeState?: string }; acceptance?: { queueState?: string; activeState?: string } }; states: Record }> }, ): string[] { const errors: string[] = []; const stateKeys = new Set(Object.keys(workflow.states)); @@ -104,6 +114,28 @@ export function validateWorkflowIntegrity( errors.push(`Initial state "${workflow.initial}" does not exist in states`); } + const validateDeliveryRef = (phase: "promotion" | "acceptance", stateKind: "queueState" | "activeState", value?: string) => { + if (!value) return; + if (!stateKeys.has(value)) { + errors.push(`workflow.delivery.${phase}.${stateKind} references non-existent state "${value}"`); + return; + } + const state = workflow.states[value]; + const expectedType = stateKind === "queueState" ? StateType.QUEUE : StateType.ACTIVE; + const expectedRole = phase === "promotion" || phase === "acceptance" ? "deployer" : undefined; + if (state?.type !== expectedType) { + errors.push(`workflow.delivery.${phase}.${stateKind} must reference a ${expectedType} state`); + } + if (expectedRole && state?.role !== expectedRole) { + errors.push(`workflow.delivery.${phase}.${stateKind} must reference a ${expectedRole}-owned state`); + } + }; + + validateDeliveryRef("promotion", "queueState", workflow.delivery?.promotion?.queueState); + validateDeliveryRef("promotion", "activeState", workflow.delivery?.promotion?.activeState); + validateDeliveryRef("acceptance", "queueState", workflow.delivery?.acceptance?.queueState); + validateDeliveryRef("acceptance", "activeState", workflow.delivery?.acceptance?.activeState); + for (const [key, state] of Object.entries(workflow.states)) { if (state.type === StateType.QUEUE && !state.role) { errors.push(`Queue state "${key}" must have a role assigned`); diff --git a/lib/dispatch/bootstrap-hook.test.ts b/lib/dispatch/bootstrap-hook.test.ts index 9a80698..4e9c7e6 100644 --- a/lib/dispatch/bootstrap-hook.test.ts +++ b/lib/dispatch/bootstrap-hook.test.ts @@ -27,6 +27,11 @@ describe("parseDevClawSessionKey", () => { assert.deepStrictEqual(result, { projectName: "webapp", role: "tester" }); }); + it("should parse a deployer session key", () => { + const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-deployer-junior"); + assert.deepStrictEqual(result, { projectName: "webapp", role: "deployer" }); + }); + it("should handle project names with hyphens", () => { const result = parseDevClawSessionKey("agent:devclaw:subagent:my-cool-project-developer-junior"); assert.deepStrictEqual(result, { projectName: "my-cool-project", role: "developer" }); @@ -147,6 +152,21 @@ describe("loadRoleInstructions", () => { await fs.rm(tmpDir, { recursive: true }); }); + it("should load deployer instructions from both workspace and package defaults", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); + const promptsDir = path.join(tmpDir, "devclaw", "prompts"); + await fs.mkdir(promptsDir, { recursive: true }); + await fs.writeFile(path.join(promptsDir, "deployer.md"), "# Deployer Default\nPromote carefully."); + + const workspaceResult = await loadRoleInstructions(tmpDir, "missing", "deployer"); + assert.strictEqual(workspaceResult, "# Deployer Default\nPromote carefully."); + + await fs.rm(tmpDir, { recursive: true }); + + const packageResult = await loadRoleInstructions(process.cwd(), "missing", "deployer"); + assert.strictEqual(packageResult, DEFAULT_ROLE_INSTRUCTIONS.deployer); + }); + it("should return empty string for unknown roles with no defaults", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); diff --git a/lib/dispatch/index.ts b/lib/dispatch/index.ts index d0ca044..eb2c2d8 100644 --- a/lib/dispatch/index.ts +++ b/lib/dispatch/index.ts @@ -18,7 +18,7 @@ import { import { resolveModel } from "../roles/index.js"; import { notify, getNotificationConfig } from "./notify.js"; import { loadConfig, type ResolvedRoleConfig } from "../config/index.js"; -import { ReviewPolicy, TestPolicy, resolveReviewRouting, resolveTestRouting, resolveNotifyChannel, isFeedbackState, hasReviewCheck, producesReviewableWork, hasTestPhase, detectOwner, getOwnerLabel, OWNER_LABEL_COLOR, getRoleLabelColor, STEP_ROUTING_COLOR, getStateLabels } from "../workflow/index.js"; +import { ReviewPolicy, TestPolicy, DeliveryPolicy, resolveReviewRouting, resolveTestRouting, resolveDeliveryRouting, resolveNotifyChannel, isFeedbackState, hasReviewCheck, producesReviewableWork, hasTestPhase, hasDeliveryPhase, detectOwner, getOwnerLabel, OWNER_LABEL_COLOR, getRoleLabelColor, STEP_ROUTING_COLOR, getStateLabels } from "../workflow/index.js"; import { fetchPrFeedback, fetchPrContext, type PrFeedback, type PrContext } from "./pr-context.js"; import { formatAttachmentsForTask } from "./attachments.js"; import { loadRoleInstructions } from "./bootstrap-hook.js"; @@ -253,6 +253,26 @@ export async function dispatchTask( await provider.addLabel(issueId, testLabel); } + if (hasDeliveryPhase(workflow, "promotion")) { + const promotionPolicy = workflow.delivery?.promotion?.policy ?? DeliveryPolicy.SKIP; + const promotionLabel = resolveDeliveryRouting(promotionPolicy, "promotion"); + const oldPromotionRouting = issue.labels.filter((l) => l.startsWith("promotion:")); + const safePromotionRouting = filterNonStateLabels(oldPromotionRouting, stateLabels); + if (safePromotionRouting.length > 0) await provider.removeLabels(issueId, safePromotionRouting); + await provider.ensureLabel(promotionLabel, STEP_ROUTING_COLOR); + await provider.addLabel(issueId, promotionLabel); + } + + if (hasDeliveryPhase(workflow, "acceptance")) { + const acceptancePolicy = workflow.delivery?.acceptance?.policy ?? DeliveryPolicy.SKIP; + const acceptanceLabel = resolveDeliveryRouting(acceptancePolicy, "acceptance"); + const oldAcceptanceRouting = issue.labels.filter((l) => l.startsWith("acceptance:")); + const safeAcceptanceRouting = filterNonStateLabels(oldAcceptanceRouting, stateLabels); + if (safeAcceptanceRouting.length > 0) await provider.removeLabels(issueId, safeAcceptanceRouting); + await provider.ensureLabel(acceptanceLabel, STEP_ROUTING_COLOR); + await provider.addLabel(issueId, acceptanceLabel); + } + // Apply owner label if issue is unclaimed (auto-claim on pickup) if (opts.instanceName && !detectOwner(issue.labels)) { const ownerLabel = getOwnerLabel(opts.instanceName); diff --git a/lib/orchestrator-intervention/engine.ts b/lib/orchestrator-intervention/engine.ts index c0d716c..9cfe893 100644 --- a/lib/orchestrator-intervention/engine.ts +++ b/lib/orchestrator-intervention/engine.ts @@ -243,12 +243,16 @@ function renderTemplate(template: string | undefined, event: OrchestratorInterve function findPlanningLabel(workflow: InterventionRuntimeContext["workflow"]): string { const planningLabel = getInitialStateLabel(workflow); const planning = findStateByLabel(workflow, planningLabel); - if (!planning || planning.type !== StateType.HOLD) { + if (!planning || !isHoldState(planning.type)) { throw new Error(`workflow initial state ${planningLabel} is not a HOLD state`); } return planningLabel; } +function isHoldState(type: string | undefined): boolean { + return (type ?? "").toLowerCase() === StateType.HOLD; +} + async function wakeOrchestrator( ctx: InterventionRuntimeContext, event: OrchestratorInterventionEvent, diff --git a/lib/roles/registry.test.ts b/lib/roles/registry.test.ts index 8835028..51ba1c4 100644 --- a/lib/roles/registry.test.ts +++ b/lib/roles/registry.test.ts @@ -33,6 +33,7 @@ describe("role registry", () => { assert.ok(ids.includes("tester")); assert.ok(ids.includes("architect")); assert.ok(ids.includes("reviewer")); + assert.ok(ids.includes("deployer")); }); it("should validate role IDs", () => { @@ -40,6 +41,7 @@ describe("role registry", () => { assert.strictEqual(isValidRole("tester"), true); assert.strictEqual(isValidRole("architect"), true); assert.strictEqual(isValidRole("reviewer"), true); + assert.strictEqual(isValidRole("deployer"), true); assert.strictEqual(isValidRole("nonexistent"), false); }); @@ -61,6 +63,7 @@ describe("levels", () => { assert.deepStrictEqual([...getLevelsForRole("tester")], ["junior", "medior", "senior"]); assert.deepStrictEqual([...getLevelsForRole("architect")], ["junior", "senior"]); assert.deepStrictEqual([...getLevelsForRole("reviewer")], ["junior", "senior"]); + assert.deepStrictEqual([...getLevelsForRole("deployer")], ["junior", "senior"]); }); it("should return empty for unknown role", () => { @@ -133,6 +136,7 @@ describe("models", () => { assert.strictEqual(getDefaultModel("developer", "medior"), "anthropic/claude-sonnet-4-5"); assert.strictEqual(getDefaultModel("tester", "medior"), "anthropic/claude-sonnet-4-5"); assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-6"); + assert.strictEqual(getDefaultModel("deployer", "senior"), "anthropic/claude-sonnet-4-5"); }); it("should return all default models", () => { @@ -192,6 +196,7 @@ describe("completion results", () => { assert.deepStrictEqual([...getCompletionResults("tester")], ["pass", "fail", "refine", "blocked"]); assert.deepStrictEqual([...getCompletionResults("architect")], ["done", "blocked"]); assert.deepStrictEqual([...getCompletionResults("reviewer")], ["approve", "reject", "blocked"]); + assert.deepStrictEqual([...getCompletionResults("deployer")], ["done", "blocked"]); }); it("should validate results", () => { @@ -203,6 +208,8 @@ describe("completion results", () => { assert.strictEqual(isValidResult("reviewer", "reject"), true); assert.strictEqual(isValidResult("reviewer", "escalate"), false); assert.strictEqual(isValidResult("reviewer", "done"), false); + assert.strictEqual(isValidResult("deployer", "done"), true); + assert.strictEqual(isValidResult("deployer", "approve"), false); }); }); @@ -213,6 +220,7 @@ describe("session key pattern", () => { assert.ok(pattern.includes("tester")); assert.ok(pattern.includes("architect")); assert.ok(pattern.includes("reviewer")); + assert.ok(pattern.includes("deployer")); }); it("should work as regex", () => { @@ -222,6 +230,7 @@ describe("session key pattern", () => { assert.ok(regex.test("tester")); assert.ok(regex.test("architect")); assert.ok(regex.test("reviewer")); + assert.ok(regex.test("deployer")); assert.ok(!regex.test("nonexistent")); }); }); diff --git a/lib/roles/registry.ts b/lib/roles/registry.ts index f7deb43..bd61e19 100644 --- a/lib/roles/registry.ts +++ b/lib/roles/registry.ts @@ -93,4 +93,23 @@ export const ROLE_REGISTRY: Record = { sessionKeyPattern: "reviewer", notifications: { onStart: true, onComplete: true }, }, + + deployer: { + id: "deployer", + displayName: "DEPLOYER", + levels: ["junior", "senior"], + defaultLevel: "junior", + models: { + junior: "anthropic/claude-haiku-4-5", + senior: "anthropic/claude-sonnet-4-5", + }, + emoji: { + junior: "🚚", + senior: "🚀", + }, + fallbackEmoji: "🚚", + completionResults: ["done", "blocked"], + sessionKeyPattern: "deployer", + notifications: { onStart: true, onComplete: true }, + }, }; diff --git a/lib/services/delivery-phases.test.ts b/lib/services/delivery-phases.test.ts new file mode 100644 index 0000000..b677c45 --- /dev/null +++ b/lib/services/delivery-phases.test.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, it } from "node:test"; +import assert from "node:assert"; +import { createTestHarness, type TestHarness } from "../testing/index.js"; +import { projectTick } from "./tick.js"; +import { deliveryPass } from "./heartbeat/delivery.js"; +import { DEFAULT_WORKFLOW, getCompletionRule, renderCandidateRecord } from "../workflow/index.js"; + +describe("delivery phase routing", () => { + let h: TestHarness; + + afterEach(async () => { + if (h) await h.cleanup(); + }); + + it("derives deployer completion rules from delivery active states", () => { + const promoteRule = getCompletionRule(DEFAULT_WORKFLOW, "deployer", "done", "Promoting"); + const acceptRule = getCompletionRule(DEFAULT_WORKFLOW, "deployer", "done", "Accepting"); + + assert.deepStrictEqual(promoteRule, { + from: "Promoting", + to: "To Accept", + actions: [], + }); + assert.deepStrictEqual(acceptRule, { + from: "Accepting", + to: "Done", + actions: ["closeIssue"], + }); + }); + + it("dispatches delivery queues into their matching active states", async () => { + h = await createTestHarness({ + workers: { + deployer: { active: false, issueId: null, sessionKey: null }, + }, + }); + + h.provider.seedIssue({ iid: 42, title: "Promote candidate", labels: ["To Promote", "promotion:agent"] }); + h.provider.seedIssue({ iid: 43, title: "Accept candidate", labels: ["To Accept", "acceptance:agent"] }); + + const promotionTick = await projectTick({ + workspaceDir: h.workspaceDir, + projectSlug: h.project.slug, + provider: h.provider, + targetRole: "deployer", + runCommand: h.runCommand, + }); + + assert.strictEqual(promotionTick.pickups.length, 1); + + const acceptanceTick = await projectTick({ + workspaceDir: h.workspaceDir, + projectSlug: h.project.slug, + provider: h.provider, + targetRole: "deployer", + runCommand: h.runCommand, + }); + + assert.strictEqual(acceptanceTick.pickups.length, 1); + + const transitions = h.provider.callsTo("transitionLabel"); + assert.deepStrictEqual(transitions.map((call) => call.args), [ + { issueId: 42, from: "To Promote", to: "Promoting" }, + { issueId: 43, from: "To Accept", to: "Accepting" }, + ]); + }); + + it("does not auto-promote human-routed delivery without an explicit candidate record", async () => { + h = await createTestHarness(); + h.provider.seedIssue({ iid: 44, title: "Human promote", labels: ["To Promote", "promotion:human"] }); + + const transitions = await deliveryPass({ + workspaceDir: h.workspaceDir, + projectName: h.project.slug, + workflow: h.workflow, + provider: h.provider, + repoPath: h.project.repo, + runCommand: h.runCommand, + }); + + assert.strictEqual(transitions, 0); + assert.deepStrictEqual(h.provider.callsTo("transitionLabel"), []); + }); + + it("advances human-routed promotion only after an active candidate record exists", async () => { + h = await createTestHarness(); + h.provider.seedIssue({ iid: 45, title: "Human promote", labels: ["To Promote", "promotion:human"] }); + await h.provider.addComment(45, renderCandidateRecord({ + issueId: 45, + candidateId: "cand-45", + commitSha: "abc123", + targetHint: "candidate", + status: "active", + promotedAt: new Date().toISOString(), + })); + + const transitions = await deliveryPass({ + workspaceDir: h.workspaceDir, + projectName: h.project.slug, + workflow: h.workflow, + provider: h.provider, + repoPath: h.project.repo, + runCommand: h.runCommand, + }); + + assert.strictEqual(transitions, 1); + assert.deepStrictEqual(h.provider.callsTo("transitionLabel").at(-1)?.args, { + issueId: 45, + from: "To Promote", + to: "To Accept", + }); + }); + + it("advances human-routed acceptance only after the candidate is explicitly accepted", async () => { + h = await createTestHarness(); + h.provider.seedIssue({ iid: 46, title: "Human accept", labels: ["To Accept", "acceptance:human"] }); + await h.provider.addComment(46, renderCandidateRecord({ + issueId: 46, + candidateId: "cand-46", + commitSha: "def456", + targetHint: "candidate", + status: "active", + promotedAt: new Date().toISOString(), + })); + + const before = await deliveryPass({ + workspaceDir: h.workspaceDir, + projectName: h.project.slug, + workflow: h.workflow, + provider: h.provider, + repoPath: h.project.repo, + runCommand: h.runCommand, + }); + + assert.strictEqual(before, 0); + + await h.provider.addComment(46, renderCandidateRecord({ + issueId: 46, + candidateId: "cand-46", + commitSha: "def456", + targetHint: "candidate", + status: "accepted", + promotedAt: new Date().toISOString(), + acceptedAt: new Date().toISOString(), + })); + + const after = await deliveryPass({ + workspaceDir: h.workspaceDir, + projectName: h.project.slug, + workflow: h.workflow, + provider: h.provider, + repoPath: h.project.repo, + runCommand: h.runCommand, + }); + + assert.strictEqual(after, 1); + assert.deepStrictEqual(h.provider.callsTo("transitionLabel").at(-1)?.args, { + issueId: 46, + from: "To Accept", + to: "Done", + }); + }); +}); diff --git a/lib/services/heartbeat/delivery.ts b/lib/services/heartbeat/delivery.ts new file mode 100644 index 0000000..d91335d --- /dev/null +++ b/lib/services/heartbeat/delivery.ts @@ -0,0 +1,92 @@ +import type { IssueProvider } from "../../providers/provider.js"; +import type { RunCommand } from "../../context.js"; +import { + Action, + StateType, + WorkflowEvent, + getCurrentCandidate, + markCandidateStatus, + type WorkflowConfig, + type StateConfig, +} from "../../workflow/index.js"; +import { detectStepRouting } from "../queue-scan.js"; +import { log as auditLog } from "../../audit.js"; + +export async function deliveryPass(opts: { + workspaceDir: string; + projectName: string; + workflow: WorkflowConfig; + provider: IssueProvider; + repoPath: string; + runCommand: RunCommand; +}): Promise { + const { workspaceDir, projectName, workflow, provider } = opts; + let transitions = 0; + + for (const [phase, step] of ([ + ["promotion", workflow.delivery?.promotion], + ["acceptance", workflow.delivery?.acceptance], + ] as const)) { + const queueStateKey = step?.queueState; + if (!queueStateKey) continue; + const state = workflow.states[queueStateKey] as StateConfig | undefined; + if (!state || state.type !== StateType.QUEUE) continue; + const issues = await provider.listIssuesByLabel(state.label); + + for (const issue of issues) { + const routing = detectStepRouting(issue.labels, phase); + if (!routing) continue; + + const event = routing === "skip" + ? WorkflowEvent.SKIP + : phase === "promotion" + ? WorkflowEvent.PROMOTED + : WorkflowEvent.ACCEPTED; + const transition = state.on?.[event]; + if (!transition) continue; + + if (routing === "human") { + const candidate = await getCurrentCandidate(provider, issue.iid); + const ready = phase === "promotion" + ? candidate?.status === "active" + : candidate?.status === "accepted"; + if (!ready) continue; + } + + const targetKey = typeof transition === "string" ? transition : transition.target; + const actions = typeof transition === "object" ? transition.actions : undefined; + const targetState = workflow.states[targetKey]; + if (!targetState) continue; + + if (actions) { + for (const action of actions) { + switch (action) { + case Action.CLOSE_ISSUE: + await provider.closeIssue(issue.iid).catch(() => {}); + break; + case Action.REOPEN_ISSUE: + await provider.reopenIssue(issue.iid).catch(() => {}); + break; + } + } + } + + if (phase === "acceptance" && routing === "skip") { + await markCandidateStatus({ provider, issueId: issue.iid, status: "accepted", reason: "acceptance:skip" }).catch(() => {}); + } + + await provider.transitionLabel(issue.iid, state.label, targetState.label); + await auditLog(workspaceDir, "delivery_transition", { + project: projectName, + issueId: issue.iid, + phase, + from: state.label, + to: targetState.label, + reason: `${phase}:${routing}`, + }); + transitions++; + } + } + + return transitions; +} diff --git a/lib/services/heartbeat/index.ts b/lib/services/heartbeat/index.ts index 44d010c..8805d42 100644 --- a/lib/services/heartbeat/index.ts +++ b/lib/services/heartbeat/index.ts @@ -130,6 +130,7 @@ async function processAllAgents( totalReviewTransitions: 0, totalReviewSkipTransitions: 0, totalTestSkipTransitions: 0, + totalDeliveryTransitions: 0, }; // Ensure defaults are fresh on every startup (prompts, workflow, etc.) diff --git a/lib/services/heartbeat/passes.ts b/lib/services/heartbeat/passes.ts index fa946a0..c83a75a 100644 --- a/lib/services/heartbeat/passes.ts +++ b/lib/services/heartbeat/passes.ts @@ -1,5 +1,5 @@ /** - * Heartbeat passes — health, review, review-skip, and test-skip passes. + * Heartbeat passes — health, review, review-skip, test-skip, and delivery passes. */ import type { PluginRuntime } from "openclaw/plugin-sdk"; import type { RunCommand } from "../../context.js"; @@ -13,6 +13,7 @@ import { import { reviewPass } from "./review.js"; import { reviewSkipPass } from "./review-skip.js"; import { testSkipPass } from "./test-skip.js"; +import { deliveryPass } from "./delivery.js"; import type { ResolvedConfig } from "../../config/types.js"; import { resolveNotifyChannel } from "../../workflow/index.js"; import { notify, getNotificationConfig } from "../../dispatch/notify.js"; @@ -274,3 +275,21 @@ export async function performTestSkipPass( provider, }); } + +export async function performDeliveryPass( + workspaceDir: string, + projectSlug: string, + repoPath: string, + provider: import("../../providers/provider.js").IssueProvider, + resolvedConfig: ResolvedConfig, + runCommand: import("../../context.js").RunCommand, +): Promise { + return deliveryPass({ + workspaceDir, + projectName: projectSlug, + workflow: resolvedConfig.workflow, + provider, + repoPath, + runCommand, + }); +} diff --git a/lib/services/heartbeat/tick-runner.ts b/lib/services/heartbeat/tick-runner.ts index eafdb1c..4c2066d 100644 --- a/lib/services/heartbeat/tick-runner.ts +++ b/lib/services/heartbeat/tick-runner.ts @@ -22,6 +22,7 @@ import { performReviewPass, performReviewSkipPass, performTestSkipPass, + performDeliveryPass, } from "./passes.js"; // --------------------------------------------------------------------------- @@ -35,6 +36,7 @@ export type TickResult = { totalReviewTransitions: number; totalReviewSkipTransitions: number; totalTestSkipTransitions: number; + totalDeliveryTransitions: number; }; // --------------------------------------------------------------------------- @@ -68,6 +70,7 @@ export async function tick(opts: { totalReviewTransitions: 0, totalReviewSkipTransitions: 0, totalTestSkipTransitions: 0, + totalDeliveryTransitions: 0, }; } @@ -78,6 +81,7 @@ export async function tick(opts: { totalReviewTransitions: 0, totalReviewSkipTransitions: 0, totalTestSkipTransitions: 0, + totalDeliveryTransitions: 0, }; const projectExecution = @@ -126,6 +130,11 @@ export async function tick(opts: { workspaceDir, slug, provider, resolvedConfig, ); + // Delivery pass: auto-transition skipped or human-completed promotion/acceptance queues + result.totalDeliveryTransitions += await performDeliveryPass( + workspaceDir, slug, project.repo, provider, resolvedConfig, runCommand, + ); + // Budget check: stop if we've hit the limit const remaining = config.maxPickupsPerTick - result.totalPickups; if (remaining <= 0) break; @@ -173,6 +182,7 @@ export async function tick(opts: { reviewTransitions: result.totalReviewTransitions, reviewSkipTransitions: result.totalReviewSkipTransitions, testSkipTransitions: result.totalTestSkipTransitions, + deliveryTransitions: result.totalDeliveryTransitions, pickups: result.totalPickups, skipped: result.totalSkipped, }); diff --git a/lib/services/pipeline-delivery.test.ts b/lib/services/pipeline-delivery.test.ts new file mode 100644 index 0000000..192b56e --- /dev/null +++ b/lib/services/pipeline-delivery.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, it } from "node:test"; +import assert from "node:assert"; +import { createTestHarness, type TestHarness } from "../testing/index.js"; +import { executeCompletion } from "./pipeline.js"; +import { DEFAULT_WORKFLOW, getCurrentCandidate } from "../workflow/index.js"; + +describe("executeCompletion delivery provenance", () => { + let h: TestHarness; + + afterEach(async () => { + if (h) await h.cleanup(); + }); + + it("records an active candidate when promotion completes into acceptance", async () => { + h = await createTestHarness({ + workers: { + deployer: { active: true, issueId: "26", level: "junior" }, + }, + }); + h.provider.seedIssue({ iid: 26, title: "Promote PR", labels: ["Promoting"] }); + + const output = await executeCompletion({ + workspaceDir: h.workspaceDir, + projectSlug: h.project.slug, + channels: h.project.channels, + role: "deployer", + result: "done", + issueId: 26, + summary: "Promoted candidate", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + workflow: DEFAULT_WORKFLOW, + runCommand: h.runCommand, + }); + + assert.strictEqual(output.labelTransition, "Promoting → To Accept"); + const candidate = await getCurrentCandidate(h.provider, 26); + assert.ok(candidate, "Expected candidate provenance to be recorded"); + assert.strictEqual(candidate?.status, "active"); + }); +}); diff --git a/lib/services/pipeline.ts b/lib/services/pipeline.ts index efaa7ca..83a5224 100644 --- a/lib/services/pipeline.ts +++ b/lib/services/pipeline.ts @@ -19,7 +19,12 @@ import { getCompletionRule, getNextStateDescription, getCompletionEmoji, + getCurrentStateLabel, resolveNotifyChannel, + findStateKeyByLabel, + getDeliveryPhaseForLabel, + recordPromotedCandidate, + markCandidateStatus, type CompletionRule, type WorkflowConfig, } from "../workflow/index.js"; @@ -47,6 +52,8 @@ function getRefiningCommentPrefix(role: string): string { return "👁️ **REVIEWER**"; case "architect": return "🏗️ **ARCHITECT**"; + case "deployer": + return "🚚 **DEPLOYER**"; default: return "🎛️ **ORCHESTRATOR**"; } @@ -104,8 +111,9 @@ export function getRule( role: string, result: string, workflow: WorkflowConfig = DEFAULT_WORKFLOW, + currentLabel?: string | null, ): CompletionRule | undefined { - return getCompletionRule(workflow, role, result) ?? undefined; + return getCompletionRule(workflow, role, result, currentLabel) ?? undefined; } /** @@ -147,7 +155,9 @@ export async function executeCompletion(opts: { } = opts; const key = `${role}:${result}`; - const rule = getCompletionRule(workflow, role, result); + const issue = await provider.getIssue(issueId); + const currentLabel = getCurrentStateLabel(issue.labels, workflow); + const rule = getCompletionRule(workflow, role, result, currentLabel); if (!rule) throw new Error(`No completion rule for ${key}`); const { timeouts } = await loadConfig(workspaceDir, projectName); @@ -195,12 +205,10 @@ export async function executeCompletion(opts: { } } - // Get issue early (for URL in notification + channel routing) - const issue = await provider.getIssue(issueId); const notifyTarget = resolveNotifyChannel(issue.labels, channels); // Get next state description from workflow - const nextState = getNextStateDescription(workflow, role, result); + const nextState = getNextStateDescription(workflow, role, result, currentLabel); // Retrieve worker name from project state (best-effort) let workerName: string | undefined; @@ -274,6 +282,9 @@ export async function executeCompletion(opts: { // Then execute post-transition actions (close/reopen) // Finally deactivate worker (last — ensures label is set even if deactivation fails) const transitionedTo = rule.to as StateLabel; + const toStateKey = findStateKeyByLabel(workflow, transitionedTo); + const toPhase = getDeliveryPhaseForLabel(workflow, transitionedTo); + const fromPhase = getDeliveryPhaseForLabel(workflow, rule.from); if (transitionedTo === "Refining") { await provider.addComment(issueId, buildRefiningHoldComment({ role, @@ -286,6 +297,25 @@ export async function executeCompletion(opts: { } await provider.transitionLabel(issueId, rule.from as StateLabel, transitionedTo); + if (fromPhase === "promotion" && toPhase === "acceptance") { + await recordPromotedCandidate({ + provider, + issueId, + repoPath, + runCommand: rc, + prUrl, + targetHint: transitionedTo, + }).catch(() => {}); + } + + if (toStateKey === "done" && fromPhase === "acceptance") { + await markCandidateStatus({ provider, issueId, status: "accepted", reason: summary }).catch(() => {}); + } + + if ((toStateKey === "toImprove" || toStateKey === "refining") && (fromPhase === "promotion" || fromPhase === "acceptance")) { + await markCandidateStatus({ provider, issueId, status: "invalidated", reason: summary }).catch(() => {}); + } + await recordLoopDiagnostic(workspaceDir, "work_finish_transition", { project: projectName, issueId, diff --git a/lib/services/tick.ts b/lib/services/tick.ts index a80ab72..adf7656 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -19,6 +19,8 @@ import { ReviewPolicy, TestPolicy, getActiveLabel, + getActiveLabelForQueueLabel, + getDeliveryQueueLabel, type WorkflowConfig, type Role, } from "../workflow/index.js"; @@ -120,46 +122,61 @@ export async function projectTick(opts: { continue; } - // Review policy gate: fallback for issues dispatched before step routing labels existed - if (role === "reviewer") { + const next = await findNextIssueForRole(provider, role, workflow, instanceName); + if (!next) continue; + + const { issue, label: currentLabel } = next; + const targetLabel = getActiveLabelForQueueLabel(workflow, role, currentLabel); + + const promotionQueueLabel = getDeliveryQueueLabel(workflow, "promotion"); + const acceptanceQueueLabel = getDeliveryQueueLabel(workflow, "acceptance"); + const isPromotionQueue = currentLabel === promotionQueueLabel; + const isAcceptanceQueue = currentLabel === acceptanceQueueLabel; + + // Fallback policy gates for legacy issues that predate routing labels. + if (role === "reviewer" && !isPromotionQueue) { + const reviewRouting = detectStepRouting(issue.labels, "review"); const policy = workflow.reviewPolicy ?? ReviewPolicy.HUMAN; - if (policy === ReviewPolicy.HUMAN) { - skipped.push({ role, reason: "Review policy: human (heartbeat handles via PR polling)" }); - continue; - } - if (policy === ReviewPolicy.SKIP) { - skipped.push({ role, reason: "Review policy: skip (heartbeat handles via review-skip pass)" }); + if (!reviewRouting && (policy === ReviewPolicy.HUMAN || policy === ReviewPolicy.SKIP)) { + skipped.push({ role, reason: `Review policy: ${policy}` }); continue; } } - // Test policy gate: fallback for issues dispatched before test routing labels existed - if (role === "tester") { + if (role === "tester" && !isAcceptanceQueue) { + const testRouting = detectStepRouting(issue.labels, "test"); const policy = workflow.testPolicy ?? TestPolicy.SKIP; - if (policy === TestPolicy.SKIP) { - skipped.push({ role, reason: "Test policy: skip (heartbeat handles via test-skip pass)" }); + if (!testRouting && policy === TestPolicy.SKIP) { + skipped.push({ role, reason: "Test policy: skip" }); continue; } } - const next = await findNextIssueForRole(provider, role, workflow, instanceName); - if (!next) continue; - - const { issue, label: currentLabel } = next; - const targetLabel = getActiveLabel(workflow, role); - - // Step routing: check for review:human / review:skip / test:skip labels - if (role === "reviewer") { - const routing = detectStepRouting(issue.labels, "review"); - if (routing === "human" || routing === "skip") { - skipped.push({ role, reason: `review:${routing} label` }); + // Step routing: check for human/skip routing labels on queue phases. + if (isPromotionQueue) { + const promotionRouting = detectStepRouting(issue.labels, "promotion"); + if (promotionRouting === "human" || promotionRouting === "skip") { + skipped.push({ role, reason: `promotion:${promotionRouting} label` }); + continue; + } + } else if (role === "reviewer") { + const reviewRouting = detectStepRouting(issue.labels, "review"); + if (reviewRouting === "human" || reviewRouting === "skip") { + skipped.push({ role, reason: `review:${reviewRouting} label` }); continue; } } - if (role === "tester") { - const routing = detectStepRouting(issue.labels, "test"); - if (routing === "skip") { - skipped.push({ role, reason: "test:skip label" }); + + if (isAcceptanceQueue) { + const acceptanceRouting = detectStepRouting(issue.labels, "acceptance"); + if (acceptanceRouting === "human" || acceptanceRouting === "skip") { + skipped.push({ role, reason: `acceptance:${acceptanceRouting} label` }); + continue; + } + } else if (role === "tester") { + const testRouting = detectStepRouting(issue.labels, "test"); + if (testRouting === "human" || testRouting === "skip") { + skipped.push({ role, reason: `test:${testRouting} label` }); continue; } } diff --git a/lib/setup/templates.ts b/lib/setup/templates.ts index 82e46bd..abd5920 100644 --- a/lib/setup/templates.ts +++ b/lib/setup/templates.ts @@ -49,6 +49,7 @@ const DEFAULT_DEV_INSTRUCTIONS = loadDefault("devclaw/prompts/developer.md"); const DEFAULT_QA_INSTRUCTIONS = loadDefault("devclaw/prompts/tester.md"); const DEFAULT_ARCHITECT_INSTRUCTIONS = loadDefault("devclaw/prompts/architect.md"); const DEFAULT_REVIEWER_INSTRUCTIONS = loadDefault("devclaw/prompts/reviewer.md"); +const DEFAULT_DEPLOYER_INSTRUCTIONS = loadDefault("devclaw/prompts/deployer.md"); export const DEFAULT_ORCHESTRATOR_INSTRUCTIONS = loadDefault("devclaw/prompts/orchestrator.md"); /** Default role instructions indexed by role ID. Used by project scaffolding. */ @@ -57,6 +58,7 @@ export const DEFAULT_ROLE_INSTRUCTIONS: Record = { tester: DEFAULT_QA_INSTRUCTIONS, architect: DEFAULT_ARCHITECT_INSTRUCTIONS, reviewer: DEFAULT_REVIEWER_INSTRUCTIONS, + deployer: DEFAULT_DEPLOYER_INSTRUCTIONS, }; // --------------------------------------------------------------------------- diff --git a/lib/testing/harness.ts b/lib/testing/harness.ts index aa083d0..975ada3 100644 --- a/lib/testing/harness.ts +++ b/lib/testing/harness.ts @@ -158,6 +158,7 @@ export async function createTestHarness(opts?: HarnessOptions): Promise s.role === "tester" && (s.type === "queue" || s.type === "active"), ), - hint: "The user can change the review policy or enable the test phase — call workflow_guide for the full reference.", + delivery: { + promotion: resolvedConfig.workflow.delivery?.promotion?.policy ?? "skip", + acceptance: resolvedConfig.workflow.delivery?.acceptance?.policy ?? "skip", + }, + hint: "The user can change review, testing, promotion, or acceptance policy — call workflow_guide for the full reference.", }; return jsonResult({ diff --git a/lib/tools/admin/project-status.ts b/lib/tools/admin/project-status.ts index 16631e0..5327b01 100644 --- a/lib/tools/admin/project-status.ts +++ b/lib/tools/admin/project-status.ts @@ -84,6 +84,10 @@ export function createProjectStatusTool(ctx: PluginContext) { reviewPolicy: workflow.reviewPolicy ?? "human", roleExecution: workflow.roleExecution ?? ExecutionMode.PARALLEL, testPhase: hasTestPhase, + delivery: { + promotion: workflow.delivery?.promotion?.policy ?? "skip", + acceptance: workflow.delivery?.acceptance?.policy ?? "skip", + }, stateFlow: Object.entries(workflow.states) .map(([, s]) => s.label) .join(" → "), diff --git a/lib/tools/admin/workflow-guide.ts b/lib/tools/admin/workflow-guide.ts index 053cabe..6118ba3 100644 --- a/lib/tools/admin/workflow-guide.ts +++ b/lib/tools/admin/workflow-guide.ts @@ -22,7 +22,7 @@ export function createWorkflowGuideTool(_ctx: PluginContext) { `Reference guide for editing workflow.yaml. ` + `Call this BEFORE making any workflow configuration changes. ` + `Returns the full config structure, all valid values (enums, free-form fields), ` + - `the three-layer override system, and common recipes like enabling the test phase ` + + `the three-layer override system, and common recipes like enabling the test or delivery phases ` + `or changing the review policy.`, parameters: { type: "object", @@ -31,9 +31,9 @@ export function createWorkflowGuideTool(_ctx: PluginContext) { type: "string", description: "Optional: narrow to a specific topic. " + - 'Options: "overview", "states", "roles", "review", "testing", "timeouts", "overrides". ' + + 'Options: "overview", "states", "roles", "review", "testing", "delivery", "timeouts", "overrides". ' + "Omit for the full guide.", - enum: ["overview", "states", "roles", "review", "testing", "timeouts", "overrides"], + enum: ["overview", "states", "roles", "review", "testing", "delivery", "timeouts", "overrides"], }, }, }, @@ -49,6 +49,7 @@ export function createWorkflowGuideTool(_ctx: PluginContext) { roles: buildRolesSection(), review: buildReviewSection(), testing: buildTestingSection(), + delivery: buildDeliverySection(), timeouts: buildTimeoutsSection(), overrides: buildOverridesSection(dataDir), }; @@ -107,6 +108,50 @@ workflow: This changes only the senior developer model and review policy; everything else inherits.`; } +function buildDeliverySection(): string { + return `# Delivery Phases + +Delivery is modeled as two optional workflow phases after testing: +- **promotion**: candidate promotion into a release lane +- **acceptance**: acceptance of the promoted candidate + +## Delivery config shape + +\`\`\`yaml +workflow: + delivery: + promotion: + policy: skip # skip | agent | human + queueState: toPromote + activeState: promoting + acceptance: + policy: skip # skip | agent | human + queueState: toAccept + activeState: accepting +\`\`\` + +## Rules +- If a delivery phase is omitted or set to \`skip\`, existing projects keep working unchanged. +- \`queueState\` must point to a queue state for the correct role. +- \`activeState\` must point to an active state for the correct role. +- Promotion should represent candidate promotion, not generic testing. +- Acceptance should represent acceptance of the promoted candidate. +- Release initiation should be policy-controlled, and may be human- or agent-initiated depending on project policy. +- Environment-specific deploy mechanics stay in project runbooks, not core workflow semantics. + +## Release-agent contract +- Workflow config covers delivery policies and the states they use. +- Release-agent config also defines project lanes or environments, allowed source → target promotion paths, proof-of-release receipts, shared acceptance defaults, and repeat or override behavior. +- See \`dev/design/deployer-contract.md\` in the repo for the operator-facing contract. + +## Routing labels +- Promotion uses \`promotion:human\`, \`promotion:agent\`, \`promotion:skip\` +- Acceptance uses \`acceptance:human\`, \`acceptance:agent\`, \`acceptance:skip\` + +## Default behavior +The built-in workflow defines delivery states, but both phases default to \`skip\`. That means older projects remain backward compatible until they opt in.`; +} + function buildStatesSection(): string { return `# Workflow States @@ -209,7 +254,7 @@ sync_labels channelId=-100123 # sync one project function buildRolesSection(): string { return `# Roles Configuration -## Built-in roles (4 defaults — can override or disable) +## Built-in roles (5 defaults — can override or disable) | Role | Default levels | Default level | Completion results | |-----------|------------------------|---------------|----------------------------| @@ -217,6 +262,7 @@ function buildRolesSection(): string { | \`tester\` | junior, medior, senior | medior | pass, fail, refine, blocked| | \`architect\` | junior, senior | junior | done, blocked | | \`reviewer\` | junior, senior | junior | approve, reject, blocked | +| \`deployer\` | junior, senior | junior | done, blocked | ## Role config fields @@ -239,6 +285,7 @@ function buildRolesSection(): string { Architect junior defaults to \`anthropic/claude-sonnet-4-5\`. Reviewer senior defaults to \`anthropic/claude-sonnet-4-5\`. +Deployer senior defaults to \`anthropic/claude-sonnet-4-5\`. ## Disabling a role @@ -269,7 +316,11 @@ Each role can have a system prompt file: - Workspace default: \`/prompts/.md\` - Project override: \`/projects//prompts/.md\` -If a role has no prompt file, the worker gets a generic system prompt. When enabling a new role (like tester), create its prompt file.`; +If a role has no prompt file, the worker gets a generic system prompt. When enabling a new role (like tester or deployer), create its prompt file. + +The Deployer uses a dedicated \`deployer.md\` prompt surface. + +Keep release lanes, routing policy, and proof requirements in workflow/config and runbooks, not only in prompt text.`; } function buildReviewSection(): string { diff --git a/lib/tools/worker/work-finish.ts b/lib/tools/worker/work-finish.ts index a457c23..d4abc3a 100644 --- a/lib/tools/worker/work-finish.ts +++ b/lib/tools/worker/work-finish.ts @@ -18,7 +18,7 @@ import { log as auditLog } from "../../audit.js"; import { DATA_DIR } from "../../setup/migrate-layout.js"; import { requireWorkspaceDir, resolveChannelId, resolveProject, resolveProvider } from "../helpers.js"; import { getAllRoleIds, isValidResult, getCompletionResults } from "../../roles/index.js"; -import { loadWorkflow } from "../../workflow/index.js"; +import { getCurrentStateLabel, loadWorkflow } from "../../workflow/index.js"; /** * Get the current git branch name. @@ -179,7 +179,7 @@ export function createWorkFinishTool(ctx: PluginContext) { return (toolCtx: ToolContext) => ({ name: "work_finish", label: "Work Finish", - description: `Complete a task: Developer done (PR created, goes to review) or blocked. Tester pass/fail/refine/blocked. Reviewer approve/reject/blocked. Architect done/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`, + description: `Complete a task: Developer done/blocked, Tester pass/fail/refine/blocked, Reviewer approve/reject/blocked, Architect done/blocked, or Deployer done/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`, parameters: { type: "object", required: ["channelId", "role", "result"], @@ -261,8 +261,10 @@ export function createWorkFinishTool(ctx: PluginContext) { const { provider } = await resolveProvider(project, ctx.runCommand); const workflow = await loadWorkflow(workspaceDir, project.name); + const issue = await provider.getIssue(issueId); + const currentLabel = getCurrentStateLabel(issue.labels, workflow); - if (!getRule(role, result, workflow)) + if (!getRule(role, result, workflow, currentLabel)) throw new Error(`Invalid completion: ${role}:${result}`); const repoPath = resolveRepoPath(project.repo); diff --git a/lib/workflow/candidate-provenance.ts b/lib/workflow/candidate-provenance.ts new file mode 100644 index 0000000..07b6eaa --- /dev/null +++ b/lib/workflow/candidate-provenance.ts @@ -0,0 +1,112 @@ +import type { IssueProvider, IssueComment } from "../providers/provider.js"; +import type { RunCommand } from "../context.js"; + +const MARKER = "devclaw:candidate-record"; + +export type CandidateStatus = "active" | "accepted" | "invalidated"; + +export type CandidateRecord = { + issueId: number; + prUrl?: string | null; + commitSha?: string | null; + candidateId?: string | null; + targetHint?: string | null; + status: CandidateStatus; + promotedAt?: string; + acceptedAt?: string; + invalidatedAt?: string; + reason?: string | null; +}; + +export async function getCurrentCandidate(provider: IssueProvider, issueId: number): Promise { + const comments = await provider.listComments(issueId); + return findLatestCandidateRecord(comments); +} + +export async function recordPromotedCandidate(opts: { + provider: IssueProvider; + issueId: number; + repoPath: string; + runCommand: RunCommand; + prUrl?: string | null; + targetHint?: string | null; +}): Promise { + const commitSha = await getHeadSha(opts.repoPath, opts.runCommand); + const promotedAt = new Date().toISOString(); + const candidateId = commitSha ? commitSha.slice(0, 12) : `issue-${opts.issueId}-${Date.now()}`; + const record: CandidateRecord = { + issueId: opts.issueId, + prUrl: opts.prUrl ?? null, + commitSha, + candidateId, + targetHint: opts.targetHint ?? null, + status: "active", + promotedAt, + }; + await opts.provider.addComment(opts.issueId, renderCandidateRecord(record)); + return record; +} + +export async function markCandidateStatus(opts: { + provider: IssueProvider; + issueId: number; + status: Exclude; + reason?: string; +}): Promise { + const current = await getCurrentCandidate(opts.provider, opts.issueId); + if (!current) return null; + const now = new Date().toISOString(); + const next: CandidateRecord = { + ...current, + status: opts.status, + acceptedAt: opts.status === "accepted" ? now : current.acceptedAt, + invalidatedAt: opts.status === "invalidated" ? now : current.invalidatedAt, + reason: opts.reason ?? current.reason ?? null, + }; + await opts.provider.addComment(opts.issueId, renderCandidateRecord(next)); + return next; +} + +export function renderCandidateRecord(record: CandidateRecord): string { + const payload = JSON.stringify(record); + const lines = [ + ``, + "## DevClaw Candidate Record", + "", + `- status: ${record.status}`, + `- candidate: ${record.candidateId ?? "unknown"}`, + `- commit: ${record.commitSha ?? "unknown"}`, + `- target: ${record.targetHint ?? "unspecified"}`, + ]; + if (record.prUrl) lines.push(`- PR: ${record.prUrl}`); + if (record.reason) lines.push(`- reason: ${record.reason}`); + return lines.join("\n"); +} + +function findLatestCandidateRecord(comments: IssueComment[]): CandidateRecord | null { + for (let i = comments.length - 1; i >= 0; i--) { + const comment = comments[i]; + const record = parseCandidateRecord(comment?.body ?? ""); + if (record) return record; + } + return null; +} + +function parseCandidateRecord(body: string): CandidateRecord | null { + const match = body.match(new RegExp(``)); + if (!match?.[1]) return null; + try { + return JSON.parse(match[1]) as CandidateRecord; + } catch { + return null; + } +} + +async function getHeadSha(repoPath: string, runCommand: RunCommand): Promise { + try { + const result = await runCommand(["git", "rev-parse", "HEAD"], { cwd: repoPath, timeoutMs: 10_000 }); + return result.stdout.trim() || null; + } catch { + return null; + } +} diff --git a/lib/workflow/completion.ts b/lib/workflow/completion.ts index 1ec870c..d84c1c3 100644 --- a/lib/workflow/completion.ts +++ b/lib/workflow/completion.ts @@ -8,7 +8,7 @@ import { StateType, WorkflowEvent, } from "./types.js"; -import { getActiveLabel, findStateKeyByLabel, findStateByLabel } from "./queries.js"; +import { getActiveLabel, findStateKeyByLabel, findStateByLabel, getActiveLabelForQueueLabel } from "./queries.js"; /** * Map completion result to workflow transition event name. @@ -27,13 +27,29 @@ export function getCompletionRule( workflow: WorkflowConfig, role: Role, result: string, + currentLabel?: string | null, ): CompletionRule | null { const event = resultToEvent(result); let activeLabel: string; try { - activeLabel = getActiveLabel(workflow, role); - } catch { return null; } + if (currentLabel) { + const currentKey = findStateKeyByLabel(workflow, currentLabel); + const currentState = currentKey ? workflow.states[currentKey] : null; + if (currentState?.type === StateType.ACTIVE && currentState.role === role) { + activeLabel = currentLabel; + } else { + activeLabel = getActiveLabelForQueueLabel(workflow, role, currentLabel); + } + } else { + activeLabel = getActiveLabel(workflow, role); + } + } catch { + if (!currentLabel) return null; + try { + activeLabel = getActiveLabel(workflow, role); + } catch { return null; } + } const activeKey = findStateKeyByLabel(workflow, activeLabel); if (!activeKey) return null; @@ -63,8 +79,9 @@ export function getNextStateDescription( workflow: WorkflowConfig, role: Role, result: string, + currentLabel?: string | null, ): string { - const rule = getCompletionRule(workflow, role, result); + const rule = getCompletionRule(workflow, role, result, currentLabel); if (!rule) return ""; const targetState = findStateByLabel(workflow, rule.to); diff --git a/lib/workflow/defaults.ts b/lib/workflow/defaults.ts index 5e7d1cf..90e937a 100644 --- a/lib/workflow/defaults.ts +++ b/lib/workflow/defaults.ts @@ -16,6 +16,10 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { initial: "planning", reviewPolicy: ReviewPolicy.HUMAN, testPolicy: TestPolicy.SKIP, + delivery: { + promotion: { policy: "skip", queueState: "toPromote", activeState: "promoting" }, + acceptance: { policy: "skip", queueState: "toAccept", activeState: "accepting" }, + }, roleExecution: ExecutionMode.PARALLEL, states: { // ── Main pipeline (happy path) ────────────────────────────── @@ -89,12 +93,63 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { label: "Testing", color: "#9b59b6", on: { - [WorkflowEvent.PASS]: { target: "done", actions: [Action.CLOSE_ISSUE] }, + [WorkflowEvent.PASS]: "toPromote", [WorkflowEvent.FAIL]: { target: "toImprove", actions: [Action.REOPEN_ISSUE] }, [WorkflowEvent.REFINE]: "refining", [WorkflowEvent.BLOCKED]: "refining", }, }, + toPromote: { + type: StateType.QUEUE, + role: "deployer", + label: "To Promote", + color: "#1d76db", + priority: 2, + on: { + [WorkflowEvent.PICKUP]: "promoting", + [WorkflowEvent.SKIP]: "toAccept", + [WorkflowEvent.PROMOTED]: "toAccept", + [WorkflowEvent.FAIL]: "toImprove", + [WorkflowEvent.DEMOTED]: "toImprove", + [WorkflowEvent.BLOCKED]: "refining", + }, + }, + promoting: { + type: StateType.ACTIVE, + role: "deployer", + label: "Promoting", + color: "#6ea8fe", + on: { + [WorkflowEvent.COMPLETE]: "toAccept", + [WorkflowEvent.BLOCKED]: "refining", + }, + }, + toAccept: { + type: StateType.QUEUE, + role: "deployer", + label: "To Accept", + color: "#20c997", + priority: 2, + on: { + [WorkflowEvent.PICKUP]: "accepting", + [WorkflowEvent.SKIP]: { target: "done", actions: [Action.CLOSE_ISSUE] }, + [WorkflowEvent.ACCEPTED]: { target: "done", actions: [Action.CLOSE_ISSUE] }, + [WorkflowEvent.FAIL]: { target: "toImprove", actions: [Action.REOPEN_ISSUE] }, + [WorkflowEvent.DEMOTED]: { target: "toImprove", actions: [Action.REOPEN_ISSUE] }, + [WorkflowEvent.REFINE]: "refining", + [WorkflowEvent.BLOCKED]: "refining", + }, + }, + accepting: { + type: StateType.ACTIVE, + role: "deployer", + label: "Accepting", + color: "#8ce0c4", + on: { + [WorkflowEvent.COMPLETE]: { target: "done", actions: [Action.CLOSE_ISSUE] }, + [WorkflowEvent.BLOCKED]: "refining", + }, + }, done: { type: StateType.TERMINAL, label: "Done", diff --git a/lib/workflow/index.ts b/lib/workflow/index.ts index edb501b..c364324 100644 --- a/lib/workflow/index.ts +++ b/lib/workflow/index.ts @@ -9,3 +9,4 @@ export * from "./defaults.js"; export * from "./queries.js"; export * from "./labels.js"; export * from "./completion.js"; +export * from "./candidate-provenance.js"; diff --git a/lib/workflow/labels.ts b/lib/workflow/labels.ts index 773ed0b..19d0fa9 100644 --- a/lib/workflow/labels.ts +++ b/lib/workflow/labels.ts @@ -1,9 +1,8 @@ /** * workflow/labels.ts — Label formatting, detection, and routing helpers. */ -import type { WorkflowConfig, ReviewPolicy, TestPolicy } from "./types.js"; -import { ReviewPolicy as RP, TestPolicy as TP } from "./types.js"; -import { getLabelColors } from "./queries.js"; +import type { WorkflowConfig, ReviewPolicy, TestPolicy, DeliveryPolicy } from "./types.js"; +import { ReviewPolicy as RP, TestPolicy as TP, DeliveryPolicy as DP } from "./types.js"; // --------------------------------------------------------------------------- // Step routing labels @@ -20,7 +19,9 @@ export type StepRoutingValue = (typeof StepRouting)[keyof typeof StepRouting]; /** Known step routing labels (created on the provider during project registration). */ export const STEP_ROUTING_LABELS: readonly string[] = [ "review:human", "review:agent", "review:skip", - "test:skip", + "test:skip", "test:agent", + "promotion:human", "promotion:agent", "promotion:skip", + "acceptance:human", "acceptance:agent", "acceptance:skip", ]; /** Step routing label color. */ @@ -115,6 +116,15 @@ export function resolveTestRouting( return "test:skip"; } +export function resolveDeliveryRouting( + policy: DeliveryPolicy, + phase: "promotion" | "acceptance", +): "promotion:human" | "promotion:agent" | "promotion:skip" | "acceptance:human" | "acceptance:agent" | "acceptance:skip" { + if (policy === DP.HUMAN) return `${phase}:human`; + if (policy === DP.AGENT) return `${phase}:agent`; + return `${phase}:skip`; +} + // --------------------------------------------------------------------------- // Role labels // --------------------------------------------------------------------------- @@ -125,6 +135,7 @@ const ROLE_LABEL_COLORS: Record = { tester: "#5319e7", architect: "#0075ca", reviewer: "#d93f0b", + deployer: "#1d76db", }; /** diff --git a/lib/workflow/queries.ts b/lib/workflow/queries.ts index 386bd29..8e672db 100644 --- a/lib/workflow/queries.ts +++ b/lib/workflow/queries.ts @@ -5,6 +5,7 @@ import { type WorkflowConfig, type StateConfig, type Role, + type DeliveryPhase, StateType, WorkflowEvent, } from "./types.js"; @@ -74,6 +75,32 @@ export function getActiveLabel(workflow: WorkflowConfig, role: Role): string { return state.label; } +/** + * Get the active label that a queue label picks up into. + */ +export function getActiveLabelForQueueLabel( + workflow: WorkflowConfig, + role: Role, + queueLabel: string, +): string { + const queueStateKey = findStateKeyByLabel(workflow, queueLabel); + if (!queueStateKey) throw new Error(`No workflow state for queue label "${queueLabel}"`); + + const queueState = workflow.states[queueStateKey]; + if (queueState.type !== StateType.QUEUE || queueState.role !== role) { + throw new Error(`Label "${queueLabel}" is not a ${role} queue state`); + } + + const pickup = queueState.on?.[WorkflowEvent.PICKUP]; + const targetKey = typeof pickup === "string" ? pickup : pickup?.target; + const targetState = targetKey ? workflow.states[targetKey] : null; + if (!targetState || targetState.type !== StateType.ACTIVE || targetState.role !== role) { + throw new Error(`Queue label "${queueLabel}" does not pick up into an active ${role} state`); + } + + return targetState.label; +} + /** * Get the revert label for a role (first queue state for that role). */ @@ -86,7 +113,8 @@ export function getRevertLabel(workflow: WorkflowConfig, role: Role): string { for (const [, state] of Object.entries(workflow.states)) { if (state.type !== StateType.QUEUE || state.role !== role) continue; const pickup = state.on?.[WorkflowEvent.PICKUP]; - if (pickup === activeStateKey) { + const targetKey = typeof pickup === "string" ? pickup : pickup?.target; + if (targetKey === activeStateKey) { return state.label; } } @@ -94,6 +122,27 @@ export function getRevertLabel(workflow: WorkflowConfig, role: Role): string { return getQueueLabels(workflow, role)[0] ?? ""; } +/** + * Get the queue label that leads into a specific active label. + */ +export function getQueueLabelForActiveLabel( + workflow: WorkflowConfig, + role: Role, + activeLabel: string, +): string { + const activeStateKey = findStateKeyByLabel(workflow, activeLabel); + if (!activeStateKey) throw new Error(`No workflow state for active label "${activeLabel}"`); + + for (const state of Object.values(workflow.states)) { + if (state.type !== StateType.QUEUE || state.role !== role) continue; + const pickup = state.on?.[WorkflowEvent.PICKUP]; + const targetKey = typeof pickup === "string" ? pickup : pickup?.target; + if (targetKey === activeStateKey) return state.label; + } + + throw new Error(`No ${role} queue state picks up into "${activeLabel}"`); +} + /** * Detect role from a label. */ @@ -195,6 +244,33 @@ export function hasTestPhase(workflow: WorkflowConfig): boolean { ); } +export function getDeliveryPhaseConfig(workflow: WorkflowConfig, phase: DeliveryPhase) { + return workflow.delivery?.[phase]; +} + +export function getDeliveryQueueLabel(workflow: WorkflowConfig, phase: DeliveryPhase): string | null { + const key = getDeliveryPhaseConfig(workflow, phase)?.queueState; + return key ? workflow.states[key]?.label ?? null : null; +} + +export function getDeliveryActiveLabel(workflow: WorkflowConfig, phase: DeliveryPhase): string | null { + const key = getDeliveryPhaseConfig(workflow, phase)?.activeState; + return key ? workflow.states[key]?.label ?? null : null; +} + +export function hasDeliveryPhase(workflow: WorkflowConfig, phase: DeliveryPhase): boolean { + return getDeliveryQueueLabel(workflow, phase) != null; +} + +export function getDeliveryPhaseForLabel(workflow: WorkflowConfig, label: string): DeliveryPhase | null { + for (const phase of ["promotion", "acceptance"] as DeliveryPhase[]) { + if (getDeliveryQueueLabel(workflow, phase) === label || getDeliveryActiveLabel(workflow, phase) === label) { + return phase; + } + } + return null; +} + /** * Load workflow config for a project. * Delegates to loadConfig() which handles the three-layer merge. diff --git a/lib/workflow/types.ts b/lib/workflow/types.ts index 5999225..39f2e4a 100644 --- a/lib/workflow/types.ts +++ b/lib/workflow/types.ts @@ -33,6 +33,20 @@ export const TestPolicy = { } as const; export type TestPolicy = (typeof TestPolicy)[keyof typeof TestPolicy]; +/** Delivery-phase policy for promotion/acceptance routing. */ +export const DeliveryPolicy = { + HUMAN: "human", + AGENT: "agent", + SKIP: "skip", +} as const; +export type DeliveryPolicy = (typeof DeliveryPolicy)[keyof typeof DeliveryPolicy]; + +export const DeliveryPhase = { + PROMOTION: "promotion", + ACCEPTANCE: "acceptance", +} as const; +export type DeliveryPhase = (typeof DeliveryPhase)[keyof typeof DeliveryPhase]; + /** Role identifier. Built-in: "developer", "tester", "architect". Extensible via config. */ export type Role = string; /** Action identifier. Built-in actions listed in `Action`; custom actions are also valid strings. */ @@ -60,6 +74,9 @@ export const WorkflowEvent = { COMPLETE: "COMPLETE", REVIEW: "REVIEW", APPROVED: "APPROVED", + PROMOTED: "PROMOTED", + ACCEPTED: "ACCEPTED", + DEMOTED: "DEMOTED", MERGE_FAILED: "MERGE_FAILED", CHANGES_REQUESTED: "CHANGES_REQUESTED", MERGE_CONFLICT: "MERGE_CONFLICT", @@ -94,6 +111,18 @@ export type WorkflowConfig = { initial: string; reviewPolicy?: ReviewPolicy; testPolicy?: TestPolicy; + delivery?: { + promotion?: { + policy?: DeliveryPolicy; + queueState?: string; + activeState?: string; + }; + acceptance?: { + policy?: DeliveryPolicy; + queueState?: string; + activeState?: string; + }; + }; roleExecution?: ExecutionMode; /** Default max workers per level across all roles. Default: 2. */ maxWorkersPerLevel?: number; diff --git a/package-lock.json b/package-lock.json index 92ec5aa..d7daeab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1851,6 +1851,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1868,6 +1871,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1885,6 +1891,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1902,6 +1911,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1919,6 +1931,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1936,6 +1951,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1953,6 +1971,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1970,6 +1991,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1987,6 +2011,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2010,6 +2037,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2033,6 +2063,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2056,6 +2089,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2079,6 +2115,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2102,6 +2141,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2125,6 +2167,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2148,6 +2193,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2585,6 +2633,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2602,6 +2653,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2619,6 +2673,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2636,6 +2693,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2653,6 +2713,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2956,6 +3019,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2977,6 +3043,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2998,6 +3067,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3019,6 +3091,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3040,6 +3115,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3121,6 +3199,9 @@ "arm64", "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3139,6 +3220,9 @@ "arm", "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3156,6 +3240,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3173,6 +3260,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3190,6 +3280,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3207,6 +3300,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3859,6 +3955,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3876,6 +3975,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3893,6 +3995,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3910,6 +4015,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4914,6 +5022,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4931,6 +5042,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4948,6 +5062,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4965,6 +5082,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [