From 43b2bdc92dfa0cc2f8c15a25807454e33be03689 Mon Sep 17 00:00:00 2001 From: Anthony Franco Date: Wed, 27 May 2026 09:54:49 -0600 Subject: [PATCH 1/2] Import CoBuilder orchestration-services pattern (ADR-0009) Adds non-persona orchestration services so Oscar delegates repeatable administrative work (wrap, handoff/priority compaction, teardown, audits) to faster/cheaper models via bounded, write-audited service packets, instead of spending lead-model context. Realizes the sub-agents/services clause already in ADR-0008, so Oz is unchanged. - lib/services.mjs engine: build/validate/execute packet + deterministic git write-audit (blocks out-of-scope writes even on a claimed PASS) - 2 hand-written contracts (declaration + packet) via existing contracts.mjs (no AJV); 11 service declarations, paths scrubbed to CoCoder layout - 5 CLI commands; separate cursor-agent-service headless adapter (interactive cursor-agent untouched); debugger guidance + session-wrap fragment bullet - ADR-0009, v0.5-orchestration-services priority, PORT-NOTES scrub log Tests: core 346/346, oz-daemon 8/8, oz-dashboard 10/10. Co-Authored-By: Claude Opus 4.7 (1M context) --- cocoder/PRIORITIES.md | 7 + cocoder/SESSION_LOG.md | 14 + .../decisions/0009-orchestration-services.md | 45 ++ cocoder/decisions/README.md | 3 +- cocoder/personas/PORT-NOTES.md | 19 + .../personas/prompts/shared/session-wrap.md | 1 + .../v0.5-orchestration-services/README.md | 27 + .../core/adapters/cursor-agent-service.json | 31 + packages/core/cli/help.mjs | 5 + packages/core/cli/registry.mjs | 77 +++ packages/core/cli/shared.mjs | 3 +- ...hestration-service-declaration.schema.json | 33 + .../orchestration-service.schema.json | 51 ++ packages/core/lib/debugger.mjs | 9 + packages/core/lib/services.mjs | 572 ++++++++++++++++++ .../core/services/commit-boundary-audit.json | 10 + packages/core/services/doc-hygiene.json | 10 + .../core/services/evidence-collation.json | 10 + .../core/services/handoff-compaction.json | 16 + packages/core/services/next-run-packet.json | 10 + packages/core/services/regression-triage.json | 10 + .../core/services/result-contract-repair.json | 13 + packages/core/services/run-summary.json | 10 + .../core/services/startup-context-audit.json | 10 + .../core/services/teardown-readiness.json | 10 + packages/core/services/wrap-execution.json | 23 + packages/core/tests/adapters.test.mjs | 2 +- packages/core/tests/debugger.test.mjs | 4 + .../core/tests/fixtures/cli-help-baseline.txt | 5 + packages/core/tests/services.test.mjs | 400 ++++++++++++ 30 files changed, 1437 insertions(+), 3 deletions(-) create mode 100644 cocoder/decisions/0009-orchestration-services.md create mode 100644 cocoder/priorities/v0.5-orchestration-services/README.md create mode 100644 packages/core/adapters/cursor-agent-service.json create mode 100644 packages/core/contracts/orchestration-service-declaration.schema.json create mode 100644 packages/core/contracts/orchestration-service.schema.json create mode 100644 packages/core/lib/services.mjs create mode 100644 packages/core/services/commit-boundary-audit.json create mode 100644 packages/core/services/doc-hygiene.json create mode 100644 packages/core/services/evidence-collation.json create mode 100644 packages/core/services/handoff-compaction.json create mode 100644 packages/core/services/next-run-packet.json create mode 100644 packages/core/services/regression-triage.json create mode 100644 packages/core/services/result-contract-repair.json create mode 100644 packages/core/services/run-summary.json create mode 100644 packages/core/services/startup-context-audit.json create mode 100644 packages/core/services/teardown-readiness.json create mode 100644 packages/core/services/wrap-execution.json create mode 100644 packages/core/tests/services.test.mjs diff --git a/cocoder/PRIORITIES.md b/cocoder/PRIORITIES.md index 2c7622a..4598dbf 100644 --- a/cocoder/PRIORITIES.md +++ b/cocoder/PRIORITIES.md @@ -24,6 +24,7 @@ Slim index of active and archived priorities. Open a priority's folder for detai | [`v0.2-adapter-extensibility`](./priorities/v0.2-adapter-extensibility/README.md) | Beyond local CLI models — cloud APIs (Anthropic Messages, Kimi K2.6), managed sessions (Cursor SDK), etc. | Draft | — | Bob + founder | After v0.1-foundation Complete **and now after v0.3-workspace-lifecycle** (2026-05-26 resequence). Depends on Sub-Playbook C Oz dashboard. Authored 2026-05-22 per founder ask. | | [`v0.3-workspace-lifecycle`](./priorities/v0.3-workspace-lifecycle/README.md) | Onboard into new/existing projects, manage multi-root workspaces, secure project secrets — via Oz | Draft | — | Bob + founder | **Sequenced before v0.2** (2026-05-26). Near-term "Dogfood Loop Enablement" slice first. Depends on Oz dashboard (Sub-Playbook C). ADR-0007 accepted. | | [`v0.4-oz-control-plane`](./priorities/v0.4-oz-control-plane/README.md) | Oz as a real control plane — in-app chat command interface + run oversight/debugger; UI per ADR-0008 | Draft | — | Bob + founder | Founder decision. Depends on the claude.ai/design output + ADR-0008. Stub authored 2026-05-27. | +| [`v0.5-orchestration-services`](./priorities/v0.5-orchestration-services/README.md) | Cheap/fast-model admin delegation — Oscar offloads wrap/compaction/teardown to bounded services | Draft | — | Bob + founder | **Engine landed 2026-05-27** (ADR-0009); adoption (wire into Oscar wrap, live cursor-agent proof, Oz run-detail surfacing) + sequencing pending. | ## Recently Archived @@ -65,3 +66,9 @@ Slim index of active and archived priorities. Open a priority's folder for detai **Summary:** Turn Oz into a real operator control plane — a per-workspace, in-dashboard headless chatbot that is the primary command interface and the primary watcher/debugger for every run. **What:** Build the Oz UI per [ADR-0008](./decisions/0008-oz-control-plane-architecture.md) (Dashboard with Oz chat + drag-reorder priorities + ad-hoc run launcher; Workspaces with primary/writable/read-only roots; CLIs with Test; Personas with CLI/model + sub-agent hierarchy + visible/headless; Runs list+detail; Settings) plus the Oz oversight/debugger mechanism. Screen/flow brief + design prompt in `docs/oz-design-brief.md`. Root roles per ADR-0007 (revised 2026-05-27). **Status:** Draft (stub). Founder decision on sequencing. Depends on the claude.ai/design output + ADR-0008. Authored 2026-05-27 per founder ask. See [`priorities/v0.4-oz-control-plane/README.md`](./priorities/v0.4-oz-control-plane/README.md). + +### [v0.5-orchestration-services](./priorities/v0.5-orchestration-services/README.md) +**Owner:** Bob + founder +**Summary:** Let Oscar run faster/cheaper models for repeatable admin work (priority/handoff editing, run wrap-up, teardown) via bounded non-persona orchestration services, instead of spending lead-model context. +**What:** Declarative services (`packages/core/services/*.json`) + two contracts + `lib/services.mjs` (build/validate/execute packet with deterministic git write-audit) + 5 CLI commands + a headless `cursor-agent-service` adapter. 11 services shipped. Oz unchanged (services run externally, surface as ordinary run artifacts — ADR-0008 preserved). Complements `model-roles.mjs` (build-side cheap models) with lead/admin-side delegation. +**Status:** Draft — **engine landed 2026-05-27** (ADR-0009; core 346/346). Remaining: wire services into Oscar's live wrap/teardown flow, prove headless `cursor-agent` execution end-to-end, confirm Oz run-detail surfacing, and founder sequencing of the `v0.5` slug. See [`priorities/v0.5-orchestration-services/README.md`](./priorities/v0.5-orchestration-services/README.md). diff --git a/cocoder/SESSION_LOG.md b/cocoder/SESSION_LOG.md index d51ccb0..27ed874 100644 --- a/cocoder/SESSION_LOG.md +++ b/cocoder/SESSION_LOG.md @@ -14,6 +14,20 @@ Append-only log of work sessions. New entries at the **top**. One entry per mean --- +## 2026-05-27 — **Imported CoBuilder orchestration-services pattern (ADR-0009)** + +**Persona:** AI (Bob) | **Priority:** v0.5-orchestration-services | **Plan:** [`decisions/0009-orchestration-services.md`](./decisions/0009-orchestration-services.md) + +**Outcomes:** +- Confirmed CoCoder had no cheap-model admin-delegation path (model-roles covers only build side); imported the pattern Oscar uses to offload wrap/compaction/teardown to faster/cheaper models. +- Landed engine: 2 contracts + `lib/services.mjs` (build/validate/execute packet + deterministic git write-audit), 11 path-scrubbed service declarations, 5 CLI commands, new headless `cursor-agent-service` adapter, debugger guidance + session-wrap bullet. +- Oz untouched (ADR-0008 preserved): services run externally, land under `/services//` as ordinary run artifacts. Scrubs logged in `personas/PORT-NOTES.md`. +- Tests green: core **346/346**, oz-daemon 8/8, oz-dashboard 10/10. Branch `orchestration-services-import` (not yet committed/pushed). + +**Next:** Founder confirms commit/push (3 unrelated pre-existing Oz-design edits in the working tree must be excluded). Then adoption: wire services into Oscar's live wrap flow + prove real `cursor-agent` execution. + +--- + ## 2026-05-24 — **Sub-Playbook D activated (Witness/Interrogate/Solve-target); PD-Q1..PD-Q7 answered** **Persona:** AI (Bob) | **Priority:** v0.1-foundation | **Plan:** [`priorities/v0.1-foundation/plans/2026-05-21-docs-publish.plan.md`](./priorities/v0.1-foundation/plans/2026-05-21-docs-publish.plan.md) diff --git a/cocoder/decisions/0009-orchestration-services.md b/cocoder/decisions/0009-orchestration-services.md new file mode 100644 index 0000000..f424748 --- /dev/null +++ b/cocoder/decisions/0009-orchestration-services.md @@ -0,0 +1,45 @@ +--- +id: ADR-0009 +title: "Non-persona orchestration services (cheap-model admin delegation)" +status: accepted +date: 2026-05-27 +relates-to: ADR-0002, ADR-0004, ADR-0008 +--- + +# ADR-0009: Non-persona orchestration services + +## Context + +The lead orchestrator (Oscar) spends expensive lead-model context on repeatable, mechanical administrative work: wrap cleanup, handoff/priority compaction, run summaries, evidence collation, result-contract repair, commit-boundary and startup-context audits, and teardown-readiness checks. None of this needs lead-model judgment, yet today it either runs as deterministic CLI checks (a subset) or falls to Oscar directly. + +CoCoder already has model-role assignment (`lib/model-roles.mjs`) for the *build* side — `orchestrator`, `builder`, `builderSubagents`, `planning`, `research` — but those are static, route-scoped lane assignments resolved at launch. There was **no** mechanism for the lead to offload its *own* admin work to a cheaper/faster model on demand, with bounded writes and verification. [ADR-0008](./0008-oz-control-plane-architecture.md) point 6 already reserved the slot ("a persona may delegate to **sub-agents/services that each independently select CLI + model**"); this ADR implements it. The pattern is ported from CoBuilder (upstream extraction reference per [ADR-0001](./0001-storage-and-license.md)). + +## Decision + +1. **Orchestration services are non-persona, bounded administrative execution units.** They are not agents and create no durable personas. Oscar may invoke a service when work is mechanical and scoped; Oscar retains all judgment (priority, architecture, scope, founder decisions, completion). + +2. **Services are declarations, not runtime code.** Each service is a JSON file at `packages/core/services/.json` declaring `id`, `label`, `mode` (`read-only` | `bounded-write` | `control-plane`), `purpose`, `execution` (model/executor guidance), `allowedWriteScopes`, and `requiredChecks`. Adding a service is a new declaration, **not** a runtime-code edit — `lib/services.mjs` must not become a catalog god file. Two hand-written JSON contracts validate the surface: `orchestration-service-declaration.schema.json` (registry entry) and `orchestration-service.schema.json` (the **packet**). These follow the same custom `{ contract, required, fields, rules }` shape and `lib/contracts.mjs` validator as the existing 12 core contracts; per [ADR-0004](./0004-typescript-validation-toolchain.md), the Zod→JSON-Schema rule governs `packages/schemas` config/Oz schemas, not the copy-verbatim orchestration contracts. + +3. **A service packet binds one execution.** `build-service-packet` instantiates a declaration against exact run context + Oscar's decision input, narrowing the declaration's broad `allowedWriteScopes` to the exact `allowedWrites` for that run, and freezing `decisionAuthority: oscar-only`, `executionAuthority: orchestration-service`, and a fixed `forbiddenDecisions` list. The packet delegates execution only. + +4. **Headless execution + deterministic write-audit.** `execute-service-packet` runs the packet headlessly (default executor `cursor-agent`, argv array — no shell interpolation), capturing a service-result JSON and transcript under `/services//`. A before/after `git status --porcelain` audit blocks acceptance — forcing `BLOCK` even on a claimed `PASS` — if any write lands outside `allowedWrites` (or any write at all for a read-only service). Failures return diagnosis + proposedFix + nextAction to Oscar, who fixes in scope or recommends the Orchestrator Debugger. + +5. **Eleven services ship at adoption.** `startup-context-audit`, `handoff-compaction`, `wrap-execution`, `evidence-collation`, `commit-boundary-audit`, `result-contract-repair`, `run-summary`, `next-run-packet`, `doc-hygiene`, `teardown-readiness`, `regression-triage`. Write scopes were path-scrubbed to CoCoder's layout (`cocoder/PRIORITIES.md`, `cocoder/SESSION_LOG.md`, `cocoder/plans/*.md`, `cocoder/priorities/*/plans/*.md`, `cocoder/priorities/zArchive/INDEX.md`, run results under `local/workspaces/*/runs/*/jobs/*`). + +6. **The debugger recognizes the pattern.** Future debugger sessions suggest a service when an issue is recurring, mechanical, bounded, and administrative — and explicitly **not** for founder judgment, priority ordering, architecture direction, persona-dispatch judgment, or Bob/Talia/Phil/Quinn/Ian domain work ([ADR-0002](./0002-talia-quinn-boundary.md) boundaries). + +7. **A dedicated `cursor-agent-service` adapter** declares the headless, write-capable executor profile (`interactive: false`, `sandboxModes: [danger-full-access]`, `resultContract: orchestration-service-packet`) as registry metadata, leaving the existing interactive `cursor-agent` adapter (resultContract `job-result`) untouched. + +## Consequences + +- **Oz requires no change ([ADR-0008](./0008-oz-control-plane-architecture.md) preserved).** Services run externally (Oscar / `cursor-agent`) and write artifacts under the run directory; Oz observes them as ordinary run artifacts in run detail. The capability lives entirely in the orchestration-services / `packages/core` layer. +- Complements `model-roles.mjs`: cheap models on the *build* side (subagents/planning/research) and now on the *lead/admin* side (services). +- New surface: `packages/core/services/`, two contracts, `lib/services.mjs`, 5 CLI commands (`list-orchestration-services`, `validate-orchestration-services`, `build-service-packet`, `validate-service-packet`, `execute-service-packet`), a session-wrap fragment bullet, and debugger guidance. Suite: 346/346 core, 8/8 oz-daemon, 10/10 oz-dashboard. +- **Adoption is pending** (tracked under `v0.5-orchestration-services`): wiring services into Oscar's live wrap/teardown flow, proving headless `cursor-agent` execution end-to-end against a real run, and surfacing service results in Oz run detail. + +## Alternatives considered + +- **Extend `model-roles.mjs` to cover admin work** — rejected; model-roles are static launch-time lane assignments, not on-demand bounded packets with write-audit. Different shape, different lifecycle. +- **Make each service a persona/sub-agent** — rejected; services must carry no judgment authority. Persona framing would invite scope creep and a durable-identity surface the pattern explicitly avoids. +- **Overwrite the interactive `cursor-agent` adapter with the headless profile** (upstream's exact shape) — rejected; it would flip `interactive`→false and `resultContract`→`orchestration-service-packet`, breaking the prior-session interactive adapter. A separate adapter id keeps both intents. +- **Write service results under `jobs//`** — rejected; services are not personas/lanes. `/services//` keeps the persona job space clean and matches upstream. diff --git a/cocoder/decisions/README.md b/cocoder/decisions/README.md index 872b979..aa57961 100644 --- a/cocoder/decisions/README.md +++ b/cocoder/decisions/README.md @@ -21,10 +21,11 @@ Numbered, dated, single-purpose decisions. Each ADR captures **context → decis | [ADR-0006](./0006-no-nested-workspaces-inside-install.md) | No workspaces nested inside the CoCoder install repository | accepted | 2026-05-22 | | [ADR-0007](./0007-workspace-files-and-multiroot-description.md) | Workspace files — storage location and the multi-root description convention | accepted (revised 2026-05-27) | 2026-05-26 | | [ADR-0008](./0008-oz-control-plane-architecture.md) | Oz control-plane architecture | accepted | 2026-05-27 | +| [ADR-0009](./0009-orchestration-services.md) | Non-persona orchestration services (cheap-model admin delegation) | accepted | 2026-05-27 | ## Pending / proposed -(None — next ADR number is 0009.) +(None — next ADR number is 0010.) ## Authoring guide diff --git a/cocoder/personas/PORT-NOTES.md b/cocoder/personas/PORT-NOTES.md index 847b406..57b5106 100644 --- a/cocoder/personas/PORT-NOTES.md +++ b/cocoder/personas/PORT-NOTES.md @@ -97,3 +97,22 @@ Paths in committed fixtures use `__REPO_ROOT__` token; tests hydrate with the ch | Phil working example | `examples/personas/phil-primitive-builder/` | CoCoder-neutral "Workshop Toolsmith" domain; not CoBuilder primitives. | | Public playbooks | `cocoder/personas/playbooks/{bob,talia,oscar,phil}.md` | Authored fresh; not copied from CoBuilder private playbooks. | | Private operator pattern | `cocoder/personas/playbooks/README-private-operator-pattern.md` + template `local/README.md` | Documents `/cocoder/local/playbooks/`. | + +## Orchestration-services import (2026-05-27) — ADR-0009 + +Ported CoBuilder's non-persona orchestration-services pattern. Source root: `/Volumes/NAS LOCAL/CoBuilder/infrastructure/cobuilder-build/orchestration/`. This port is **runtime/contracts**, not personas, but the scrubs are recorded here as the canonical CoBuilder→CoCoder divergence log. + +| Borrowed file (CoBuilder) | Target in CoCoder | Scrub / divergence | +|---|---|---| +| `core/lib/services.mjs` | `packages/core/lib/services.mjs` | **3 scrubs, logic verbatim:** (1) `DEFAULT_SERVICES_DIR` resolved module-relative (`../services` via `fileURLToPath`) instead of `repoPath('cobuilder-build/orchestration/services')` — services ship inside the package like `contracts/`+`adapters/`; (2) dropped the now-unused `repoPath` import; (3) `renderServicePrompt` "CoBuilder orchestration service packet" → "CoCoder". Reuses CoCoder's existing `lib/contracts.mjs` `loadContracts`/`validateInstance` (same names/shape upstream uses) — **no AJV introduced**. | +| `contracts/orchestration-service-declaration.schema.json` | `packages/core/contracts/orchestration-service-declaration.schema.json` | Verbatim (repo-agnostic custom `{contract,required,fields,rules}` shape; loads via existing `contracts.mjs`). | +| `contracts/orchestration-service.schema.json` | `packages/core/contracts/orchestration-service.schema.json` | Verbatim (the packet contract; `createdAt: iso-datetime` validates via the Bug-B `matchesType` fix). | +| `services/*.json` (11) | `packages/core/services/*.json` (11) | Verbatim **except `allowedWriteScopes` path scrubs** (read-only services unchanged — empty scopes): `cobuilder-build/PRIORITIES.md`→`cocoder/PRIORITIES.md`; `cobuilder-build/SESSION_LOG.md`→`cocoder/SESSION_LOG.md`; `SESSION_LOG_ARCHIVE.md` same prefix swap; `cobuilder-build/plans/*.md`→`cocoder/plans/*.md` **+ added `cocoder/priorities/*/plans/*.md`** (most CoCoder plans live under the priority folder); `cobuilder-build/PRIORITIES-ARCHIVE.md`→`cocoder/priorities/zArchive/INDEX.md` (CoCoder has no PRIORITIES-ARCHIVE.md); `cobuilder-build/orchestration/runs/*/jobs/*/result.{json,md}`→`local/workspaces/*/runs/*/jobs/*/result.{json,md}`. `requiredChecks` verbatim — all gate/command names (`check-handoff-consistency`, `check-session-log-hygiene`, `gate-result`, `orchestrator-commit`, `finalize-run-status`, `check-doc-refs`, …) already exist in CoCoder's CLI. | +| `adapters/cursor-agent.json` (repurposed headless) | `packages/core/adapters/cursor-agent-service.json` (**new id**) | Founder decision: do NOT overwrite CoCoder's interactive `cursor-agent.json` (resultContract `job-result`). New separate adapter declares the headless profile (`interactive:false`, `sandboxModes:[danger-full-access]`, `approvalModes:[never]`, `resultContract:orchestration-service-packet`). The executor hardcodes its own `cursor-agent` flags, so this adapter is registry metadata only. | +| `core/cli.mjs` service commands (5) | `packages/core/cli/registry.mjs` handlers + `commandRegistry` + `cli/help.mjs` | Same 5 commands. Added `service`/`executorCommand`/`model` to the `parseArgs` string allow-list in `cli/shared.mjs` (must not be `path.resolve`d). Added `DEFAULT_SERVICES_DIR`. Help baseline fixture `tests/fixtures/cli-help-baseline.txt` regenerated. | +| `core/lib/debugger.mjs` "Orchestration Service Pattern" section | `packages/core/lib/debugger.mjs` `renderDebuggerPrompt` | Additive prompt text only (no change to evidence APIs Oz reuses). Path scrub `cobuilder-build/orchestration/services/.json`→`packages/core/services/.json`. | +| `personas/prompts/shared/session-wrap.md` (item 7) | `cocoder/personas/prompts/shared/session-wrap.md` | Added the one service-delegation bullet verbatim (no CoBuilder-specific paths in it). | +| `tests/services.test.mjs` | `packages/core/tests/services.test.mjs` | Ported; dirs resolved module-relative; fixture paths `cobuilder-build/`→`cocoder/`; out-of-scope product path → `packages/core/lib/launch.mjs`; CLI entry → `packages/core/cli.mjs`. | +| `tests/debugger.test.mjs` (service-guidance asserts) | `packages/core/tests/debugger.test.mjs` | Added 4 assertions for the new guidance section (scrubbed path). | + +**Layout decision (founder):** service results land at `/services//` (upstream layout preserved), not `jobs//` — services are not personas/lanes. **Oz untouched (ADR-0008):** services execute externally and surface as ordinary run artifacts. diff --git a/cocoder/personas/prompts/shared/session-wrap.md b/cocoder/personas/prompts/shared/session-wrap.md index 12f43d8..8b7c39b 100644 --- a/cocoder/personas/prompts/shared/session-wrap.md +++ b/cocoder/personas/prompts/shared/session-wrap.md @@ -4,6 +4,7 @@ - Flag stale result text, missing Markdown/JSON result pairs, and mismatched Markdown/JSON result status or next action. - If a teammate result is non-PASS and you accept it, write your own PASS result JSON and Markdown pair, then run `record-supersession` before `finalize-run-status`; a textual acceptance in your result is not a supersession. - During Wrap Up, commit, or autonomous continuation decisions, classify dirty worktree state against the selected priority boundary and require a commit-boundary audit. This is not a pre-`add-lanes` smoke-test blocker; unrelated unstaged dirt should be preserved and carried as background context unless it overlaps the current command's read/write set or files Oscar is about to commit. +- For mechanical wrap, handoff compaction, or pre-launch context diagnosis work that would otherwise spend lead-model context, build a service packet with `build-service-packet` and run it through `execute-service-packet` before the route-owned wrap commit. Use `startup-context-audit` to find oversized startup/handoff sources, `wrap-execution` for Oscar-approved closeout edits, and `handoff-compaction` for oversized `PRIORITIES.md`, plan, or session-log handoff surfaces. The service is administrative only: Oscar keeps judgment authority, the packet must name exact `allowedWrites`, and any BLOCK/FAILED result or deterministic write-audit failure must be returned to Oscar with the diagnosis and proposed fix instead of being hidden. - Keep `SESSION_LOG.md` as a short newest-first handoff: at most 10 live entries, 10-20 lines per entry, no file inventories, commit-SHA lists, LOC counts, or test-count dumps. Rotate older entries to `SESSION_LOG_ARCHIVE.md` in the same route-owned commit when needed. - A terminal run is closed for new atom work, but its panes must remain open for the founder handoff. After `finalize-run-status` returns a terminal status, do not dispatch, send helper messages, commit, continue implementation, close panes, request pane teardown, or launch another Oscar run from the old pane unless the founder explicitly approves the next launch path. - If the founder asks to commit, update priorities, revise status docs, or keep going after terminalization, treat that as a new post-terminal execution request. Do not run git commands or edit files from the closed pane; state that the old pane is report-only and route the work to a fresh visible run or the Orchestrator Debugger. A broad approval such as "follow your recommendation" is acknowledgement of the next run/route, not permission to mutate state from a terminalized pane. The runtime enforces this: commit, lead-support-commit, send-message, add-lanes, and continuation paths refuse a terminal run (`terminal-run-locked`); read-only debugger, stop, and evidence commands stay available. diff --git a/cocoder/priorities/v0.5-orchestration-services/README.md b/cocoder/priorities/v0.5-orchestration-services/README.md new file mode 100644 index 0000000..05207eb --- /dev/null +++ b/cocoder/priorities/v0.5-orchestration-services/README.md @@ -0,0 +1,27 @@ +# v0.5 — Orchestration Services (cheap-model admin delegation) + +**Status:** Draft — engine landed 2026-05-27; adoption pending. **Owner:** Bob + founder. +**Decision:** [ADR-0009](../../decisions/0009-orchestration-services.md). **Relates to:** [ADR-0008](../../decisions/0008-oz-control-plane-architecture.md) (Oz unchanged). + +## Why + +Oscar (the lead orchestrator) was spending expensive lead-model context on repeatable, mechanical admin work — wrap cleanup, handoff/priority compaction, run summaries, teardown/commit-boundary/startup-context audits, result repair. CoCoder had no way to offload that to a cheaper/faster model with bounded writes + verification (model-roles covers only the *build* side). Orchestration services close that gap and are the concrete implementation of the "sub-agents/services that independently select CLI + model" clause already in ADR-0008 — which is why **Oz needs no change**. + +## Landed this session (2026-05-27) + +- Two contracts (`orchestration-service-declaration`, `orchestration-service` packet) + `lib/services.mjs` engine (build/validate/execute packet, deterministic git write-audit). +- 11 service declarations under `packages/core/services/` (path-scrubbed to CoCoder layout). +- 5 CLI commands; new `cursor-agent-service` headless executor adapter; debugger guidance; session-wrap fragment bullet. +- Tests: core 346/346, oz-daemon 8/8, oz-dashboard 10/10. `validate-orchestration-services` green against shipped declarations. + +## Remaining (adoption) + +1. **Wire services into Oscar's live wrap/teardown flow** — Oscar builds + runs packets during real runs (currently the engine + prompt guidance exist; the runtime wrap path does not yet invoke them automatically). +2. **Prove headless `cursor-agent` execution end-to-end** against a real run (the suite exercises a fake executor; validate the real `cursor-agent --print --trust --force --sandbox disabled` path + a cheap model). +3. **Surface service results in Oz run detail** — service artifacts already land under `/services//`; confirm the Oz run watcher enumerates/labels them (no Oz code change expected; verify only). +4. **Sequencing** — founder to place this relative to v0.2 / v0.3 / v0.4. The `v0.5` slug is provisional (engine is already in `main`-line core). + +## Notes + +- Adding a service is a new JSON declaration, never a `lib/services.mjs` edit (god-file guard, enforced by `validate-orchestration-services` + debugger guidance). +- Read-only services carry empty write scopes; bounded-write services (`handoff-compaction`, `wrap-execution`, `result-contract-repair`) are write-audited against exact packet `allowedWrites`. diff --git a/packages/core/adapters/cursor-agent-service.json b/packages/core/adapters/cursor-agent-service.json new file mode 100644 index 0000000..3974b4d --- /dev/null +++ b/packages/core/adapters/cursor-agent-service.json @@ -0,0 +1,31 @@ +{ + "id": "cursor-agent-service", + "label": "Cursor Agent (headless orchestration service executor)", + "kind": "llm-cli", + "command": "cursor-agent", + "commandEnv": "inherit", + "availabilityCheck": { + "commandExists": "cursor-agent", + "versionCommand": "cursor-agent --version", + "authHint": "Install Cursor Agent and complete local authentication (`cursor-agent login` or CURSOR_API_KEY) before assigning it to headless orchestration services." + }, + "capabilities": { + "interactive": false, + "initialPrompt": true, + "stdinDispatch": false, + "resultFile": true, + "transcriptCapture": true, + "streamingDetection": false, + "screenshots": false, + "dom": false, + "console": false, + "shell": true, + "fileEdit": true + }, + "writeCapability": "repo", + "sandboxModes": ["danger-full-access"], + "approvalModes": ["never"], + "resultContract": "orchestration-service-packet", + "evidenceCapabilities": ["transcript", "command-output", "diff", "test-result"], + "failureModes": ["missing-cli", "auth-expired", "refusal", "no-result-file", "permission-prompt", "rate-limit", "unknown"] +} diff --git a/packages/core/cli/help.mjs b/packages/core/cli/help.mjs index 1d962b1..ff19bfa 100644 --- a/packages/core/cli/help.mjs +++ b/packages/core/cli/help.mjs @@ -73,6 +73,11 @@ Commands: prepare-debugger --no-session true [--mode launch-failure|preflight|repo-audit] [--runs-dir PATH] [--debugger-runs-dir PATH] [--tmux-bin PATH] prepare-debug (alias for prepare-debugger) watch-debugger-evidence --run-dir PATH --session-id ID --debug-dir PATH [--follow-interval-seconds N] [--tmux-bin PATH] [--max-cycles N] + list-orchestration-services [--services-dir PATH] [--contracts-dir PATH] + validate-orchestration-services [--services-dir PATH] [--contracts-dir PATH] + build-service-packet --service ID --run-dir PATH --request PATH [--output PATH] [--services-dir PATH] [--contracts-dir PATH] [--now ISO] + validate-service-packet --packet PATH [--services-dir PATH] [--contracts-dir PATH] + execute-service-packet --packet PATH [--repo-root PATH] [--executor-command CMD] [--model ID] [--result PATH] [--transcript PATH] [--services-dir PATH] [--contracts-dir PATH] [--now ISO] `; export { HELP_TEXT }; diff --git a/packages/core/cli/registry.mjs b/packages/core/cli/registry.mjs index 16e9fa1..6d62f03 100644 --- a/packages/core/cli/registry.mjs +++ b/packages/core/cli/registry.mjs @@ -48,6 +48,12 @@ import { validateImprovementDirectory } from '../lib/self-healing.mjs'; import { followDebuggerEvidence, prepareDebuggerSession } from '../lib/debugger.mjs'; +import { + buildOrchestrationServicePacket, + executeOrchestrationServicePacket, + listOrchestrationServices, + validateOrchestrationServicePacket +} from '../lib/services.mjs'; import { applyWorkspaceInit } from '../lib/init-merge.mjs'; import { auditWorkspace, refreshWorkspaceMemory } from '../lib/workspace-audit.mjs'; import { ozSubcommandHandlers } from './oz.mjs'; @@ -56,6 +62,7 @@ import { DEFAULT_ADAPTERS_DIR, DEFAULT_BASELINE, DEFAULT_CONTRACTS_DIR, + DEFAULT_SERVICES_DIR, DEFAULT_IMPROVEMENTS_DIR, DEFAULT_PERSONAS_DIR, DEFAULT_PRIORITY_BOUNDARIES_DIR, @@ -929,6 +936,71 @@ async function handle_oz() { throw new Error('Use: cocoder oz start|stop|status|register [--cocoder-home PATH]'); } +async function handle_list_orchestration_services(args) { + const result = await listOrchestrationServices({ + servicesDir: args.servicesDir || DEFAULT_SERVICES_DIR, + contractsDir: args.contractsDir || DEFAULT_CONTRACTS_DIR + }); + console.log(JSON.stringify(result, null, 2)); + if (!result.ok) process.exitCode = 1; + return; +} + +async function handle_validate_orchestration_services(args) { + const result = await listOrchestrationServices({ + servicesDir: args.servicesDir || DEFAULT_SERVICES_DIR, + contractsDir: args.contractsDir || DEFAULT_CONTRACTS_DIR + }); + console.log(JSON.stringify({ ok: result.ok, services: result.services.map((service) => service.id), issues: result.issues }, null, 2)); + if (!result.ok) process.exitCode = 1; + return; +} + +async function handle_build_service_packet(args) { + requireArgs(args, ['service', 'runDir', 'request']); + const result = await buildOrchestrationServicePacket({ + serviceId: args.service, + runDir: args.runDir, + request: args.request, + outputPath: args.output, + contractsDir: args.contractsDir || DEFAULT_CONTRACTS_DIR, + servicesDir: args.servicesDir || DEFAULT_SERVICES_DIR, + now: args.now || new Date().toISOString() + }); + console.log(JSON.stringify(result, null, 2)); + if (!result.ok) process.exitCode = 1; + return; +} + +async function handle_validate_service_packet(args) { + requireArgs(args, ['packet']); + const result = await validateOrchestrationServicePacket(args.packet, { + contractsDir: args.contractsDir || DEFAULT_CONTRACTS_DIR, + servicesDir: args.servicesDir || DEFAULT_SERVICES_DIR + }); + console.log(JSON.stringify(result, null, 2)); + if (!result.ok) process.exitCode = 1; + return; +} + +async function handle_execute_service_packet(args) { + requireArgs(args, ['packet']); + const result = await executeOrchestrationServicePacket({ + packetPath: args.packet, + repoRoot: args.repoRoot || process.cwd(), + contractsDir: args.contractsDir || DEFAULT_CONTRACTS_DIR, + servicesDir: args.servicesDir || DEFAULT_SERVICES_DIR, + executorCommand: args.executorCommand || 'cursor-agent', + model: args.model, + resultPath: args.result, + transcriptPath: args.transcript, + now: args.now || new Date().toISOString() + }); + console.log(JSON.stringify(result, null, 2)); + if (!result.ok) process.exitCode = 1; + return; +} + export { ozSubcommandHandlers }; export const commandRegistry = new Map([ @@ -998,6 +1070,11 @@ export const commandRegistry = new Map([ ['prepare-debugger', handle_prepare_debugger], ['prepare-debug', handle_prepare_debugger], ['watch-debugger-evidence', handle_watch_debugger_evidence], + ['list-orchestration-services', handle_list_orchestration_services], + ['validate-orchestration-services', handle_validate_orchestration_services], + ['build-service-packet', handle_build_service_packet], + ['validate-service-packet', handle_validate_service_packet], + ['execute-service-packet', handle_execute_service_packet], ]); export const registeredCommandNames = [...commandRegistry.keys()].sort((a, b) => a.localeCompare(b)); diff --git a/packages/core/cli/shared.mjs b/packages/core/cli/shared.mjs index af8635d..9cda365 100644 --- a/packages/core/cli/shared.mjs +++ b/packages/core/cli/shared.mjs @@ -16,6 +16,7 @@ export const CORE_DIR = path.join(CLI_DIR, '..'); export const DEFAULT_CONTRACTS_DIR = path.join(CORE_DIR, 'contracts'); export const DEFAULT_BASELINE = path.join(CORE_DIR, 'baselines', 'accepted-reference-baseline.md'); export const DEFAULT_ADAPTERS_DIR = path.join(CORE_DIR, 'adapters'); +export const DEFAULT_SERVICES_DIR = path.join(CORE_DIR, 'services'); export const DEFAULT_PROFILES_DIR = repoPath('cocoder/profiles'); export const DEFAULT_ROUTES_DIR = repoPath('cocoder/routes'); export const DEFAULT_PERSONAS_DIR = repoPath('cocoder/personas'); @@ -75,7 +76,7 @@ export function parseArgs(tokens) { if (!next || next.startsWith('--')) args[key] = 'true'; else { args[key] = path.resolve(next); - if (['contract', 'prioritySlug', 'status', 'reason', 'summary', 'runId', 'jobId', 'sessionId', 'confirmRunId', 'mode', 'followIntervalSeconds', 'maxCycles', 'execute', 'deferStart', 'attach', 'stopTerminalSessions', 'founderApprovedTeardown', 'sessionLineLimit', 'owner', 'nonce', 'now', 'ttlMs', 'staleMs', 'timeoutMs', 'thresholdDays', 'maxChars', 'maxEntries', 'maxEntryLines', 'maxEntryChars', 'allowLive', 'cdpUrl', 'socketName', 'socketPath', 'lane', 'lanes', 'message', 'command', 'tmuxBin', 'noSession', 'supersededLane', 'resolvingLane', 'basis', 'findings', 'evidence', 'id', 'name', 'tmuxSocket', 'createdBy', 'personaPaths', 'sessionLog', 'topologyOption', 'requiredPersonas', 'autoAttachAddedLanes', 'workspaceSlug', 'developerMode', 'allowConcurrentPriorityRun', 'revealSecrets'].includes(key)) args[key] = next; + if (['contract', 'prioritySlug', 'status', 'reason', 'summary', 'runId', 'jobId', 'sessionId', 'confirmRunId', 'mode', 'followIntervalSeconds', 'maxCycles', 'execute', 'deferStart', 'attach', 'stopTerminalSessions', 'founderApprovedTeardown', 'sessionLineLimit', 'owner', 'nonce', 'now', 'ttlMs', 'staleMs', 'timeoutMs', 'thresholdDays', 'maxChars', 'maxEntries', 'maxEntryLines', 'maxEntryChars', 'allowLive', 'cdpUrl', 'socketName', 'socketPath', 'lane', 'lanes', 'message', 'command', 'tmuxBin', 'noSession', 'supersededLane', 'resolvingLane', 'basis', 'findings', 'evidence', 'id', 'name', 'tmuxSocket', 'createdBy', 'personaPaths', 'sessionLog', 'topologyOption', 'requiredPersonas', 'autoAttachAddedLanes', 'workspaceSlug', 'developerMode', 'allowConcurrentPriorityRun', 'revealSecrets', 'service', 'executorCommand', 'model'].includes(key)) args[key] = next; index += 1; } } diff --git a/packages/core/contracts/orchestration-service-declaration.schema.json b/packages/core/contracts/orchestration-service-declaration.schema.json new file mode 100644 index 0000000..292ebda --- /dev/null +++ b/packages/core/contracts/orchestration-service-declaration.schema.json @@ -0,0 +1,33 @@ +{ + "contract": "orchestration-service-declaration", + "version": 1, + "status": "draft", + "description": "Declarative registry entry for a non-persona orchestration service that Oscar may use for bounded administrative work.", + "required": [ + "version", + "id", + "label", + "mode", + "purpose", + "execution", + "allowedWriteScopes", + "requiredChecks" + ], + "fields": { + "version": { "type": "number" }, + "id": { "type": "string", "pattern": "kebab-case stable identifier" }, + "label": { "type": "string" }, + "mode": { "enum": ["read-only", "bounded-write", "control-plane"] }, + "purpose": { "type": "string" }, + "execution": { "type": "object", "required": ["style", "preferredModelClass", "fallback"] }, + "allowedWriteScopes": { "type": "array", "items": "repo-relative file path or glob" }, + "requiredChecks": { "type": "array", "items": "command or gate name" } + }, + "rules": [ + "Service declarations are registry metadata, not personas.", + "Read-only service declarations must have an empty allowedWriteScopes list.", + "Bounded-write service declarations must name the broadest service-owned scopes; service packets narrow those scopes to exact allowedWrites for each execution.", + "The declaration file name must be .json.", + "Execution guidance may name a model class, but Oscar retains decision authority and packets retain exact write authority." + ] +} diff --git a/packages/core/contracts/orchestration-service.schema.json b/packages/core/contracts/orchestration-service.schema.json new file mode 100644 index 0000000..6efc9e4 --- /dev/null +++ b/packages/core/contracts/orchestration-service.schema.json @@ -0,0 +1,51 @@ +{ + "contract": "orchestration-service-packet", + "version": 1, + "status": "draft", + "description": "Bounded administrative service request packet used by Oscar to delegate mechanical orchestration work without delegating judgment.", + "required": [ + "version", + "id", + "createdAt", + "serviceId", + "mode", + "decisionAuthority", + "executionAuthority", + "execution", + "run", + "objective", + "allowedWrites", + "forbiddenDecisions", + "requiredChecks", + "resultContract" + ], + "fields": { + "version": { "type": "number" }, + "id": { "type": "string" }, + "createdAt": { "type": "iso-datetime" }, + "serviceId": { "type": "string" }, + "serviceLabel": { "type": "string" }, + "mode": { "enum": ["read-only", "bounded-write", "control-plane"] }, + "requestedBy": { "type": "string" }, + "decisionAuthority": { "enum": ["oscar-only"] }, + "executionAuthority": { "enum": ["orchestration-service"] }, + "execution": { "type": "object", "required": ["style", "preferredModelClass", "fallback"] }, + "run": { "type": "object", "required": ["runId", "runDir", "status"] }, + "objective": { "type": "string" }, + "oscarDecision": { "type": "object" }, + "allowedWrites": { "type": "array", "items": "repo-relative file path" }, + "forbiddenDecisions": { "type": "array", "items": "string" }, + "requiredChecks": { "type": "array", "items": "command or gate name" }, + "evidence": { "type": "array", "items": "artifact path or evidence summary" }, + "constraints": { "type": "array", "items": "string" }, + "resultContract": { "type": "object", "required": ["statusValues", "mustReport", "mayEditOnlyAllowedWrites"] } + }, + "rules": [ + "Service packets delegate administrative execution only; Oscar retains phase, priority, architecture, scope, and founder-decision judgment.", + "Read-only services must have an empty allowedWrites list.", + "Bounded-write services may edit only paths listed in allowedWrites and must satisfy requiredChecks before reporting PASS.", + "Service packets are not persona contracts and do not create new durable personas.", + "Any cheap-model execution behind a service must consume this packet and return a result against its resultContract.", + "Headless service execution must return diagnosis and proposedFix to Oscar on BLOCK, FAILED, or deterministic validation failure." + ] +} diff --git a/packages/core/lib/debugger.mjs b/packages/core/lib/debugger.mjs index 5181ded..2827308 100644 --- a/packages/core/lib/debugger.mjs +++ b/packages/core/lib/debugger.mjs @@ -667,6 +667,15 @@ function renderDebuggerPrompt(bundle) { '8. Ask the founder: "Do you want me to apply the recommended orchestration fixes?"', '9. Only after explicit yes, patch orchestration files, add/update tests, run the relevant tests, and write `debug_report` plus `debug_result`.', '', + '## Orchestration Service Pattern', + '', + '- When an orchestration issue is recurring, mechanical, and bounded, consider whether it should become or use an orchestration service instead of staying as ad hoc debugger guidance.', + '- Suggest or add a service when Oscar is spending expensive lead-model context on repeatable administrative work, when a failure has a recurring diagnostic shape, when wrap/teardown/continuation mechanics can be packetized, or when the same debugger finding appears across runs.', + '- Do not suggest a service for priority ordering, architecture direction, founder-gated scope, persona dispatch judgment, Bob/Talia/Phil/Quinn/Ian domain work, or fuzzy work whose boundaries would hide judgment inside a cheap model.', + '- New services are declaration files under `packages/core/services/.json`, validated by `validate-orchestration-services`; runtime service code should not become a catalog god file.', + '- Service declarations must keep Oscar as decision authority. Use read-only mode for audits and bounded-write mode only when `allowedWriteScopes` can be narrowed by each service packet.', + '- Useful current services include `startup-context-audit` for oversized launch/handoff context, `handoff-compaction` for bloated status surfaces, and `wrap-execution` for Oscar-approved closeout edits.', + '', '## No-Session Mode', '', 'If `no_session` is `true`, this debugger was launched without a run-backed session because orchestration may be failing before run artifacts exist.', diff --git a/packages/core/lib/services.mjs b/packages/core/lib/services.mjs new file mode 100644 index 0000000..d2867d7 --- /dev/null +++ b/packages/core/lib/services.mjs @@ -0,0 +1,572 @@ +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import { loadContracts, validateInstance } from './contracts.mjs'; +import { pathExists, readJson, writeJson } from './fs-utils.mjs'; + +const execFileAsync = promisify(execFile); +const DEFAULT_SERVICES_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../services'); +const FORBIDDEN_DECISIONS = [ + 'Do not decide priority order.', + 'Do not decide architecture direction.', + 'Do not decide founder-gated scope.', + 'Do not declare an atom complete unless Oscar supplied that decision.', + 'Do not expand write scope beyond allowedWrites.', + 'Do not dispatch personas, launch lanes, or substitute model roles.' +]; + +export async function listOrchestrationServices({ servicesDir = DEFAULT_SERVICES_DIR, contractsDir } = {}) { + return loadOrchestrationServiceDeclarations({ servicesDir, contractsDir }); +} + +export async function loadOrchestrationServiceDeclarations({ servicesDir = DEFAULT_SERVICES_DIR, contractsDir } = {}) { + const issues = []; + const services = []; + const seen = new Set(); + const files = (await readdir(servicesDir)) + .filter((fileName) => fileName.endsWith('.json')) + .sort(); + const declarationContract = contractsDir ? await readDeclarationContract(contractsDir, issues) : null; + + for (const fileName of files) { + const filePath = path.join(servicesDir, fileName); + let service = null; + try { + service = await readJson(filePath); + } catch (error) { + issues.push(issue('invalid-service-json', `${filePath}: ${error.message}`)); + continue; + } + if (declarationContract) { + issues.push(...validateInstance(declarationContract, service).map((detail) => issue('service-declaration-contract-invalid', `${filePath}: ${detail}`))); + } + issues.push(...validateServiceDeclaration(service, fileName, seen)); + if (service?.id) seen.add(service.id); + services.push(service); + } + return { ok: issues.length === 0, services, issues }; +} + +export async function buildOrchestrationServicePacket({ + serviceId, + runDir, + request, + outputPath, + contractsDir, + servicesDir = DEFAULT_SERVICES_DIR, + now = new Date().toISOString() +} = {}) { + const catalog = await loadOrchestrationServiceDeclarations({ servicesDir, contractsDir }); + if (!catalog.ok) return failedPacketResult(catalog.issues); + const service = serviceById(catalog.services, serviceId); + if (!service) return failedPacketResult([issue('unknown-service', `unknown orchestration service: ${serviceId || ''}`)]); + const normalizedRequest = typeof request === 'string' ? await readJson(request) : (request || {}); + const runContext = await readRunContext(runDir); + const requestedWrites = normalizeStringArray(normalizedRequest.allowedWrites || normalizedRequest.filesAllowed || []); + const allowedWrites = normalizeAllowedWrites(service, requestedWrites); + const issues = [ + ...validateServiceRequest({ service, request: normalizedRequest, requestedWrites, allowedWrites }), + ...runContext.issues + ]; + const packet = { + version: 1, + id: normalizedRequest.id || `${service.id}-${safeId(runContext.run.runId || 'no-run')}-${compactTimestamp(now)}`, + createdAt: now, + serviceId: service.id, + serviceLabel: service.label, + mode: service.mode, + execution: service.execution, + requestedBy: normalizedRequest.requestedBy || 'oscar', + decisionAuthority: 'oscar-only', + executionAuthority: 'orchestration-service', + run: runContext.run, + objective: normalizedRequest.objective || service.purpose, + oscarDecision: normalizedRequest.oscarDecision || {}, + allowedWrites, + forbiddenDecisions: FORBIDDEN_DECISIONS, + requiredChecks: service.requiredChecks, + evidence: normalizeStringArray(normalizedRequest.evidence), + constraints: [ + ...(service.mode === 'read-only' ? ['read-only service: do not edit files'] : ['bounded-write service: edit allowedWrites only']), + ...normalizeStringArray(normalizedRequest.constraints) + ], + resultContract: { + statusValues: ['PASS', 'BLOCK', 'NEEDS_FOUNDER', 'FAILED'], + mustReport: ['filesChanged', 'checksRun', 'evidence', 'residualRisk', 'nextAction'], + mayEditOnlyAllowedWrites: service.mode !== 'read-only' + } + }; + + const contractIssues = contractsDir ? await validatePacketContract(packet, contractsDir) : []; + issues.push(...contractIssues); + const result = { ok: issues.length === 0, packet, issues }; + if (result.ok && outputPath) await writeJson(outputPath, packet); + return result; +} + +export async function validateOrchestrationServicePacket(packetOrPath, { contractsDir, servicesDir = DEFAULT_SERVICES_DIR } = {}) { + const packet = typeof packetOrPath === 'string' ? await readJson(packetOrPath) : packetOrPath; + const catalog = await loadOrchestrationServiceDeclarations({ servicesDir, contractsDir }); + const issues = [...catalog.issues]; + const service = catalog.ok ? serviceById(catalog.services, packet?.serviceId) : null; + if (!service) issues.push(issue('unknown-service', `unknown orchestration service: ${packet?.serviceId || ''}`)); + if (service && packet.mode !== service.mode) issues.push(issue('service-mode-mismatch', `packet mode ${packet.mode || ''} does not match service ${service.mode}`)); + if (packet?.decisionAuthority !== 'oscar-only') issues.push(issue('invalid-decision-authority', 'decisionAuthority must be oscar-only')); + if (packet?.executionAuthority !== 'orchestration-service') issues.push(issue('invalid-execution-authority', 'executionAuthority must be orchestration-service')); + if (service) { + const allowedWrites = normalizeStringArray(packet.allowedWrites); + issues.push(...validateAllowedWrites(service, allowedWrites)); + } + if (contractsDir) issues.push(...await validatePacketContract(packet, contractsDir)); + return { ok: issues.length === 0, packet, issues }; +} + +export async function executeOrchestrationServicePacket({ + packetPath, + packet, + repoRoot = process.cwd(), + contractsDir, + servicesDir = DEFAULT_SERVICES_DIR, + executorCommand = 'cursor-agent', + model, + resultPath, + transcriptPath, + now = new Date().toISOString() +} = {}) { + const loadedPacket = packet || await readJson(packetPath); + const validation = await validateOrchestrationServicePacket(loadedPacket, { contractsDir, servicesDir }); + if (!validation.ok) { + return serviceExecutionResult({ + ok: false, + status: 'BLOCK', + packet: loadedPacket, + issues: validation.issues, + diagnosis: 'Service packet validation failed.', + proposedFix: 'Fix the service packet fields or ask Oscar to revise the service request.' + }); + } + + const service = serviceById((await loadOrchestrationServiceDeclarations({ servicesDir, contractsDir })).services, loadedPacket.serviceId); + const outputDir = path.join(loadedPacket.run.runDir, 'services', loadedPacket.id); + await mkdir(outputDir, { recursive: true }); + const resolvedResultPath = resultPath || path.join(outputDir, 'result.json'); + const resolvedTranscriptPath = transcriptPath || path.join(outputDir, 'transcript.txt'); + const beforeState = await gitStatusMap(repoRoot); + const prompt = renderServicePrompt({ + packet: loadedPacket, + resultPath: resolvedResultPath, + repoRoot + }); + + let stdout = ''; + let stderr = ''; + let executorError = null; + try { + const executed = await runServiceExecutor({ + executorCommand, + prompt, + repoRoot, + resultPath: resolvedResultPath, + packetPath, + model + }); + stdout = executed.stdout; + stderr = executed.stderr; + } catch (error) { + executorError = error; + stdout = error.stdout || ''; + stderr = error.stderr || error.message; + } + await writeFile(resolvedTranscriptPath, [ + `# Orchestration Service Transcript`, + `createdAt: ${now}`, + `serviceId: ${loadedPacket.serviceId}`, + `executorCommand: ${executorCommand}`, + '', + '## STDOUT', + stdout || '', + '', + '## STDERR', + stderr || '' + ].join('\n')); + + if (executorError) { + return serviceExecutionResult({ + ok: false, + status: 'FAILED', + packet: loadedPacket, + resultPath: resolvedResultPath, + transcriptPath: resolvedTranscriptPath, + issues: [issue('executor-failed', executorError.message)], + diagnosis: 'The headless service executor failed before producing an accepted service result.', + proposedFix: 'Oscar should inspect the transcript and either retry with a narrower packet or recommend an Orchestrator Debugger launch.' + }); + } + + const serviceResult = await readServiceResult(resolvedResultPath, stdout); + const resultIssues = validateServiceResult(serviceResult, loadedPacket); + const afterState = await gitStatusMap(repoRoot); + const writeIssues = auditServiceWrites({ + service, + packet: loadedPacket, + beforeState, + afterState, + ignoredPaths: [ + repoRelativePath(repoRoot, resolvedResultPath), + repoRelativePath(repoRoot, resolvedTranscriptPath) + ] + }); + const issues = [...resultIssues, ...writeIssues]; + const status = issues.length > 0 ? 'BLOCK' : serviceResult.status; + return serviceExecutionResult({ + ok: issues.length === 0 && status === 'PASS', + status, + packet: loadedPacket, + serviceResult, + resultPath: resolvedResultPath, + transcriptPath: resolvedTranscriptPath, + issues, + diagnosis: issues.length > 0 + ? 'Service execution completed, but deterministic validation blocked acceptance.' + : serviceResult.diagnosis || 'Service execution passed.', + proposedFix: issues.length > 0 + ? 'Oscar should apply the proposed fix from the service result if it stays in scope; otherwise recommend an Orchestrator Debugger launch.' + : serviceResult.proposedFix || 'None.' + }); +} + +async function readDeclarationContract(contractsDir, issues) { + const contracts = await loadContracts(contractsDir); + const contract = contracts.get('orchestration-service-declaration'); + if (!contract) { + issues.push(issue('missing-contract', 'orchestration-service-declaration contract is missing')); + return null; + } + return contract; +} + +function validateServiceDeclaration(service, fileName, seen) { + const issues = []; + if (!service || typeof service !== 'object' || Array.isArray(service)) { + return [issue('invalid-service-declaration', `${fileName}: service declaration must be an object`)]; + } + if (seen.has(service.id)) issues.push(issue('duplicate-service-id', `${service.id} is declared more than once`)); + if (service.id && fileName !== `${service.id}.json`) { + issues.push(issue('service-file-id-mismatch', `${fileName} must match service id ${service.id}`)); + } + if (service.mode === 'read-only' && normalizeStringArray(service.allowedWriteScopes).length > 0) { + issues.push(issue('read-only-service-has-write-scopes', `${service.id} is read-only but declares allowedWriteScopes`)); + } + if (service.mode === 'bounded-write' && normalizeStringArray(service.allowedWriteScopes).length === 0) { + issues.push(issue('bounded-write-service-missing-scopes', `${service.id} must declare allowedWriteScopes`)); + } + return issues; +} + +function serviceById(services, serviceId) { + return services.find((service) => service.id === serviceId) || null; +} + +async function readRunContext(runDir) { + const issues = []; + if (!runDir) { + return { + run: { runId: '', runDir: '', status: 'unknown', terminal: false, lanes: [] }, + issues: [issue('missing-run-dir', 'runDir is required')] + }; + } + const resolvedRunDir = path.resolve(runDir); + const statusPath = path.join(resolvedRunDir, 'status.json'); + const launchPath = path.join(resolvedRunDir, 'launch.json'); + const startupPacketPath = path.join(resolvedRunDir, 'startup-packet.json'); + const status = await readJsonIfExists(statusPath, issues); + const launch = await readJsonIfExists(launchPath, issues); + const startupPacket = await readJsonIfExists(startupPacketPath, issues); + const sessions = Array.isArray(launch?.sessions) ? launch.sessions : []; + return { + run: { + runId: status?.runId || launch?.runId || path.basename(resolvedRunDir), + runDir: resolvedRunDir, + routeId: status?.routeId || launch?.route?.id || startupPacket?.route?.id || null, + prioritySlug: startupPacket?.selectedPriority?.slug || startupPacket?.resolvedWriteBoundary?.prioritySlug || null, + status: status?.status || 'unknown', + terminal: status?.terminal === true, + lanes: sessions.map((session) => ({ + lane: session.lane, + persona: session.persona, + adapter: session.adapter, + resultPath: session.resultPath, + resultStatus: status?.jobs?.[session.lane]?.status || null + })) + }, + issues + }; +} + +async function readJsonIfExists(filePath, issues) { + if (!(await pathExists(filePath))) { + issues.push(issue('missing-run-artifact', `missing run artifact: ${filePath}`)); + return null; + } + try { + return JSON.parse(await readFile(filePath, 'utf8')); + } catch (error) { + issues.push(issue('invalid-json', `${filePath}: ${error.message}`)); + return null; + } +} + +function normalizeAllowedWrites(service, requested) { + if (service.mode === 'read-only') return []; + const values = normalizeStringArray(requested); + return values; +} + +async function runServiceExecutor({ executorCommand, prompt, repoRoot, resultPath, packetPath, model }) { + const base = path.basename(executorCommand); + if (base === 'cursor-agent' || base === 'cursor-agent.cmd') { + const args = [ + '--print', + '--trust', + '--force', + '--sandbox', + 'disabled', + '--workspace', + repoRoot + ]; + if (model) args.push('--model', model); + args.push(prompt); + return execFileAsync(executorCommand, args, { + cwd: repoRoot, + env: { + ...process.env, + SERVICE_PACKET_PATH: packetPath || '', + SERVICE_RESULT_PATH: resultPath + }, + maxBuffer: 1024 * 1024 * 10 + }); + } + + return execFileAsync(executorCommand, [], { + cwd: repoRoot, + env: { + ...process.env, + SERVICE_PACKET_PATH: packetPath || '', + SERVICE_RESULT_PATH: resultPath, + SERVICE_PROMPT: prompt + }, + maxBuffer: 1024 * 1024 * 10 + }); +} + +function renderServicePrompt({ packet, resultPath, repoRoot }) { + return [ + 'You are executing a CoCoder orchestration service packet, not acting as a persona.', + 'Oscar owns all judgment. Do not change priority, scope, architecture, founder decisions, or atom completion.', + `Workspace: ${repoRoot}`, + `Write the service result JSON to: ${resultPath}`, + '', + 'Hard rules:', + '- Edit only files listed in allowedWrites. If the packet is read-only, edit nothing.', + '- If checks fail, return status BLOCK with diagnosis and proposedFix for Oscar.', + '- Always write non-empty diagnosis, proposedFix, and nextAction strings, including for PASS. For a clean PASS, use diagnosis like "Required checks passed." and proposedFix "None.".', + '- If scope is missing or unsafe, do not improvise; return NEEDS_FOUNDER or BLOCK.', + '- Do not run git commit unless the packet explicitly includes that as an allowed required check and the service instructions require it.', + '', + 'Service result JSON shape:', + '{ "status": "PASS|BLOCK|NEEDS_FOUNDER|FAILED", "serviceId": "...", "filesChanged": [], "checksRun": [], "evidence": [], "residualRisk": [], "diagnosis": "...", "proposedFix": "...", "nextAction": "..." }', + '', + 'Packet:', + JSON.stringify(packet, null, 2) + ].join('\n'); +} + +async function readServiceResult(resultPath, stdout) { + if (await pathExists(resultPath)) return readJson(resultPath); + try { + return JSON.parse(stdout); + } catch { + return { + status: 'FAILED', + serviceId: '', + filesChanged: [], + checksRun: [], + evidence: [], + residualRisk: [], + diagnosis: 'Executor did not write result JSON and stdout was not JSON.', + proposedFix: 'Ask Oscar to retry with a narrower service packet or launch the Orchestrator Debugger.', + nextAction: 'Return failure to Oscar.' + }; + } +} + +function validateServiceResult(result, packet) { + const issues = []; + if (!result || typeof result !== 'object' || Array.isArray(result)) { + return [issue('service-result-invalid', 'service result must be an object')]; + } + if (!['PASS', 'BLOCK', 'NEEDS_FOUNDER', 'FAILED'].includes(result.status)) { + issues.push(issue('service-result-status-invalid', 'service result status must be PASS, BLOCK, NEEDS_FOUNDER, or FAILED')); + } + if (result.serviceId !== packet.serviceId) { + issues.push(issue('service-result-id-mismatch', `service result serviceId ${result.serviceId || ''} does not match packet ${packet.serviceId}`)); + } + for (const field of ['filesChanged', 'checksRun', 'evidence', 'residualRisk']) { + if (!Array.isArray(result[field])) issues.push(issue('service-result-field-invalid', `${field} must be an array`)); + } + for (const field of ['diagnosis', 'proposedFix', 'nextAction']) { + if (typeof result[field] !== 'string' || result[field].trim() === '') { + issues.push(issue('service-result-field-invalid', `${field} must be a non-empty string`)); + } + } + if (result.status !== 'PASS' && (!result.diagnosis || !result.proposedFix)) { + issues.push(issue('service-result-missing-diagnosis', 'non-PASS service results must include diagnosis and proposedFix')); + } + return issues; +} + +function auditServiceWrites({ service, packet, beforeState, afterState, ignoredPaths = [] }) { + const issues = []; + const ignored = new Set(ignoredPaths.filter(Boolean).map((filePath) => normalizeScope(filePath))); + const changed = changedSince(beforeState, afterState).filter((filePath) => !ignored.has(normalizeScope(filePath))); + const allowed = service.mode === 'read-only' ? [] : packet.allowedWrites; + if (service.mode === 'read-only' && changed.length > 0) { + issues.push(issue('read-only-service-wrote-files', `read-only service changed files: ${changed.join(', ')}`)); + } + for (const filePath of changed) { + if (!matchesAnyScope(filePath, allowed)) { + issues.push(issue('service-write-outside-allowed-writes', `${filePath} changed outside allowedWrites`)); + } + } + return issues; +} + +function repoRelativePath(repoRoot, filePath) { + if (!filePath) return ''; + const relativePath = path.relative(repoRoot, filePath); + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) return ''; + return relativePath; +} + +function changedSince(beforeState, afterState) { + const paths = new Set([...beforeState.keys(), ...afterState.keys()]); + return [...paths].filter((filePath) => beforeState.get(filePath) !== afterState.get(filePath)).sort(); +} + +async function gitStatusMap(repoRoot) { + try { + const { stdout } = await execFileAsync('git', ['status', '--porcelain=v1', '--untracked-files=all'], { + cwd: repoRoot, + maxBuffer: 1024 * 1024 * 5 + }); + const map = new Map(); + for (const line of stdout.split(/\r?\n/)) { + if (!line.trim()) continue; + const filePath = line.slice(3).replace(/^"|"$/g, ''); + map.set(filePath, line.slice(0, 2)); + } + return map; + } catch { + return new Map(); + } +} + +function serviceExecutionResult({ ok, status, packet, serviceResult = null, resultPath = '', transcriptPath = '', issues = [], diagnosis, proposedFix }) { + return { + ok, + status, + serviceId: packet?.serviceId || null, + packetId: packet?.id || null, + resultPath, + transcriptPath, + serviceResult, + issues, + diagnosis, + proposedFix, + nextAction: ok + ? 'Return PASS service result to Oscar.' + : 'Return diagnosis and proposed fix to Oscar; Oscar either fixes in scope or recommends an Orchestrator Debugger launch.' + }; +} + +function validateServiceRequest({ service, request, requestedWrites, allowedWrites }) { + const issues = []; + if (typeof request.objective !== 'string' || request.objective.trim() === '') { + issues.push(issue('missing-objective', 'service request must include a concrete objective')); + } + if (service.mode === 'read-only' && requestedWrites.length > 0) { + issues.push(issue('read-only-service-requested-writes', `read-only service ${service.id} request must not include allowedWrites`)); + } + if (service.mode !== 'read-only' && (!request.oscarDecision || typeof request.oscarDecision !== 'object' || Array.isArray(request.oscarDecision))) { + issues.push(issue('missing-oscar-decision', 'bounded-write services require an oscarDecision object')); + } + if (service.mode !== 'read-only' && requestedWrites.length === 0) { + issues.push(issue('missing-allowed-writes', `bounded-write service ${service.id} request must name exact allowedWrites`)); + } + issues.push(...validateAllowedWrites(service, allowedWrites)); + return issues; +} + +function validateAllowedWrites(service, allowedWrites) { + const issues = []; + if (service.mode === 'read-only' && allowedWrites.length > 0) { + issues.push(issue('read-only-service-has-writes', `read-only service ${service.id} cannot allow writes`)); + } + for (const filePath of allowedWrites) { + if (!matchesAnyScope(filePath, service.allowedWriteScopes)) { + issues.push(issue('write-outside-service-scope', `${filePath} is outside service ${service.id} write scope`)); + } + } + return issues; +} + +async function validatePacketContract(packet, contractsDir) { + const contracts = await loadContracts(contractsDir); + const contract = contracts.get('orchestration-service-packet'); + if (!contract) return [issue('missing-contract', 'orchestration-service-packet contract is missing')]; + return validateInstance(contract, packet).map((detail) => issue('contract-invalid', detail)); +} + +function matchesAnyScope(filePath, scopes) { + if (!scopes || scopes.length === 0) return false; + return scopes.some((scope) => matchesScope(filePath, scope)); +} + +function matchesScope(filePath, scope) { + const normalizedPath = normalizeScope(filePath); + const normalizedScope = normalizeScope(scope); + if (normalizedScope.includes('*')) return globToRegExp(normalizedScope).test(normalizedPath); + return normalizedPath === normalizedScope || normalizedPath.startsWith(`${normalizedScope}/`); +} + +function globToRegExp(scope) { + const escaped = scope.split('*').map((part) => part.replace(/[|\\{}()[\]^$+?.]/g, '\\$&')).join('[^/]*'); + return new RegExp(`^${escaped}$`); +} + +function normalizeScope(value) { + return String(value || '').split(path.sep).join('/').replace(/^\/+/, '').replace(/\/+$/g, ''); +} + +function normalizeStringArray(value) { + if (!Array.isArray(value)) return []; + return value.map((item) => String(item || '').trim()).filter(Boolean); +} + +function failedPacketResult(issues) { + return { ok: false, packet: null, issues }; +} + +function issue(code, detail) { + return { code, detail }; +} + +function safeId(value) { + return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'service'; +} + +function compactTimestamp(iso) { + return String(iso || new Date().toISOString()).replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); +} diff --git a/packages/core/services/commit-boundary-audit.json b/packages/core/services/commit-boundary-audit.json new file mode 100644 index 0000000..e2f561f --- /dev/null +++ b/packages/core/services/commit-boundary-audit.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "id": "commit-boundary-audit", + "label": "Commit Boundary Audit", + "mode": "read-only", + "purpose": "Classify staged files, dirty files, allowed paths, out-of-scope dirt, and exact filesChanged before a route-owned commit.", + "execution": { "style": "deterministic", "preferredModelClass": "none", "fallback": "ask-oscar" }, + "allowedWriteScopes": [], + "requiredChecks": ["git status --short", "write-boundary audit"] +} diff --git a/packages/core/services/doc-hygiene.json b/packages/core/services/doc-hygiene.json new file mode 100644 index 0000000..3a2f1d5 --- /dev/null +++ b/packages/core/services/doc-hygiene.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "id": "doc-hygiene", + "label": "Doc Hygiene", + "mode": "read-only", + "purpose": "Run stale-doc, ADR, reference, write-authority, and handoff-budget checks and summarize exact blockers.", + "execution": { "style": "deterministic", "preferredModelClass": "none", "fallback": "ask-oscar" }, + "allowedWriteScopes": [], + "requiredChecks": ["check-doc-refs", "check-adr-status-consistency", "check-doc-freshness", "check-write-authority"] +} diff --git a/packages/core/services/evidence-collation.json b/packages/core/services/evidence-collation.json new file mode 100644 index 0000000..d64fdde --- /dev/null +++ b/packages/core/services/evidence-collation.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "id": "evidence-collation", + "label": "Evidence Collation", + "mode": "read-only", + "purpose": "Collect command outputs, result paths, watcher logs, git facts, and evidence references into a compact bundle for Oscar review.", + "execution": { "style": "deterministic", "preferredModelClass": "none", "fallback": "ask-oscar" }, + "allowedWriteScopes": [], + "requiredChecks": ["source artifact existence", "bounded evidence summary"] +} diff --git a/packages/core/services/handoff-compaction.json b/packages/core/services/handoff-compaction.json new file mode 100644 index 0000000..a20e4a3 --- /dev/null +++ b/packages/core/services/handoff-compaction.json @@ -0,0 +1,16 @@ +{ + "version": 1, + "id": "handoff-compaction", + "label": "Handoff Compaction", + "mode": "bounded-write", + "purpose": "Compact bloated priority, plan, and session-log handoff text to budgeted current-state summaries without changing Oscar decisions.", + "execution": { "style": "cheap-model", "preferredModelClass": "fast-low-cost-summarizer", "fallback": "deterministic-budget-block" }, + "allowedWriteScopes": [ + "cocoder/PRIORITIES.md", + "cocoder/SESSION_LOG.md", + "cocoder/SESSION_LOG_ARCHIVE.md", + "cocoder/plans/*.md", + "cocoder/priorities/*/plans/*.md" + ], + "requiredChecks": ["check-handoff-consistency", "check-session-log-hygiene", "handoff budget gates"] +} diff --git a/packages/core/services/next-run-packet.json b/packages/core/services/next-run-packet.json new file mode 100644 index 0000000..9d7e890 --- /dev/null +++ b/packages/core/services/next-run-packet.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "id": "next-run-packet", + "label": "Next Run Packet", + "mode": "read-only", + "purpose": "Prepare a concise launch note and next-atom packet from Oscar-approved continuation fields.", + "execution": { "style": "cheap-model-or-deterministic", "preferredModelClass": "fast-low-cost-summarizer", "fallback": "ask-oscar" }, + "allowedWriteScopes": [], + "requiredChecks": ["parseable next atom", "priority boundary present"] +} diff --git a/packages/core/services/regression-triage.json b/packages/core/services/regression-triage.json new file mode 100644 index 0000000..14ebe97 --- /dev/null +++ b/packages/core/services/regression-triage.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "id": "regression-triage", + "label": "Regression Triage", + "mode": "read-only", + "purpose": "Summarize failing checks, likely ownership, candidate files, and reproduction commands without editing code.", + "execution": { "style": "cheap-model", "preferredModelClass": "fast-low-cost-triage", "fallback": "ask-oscar" }, + "allowedWriteScopes": [], + "requiredChecks": ["failing command capture", "owner/boundary classification"] +} diff --git a/packages/core/services/result-contract-repair.json b/packages/core/services/result-contract-repair.json new file mode 100644 index 0000000..b17b594 --- /dev/null +++ b/packages/core/services/result-contract-repair.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "id": "result-contract-repair", + "label": "Result Contract Repair", + "mode": "bounded-write", + "purpose": "Repair malformed run-local result JSON/Markdown against the launch result contract without changing the substantive verdict.", + "execution": { "style": "cheap-model-or-deterministic", "preferredModelClass": "fast-low-cost-editor", "fallback": "ask-oscar" }, + "allowedWriteScopes": [ + "local/workspaces/*/runs/*/jobs/*/result.json", + "local/workspaces/*/runs/*/jobs/*/result.md" + ], + "requiredChecks": ["gate-result", "founder completion brief validation"] +} diff --git a/packages/core/services/run-summary.json b/packages/core/services/run-summary.json new file mode 100644 index 0000000..bf612b5 --- /dev/null +++ b/packages/core/services/run-summary.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "id": "run-summary", + "label": "Run Summary", + "mode": "read-only", + "purpose": "Summarize run status, lane results, watchers, events, and next action from run artifacts.", + "execution": { "style": "deterministic", "preferredModelClass": "none", "fallback": "cheap-model-summary" }, + "allowedWriteScopes": [], + "requiredChecks": ["status/result consistency"] +} diff --git a/packages/core/services/startup-context-audit.json b/packages/core/services/startup-context-audit.json new file mode 100644 index 0000000..9c0167f --- /dev/null +++ b/packages/core/services/startup-context-audit.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "id": "startup-context-audit", + "label": "Startup Context Audit", + "mode": "read-only", + "purpose": "Audit selected priority, startup packet, prompt, plan, and session-log context budgets before launch or wrap so Oscar gets exact compaction targets instead of absorbing oversized history.", + "execution": { "style": "deterministic-or-cheap-model", "preferredModelClass": "fast-low-cost-audit", "fallback": "ask-oscar" }, + "allowedWriteScopes": [], + "requiredChecks": ["startup packet budget review", "priority/session excerpt budget review", "prompt source size summary", "compaction target list"] +} diff --git a/packages/core/services/teardown-readiness.json b/packages/core/services/teardown-readiness.json new file mode 100644 index 0000000..ff8cf9f --- /dev/null +++ b/packages/core/services/teardown-readiness.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "id": "teardown-readiness", + "label": "Teardown Readiness", + "mode": "read-only", + "purpose": "Confirm terminal status, committed wrap, no staged work, no pending dispatch, and founder-approved teardown condition.", + "execution": { "style": "deterministic", "preferredModelClass": "none", "fallback": "ask-oscar" }, + "allowedWriteScopes": [], + "requiredChecks": ["terminal status", "git staged state", "lane result pair existence"] +} diff --git a/packages/core/services/wrap-execution.json b/packages/core/services/wrap-execution.json new file mode 100644 index 0000000..1858cd6 --- /dev/null +++ b/packages/core/services/wrap-execution.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "id": "wrap-execution", + "label": "Wrap Execution", + "mode": "bounded-write", + "purpose": "Apply Oscar-approved closeout intent to handoff/status docs, then prepare route-owned commit/finalization checks.", + "execution": { "style": "cheap-model-or-deterministic", "preferredModelClass": "fast-low-cost-editor", "fallback": "ask-oscar" }, + "allowedWriteScopes": [ + "cocoder/PRIORITIES.md", + "cocoder/priorities/zArchive/INDEX.md", + "cocoder/SESSION_LOG.md", + "cocoder/SESSION_LOG_ARCHIVE.md", + "cocoder/plans/*.md", + "cocoder/priorities/*/plans/*.md" + ], + "requiredChecks": [ + "check-handoff-consistency", + "check-session-log-hygiene", + "git diff --check", + "orchestrator-commit", + "finalize-run-status" + ] +} diff --git a/packages/core/tests/adapters.test.mjs b/packages/core/tests/adapters.test.mjs index 192b8ef..23e7674 100644 --- a/packages/core/tests/adapters.test.mjs +++ b/packages/core/tests/adapters.test.mjs @@ -14,7 +14,7 @@ test('committed adapter declarations validate against adapter-declaration contra assert.equal(loaded.failures.length, 0); assert.deepEqual( loaded.adapters.map((adapter) => adapter.id).sort(), - ['claude', 'codex', 'cursor-agent', 'future-cli-template', 'gemini', 'grok', 'kimi', 'quinn-scripts'] + ['claude', 'codex', 'cursor-agent', 'cursor-agent-service', 'future-cli-template', 'gemini', 'grok', 'kimi', 'quinn-scripts'] ); }); diff --git a/packages/core/tests/debugger.test.mjs b/packages/core/tests/debugger.test.mjs index 34f7e4e..4736488 100644 --- a/packages/core/tests/debugger.test.mjs +++ b/packages/core/tests/debugger.test.mjs @@ -60,6 +60,10 @@ test('debugger resolves run suffixes and writes a Codex audit prompt', async () assert.match(prompt, /topology-decision\.json/); assert.match(prompt, /debugger tmux send\/intervention attempt fails/); assert.match(prompt, /observe-only/); + assert.match(prompt, /Orchestration Service Pattern/); + assert.match(prompt, /recurring, mechanical, and bounded/); + assert.match(prompt, /packages\/core\/services\/\.json/); + assert.match(prompt, /validate-orchestration-services/); const wrapper = await readFile(result.wrapperPath, 'utf8'); assert.match(wrapper, /COCODER_ORCH_DEBUGGER_GIT_WRITE/); diff --git a/packages/core/tests/fixtures/cli-help-baseline.txt b/packages/core/tests/fixtures/cli-help-baseline.txt index 75fa3a2..1190d60 100644 --- a/packages/core/tests/fixtures/cli-help-baseline.txt +++ b/packages/core/tests/fixtures/cli-help-baseline.txt @@ -73,4 +73,9 @@ Commands: prepare-debugger --no-session true [--mode launch-failure|preflight|repo-audit] [--runs-dir PATH] [--debugger-runs-dir PATH] [--tmux-bin PATH] prepare-debug (alias for prepare-debugger) watch-debugger-evidence --run-dir PATH --session-id ID --debug-dir PATH [--follow-interval-seconds N] [--tmux-bin PATH] [--max-cycles N] + list-orchestration-services [--services-dir PATH] [--contracts-dir PATH] + validate-orchestration-services [--services-dir PATH] [--contracts-dir PATH] + build-service-packet --service ID --run-dir PATH --request PATH [--output PATH] [--services-dir PATH] [--contracts-dir PATH] [--now ISO] + validate-service-packet --packet PATH [--services-dir PATH] [--contracts-dir PATH] + execute-service-packet --packet PATH [--repo-root PATH] [--executor-command CMD] [--model ID] [--result PATH] [--transcript PATH] [--services-dir PATH] [--contracts-dir PATH] [--now ISO] diff --git a/packages/core/tests/services.test.mjs b/packages/core/tests/services.test.mjs new file mode 100644 index 0000000..8d4b7f0 --- /dev/null +++ b/packages/core/tests/services.test.mjs @@ -0,0 +1,400 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { + buildOrchestrationServicePacket, + executeOrchestrationServicePacket, + listOrchestrationServices, + validateOrchestrationServicePacket +} from '../lib/services.mjs'; + +const execFileAsync = promisify(execFile); +const coreRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const contractsDir = path.join(coreRoot, 'contracts'); +const servicesDir = path.join(coreRoot, 'services'); +const cliPath = path.join(coreRoot, 'cli.mjs'); + +test('orchestration service catalog separates admin services from personas', async () => { + const loaded = await listOrchestrationServices({ servicesDir, contractsDir }); + assert.equal(loaded.ok, true, JSON.stringify(loaded.issues, null, 2)); + const services = loaded.services; + const ids = services.map((service) => service.id); + assert.ok(ids.includes('wrap-execution')); + assert.ok(ids.includes('evidence-collation')); + assert.ok(ids.includes('handoff-compaction')); + assert.ok(ids.includes('startup-context-audit')); + const wrap = services.find((service) => service.id === 'wrap-execution'); + assert.equal(wrap.mode, 'bounded-write'); + assert.equal(wrap.execution.preferredModelClass, 'fast-low-cost-editor'); + assert.deepEqual(services.filter((service) => service.mode === 'read-only').flatMap((service) => service.allowedWriteScopes), []); +}); + +test('wrap-execution service packet preserves Oscar decision authority and bounded writes', async () => { + const fixture = await createRunFixture(); + try { + const result = await buildOrchestrationServicePacket({ + serviceId: 'wrap-execution', + runDir: fixture.runDir, + request: { + objective: 'Apply Oscar-approved Phase 2 closeout handoff.', + oscarDecision: { + disposition: 'continue', + completedAtom: 'D4', + nextAtom: 'P3.1' + }, + allowedWrites: [ + 'cocoder/PRIORITIES.md', + 'cocoder/SESSION_LOG.md', + 'cocoder/SESSION_LOG_ARCHIVE.md', + 'cocoder/plans/2026-05-24-file-state-rebuild-2.md' + ], + evidence: ['jobs/oscar/result.json'] + }, + contractsDir, + servicesDir, + now: '2026-05-27T12:00:00.000Z' + }); + + assert.equal(result.ok, true, JSON.stringify(result.issues, null, 2)); + assert.equal(result.packet.decisionAuthority, 'oscar-only'); + assert.equal(result.packet.executionAuthority, 'orchestration-service'); + assert.equal(result.packet.mode, 'bounded-write'); + assert.equal(result.packet.run.prioritySlug, 'FILE-STATE-REBUILD-2'); + assert.equal(result.packet.run.lanes[0].resultStatus, 'PASS'); + assert.equal(result.packet.allowedWrites.includes('cocoder/plans/2026-05-24-file-state-rebuild-2.md'), true); + assert.equal(result.packet.forbiddenDecisions.some((item) => item.includes('Do not decide priority order')), true); + assert.equal(result.packet.execution.preferredModelClass, 'fast-low-cost-editor'); + + const validation = await validateOrchestrationServicePacket(result.packet, { contractsDir, servicesDir }); + assert.equal(validation.ok, true, JSON.stringify(validation.issues, null, 2)); + } finally { + await fixture.cleanup(); + } +}); + +test('service packet builder rejects write scope expansion and read-only writes', async () => { + const fixture = await createRunFixture(); + try { + const outOfScope = await buildOrchestrationServicePacket({ + serviceId: 'wrap-execution', + runDir: fixture.runDir, + request: { + objective: 'Try to edit product code.', + oscarDecision: { nextAtom: 'P3.1' }, + allowedWrites: ['packages/core/lib/launch.mjs'] + }, + contractsDir + }); + assert.equal(outOfScope.ok, false); + assert.equal(outOfScope.issues.some((issue) => issue.code === 'write-outside-service-scope'), true); + + const readOnly = await validateOrchestrationServicePacket({ + version: 1, + id: 'bad-read-only', + createdAt: '2026-05-27T12:00:00.000Z', + serviceId: 'evidence-collation', + mode: 'read-only', + requestedBy: 'oscar', + decisionAuthority: 'oscar-only', + executionAuthority: 'orchestration-service', + execution: { style: 'deterministic', preferredModelClass: 'none', fallback: 'ask-oscar' }, + run: { runId: 'run-fixture', runDir: fixture.runDir, status: 'running' }, + objective: 'Collect evidence.', + allowedWrites: ['cocoder/SESSION_LOG.md'], + forbiddenDecisions: ['Do not decide priority order.'], + requiredChecks: ['source artifact existence'], + resultContract: { + statusValues: ['PASS'], + mustReport: ['evidence'], + mayEditOnlyAllowedWrites: false + } + }, { contractsDir, servicesDir }); + assert.equal(readOnly.ok, false); + assert.equal(readOnly.issues.some((issue) => issue.code === 'read-only-service-has-writes'), true); + + const readOnlyRequest = await buildOrchestrationServicePacket({ + serviceId: 'startup-context-audit', + runDir: fixture.runDir, + request: { + objective: 'Audit startup context without editing files.', + allowedWrites: ['cocoder/PRIORITIES.md'] + }, + contractsDir, + servicesDir + }); + assert.equal(readOnlyRequest.ok, false); + assert.equal(readOnlyRequest.issues.some((issue) => issue.code === 'read-only-service-requested-writes'), true); + + const implicitWriteScope = await buildOrchestrationServicePacket({ + serviceId: 'handoff-compaction', + runDir: fixture.runDir, + request: { + objective: 'Compact handoff docs.', + oscarDecision: { nextAtom: 'P3.1' } + }, + contractsDir, + servicesDir + }); + assert.equal(implicitWriteScope.ok, false); + assert.equal(implicitWriteScope.issues.some((issue) => issue.code === 'missing-allowed-writes'), true); + } finally { + await fixture.cleanup(); + } +}); + +test('startup-context-audit service packet is read-only and preserves Oscar authority', async () => { + const fixture = await createRunFixture(); + try { + const result = await buildOrchestrationServicePacket({ + serviceId: 'startup-context-audit', + runDir: fixture.runDir, + request: { + objective: 'Audit startup context budgets before Oscar reads large handoff files.', + evidence: ['startup-packet.json', 'jobs/oscar/prompt.md'] + }, + contractsDir, + servicesDir, + now: '2026-05-27T12:00:00.000Z' + }); + + assert.equal(result.ok, true, JSON.stringify(result.issues, null, 2)); + assert.equal(result.packet.serviceId, 'startup-context-audit'); + assert.equal(result.packet.mode, 'read-only'); + assert.equal(result.packet.decisionAuthority, 'oscar-only'); + assert.deepEqual(result.packet.allowedWrites, []); + assert.equal(result.packet.resultContract.mayEditOnlyAllowedWrites, false); + assert.ok(result.packet.constraints.includes('read-only service: do not edit files')); + } finally { + await fixture.cleanup(); + } +}); + +test('execute-service-packet runs a headless executor and accepts bounded writes', async () => { + const fixture = await createGitRunFixture(); + try { + const executorPath = await createFakeExecutor(fixture.tmp, { + changedFile: 'cocoder/PRIORITIES.md', + status: 'PASS' + }); + const packet = await buildServicePacketForExecution(fixture); + + const result = await executeOrchestrationServicePacket({ + packet, + repoRoot: fixture.tmp, + contractsDir, + servicesDir, + executorCommand: executorPath, + now: '2026-05-27T12:00:00.000Z' + }); + + assert.equal(result.ok, true, JSON.stringify(result.issues, null, 2)); + assert.equal(result.status, 'PASS'); + assert.equal(result.nextAction, 'Return PASS service result to Oscar.'); + assert.equal(result.resultPath.endsWith('/services/handoff-compaction-run-fixture-20260527T120000Z/result.json'), true); + assert.match(await readFile(result.transcriptPath, 'utf8'), /executorCommand:/); + } finally { + await fixture.cleanup(); + } +}); + +test('execute-service-packet blocks out-of-scope writes with Oscar-facing diagnosis', async () => { + const fixture = await createGitRunFixture(); + try { + const executorPath = await createFakeExecutor(fixture.tmp, { + changedFile: 'outside.txt', + status: 'PASS' + }); + const packet = await buildServicePacketForExecution(fixture); + + const result = await executeOrchestrationServicePacket({ + packet, + repoRoot: fixture.tmp, + contractsDir, + servicesDir, + executorCommand: executorPath, + now: '2026-05-27T12:00:00.000Z' + }); + + assert.equal(result.ok, false); + assert.equal(result.status, 'BLOCK'); + assert.equal(result.issues.some((issue) => issue.code === 'service-write-outside-allowed-writes'), true); + assert.equal( + result.nextAction, + 'Return diagnosis and proposed fix to Oscar; Oscar either fixes in scope or recommends an Orchestrator Debugger launch.' + ); + assert.match(result.diagnosis, /deterministic validation blocked/); + } finally { + await fixture.cleanup(); + } +}); + +test('build-service-packet CLI writes a validated packet', async () => { + const fixture = await createRunFixture(); + try { + const requestPath = path.join(fixture.tmp, 'request.json'); + const outputPath = path.join(fixture.tmp, 'packet.json'); + await writeFile(requestPath, `${JSON.stringify({ + objective: 'Compact the handoff without changing Oscar decisions.', + oscarDecision: { nextAtom: 'P3.1' }, + allowedWrites: ['cocoder/PRIORITIES.md', 'cocoder/plans/2026-05-24-file-state-rebuild-2.md'] + }, null, 2)}\n`); + + const { stdout } = await execFileAsync(process.execPath, [ + cliPath, + 'build-service-packet', + '--service', 'handoff-compaction', + '--run-dir', fixture.runDir, + '--request', requestPath, + '--output', outputPath, + '--services-dir', servicesDir, + '--contracts-dir', contractsDir, + '--now', '2026-05-27T12:00:00.000Z' + ]); + const result = JSON.parse(stdout); + assert.equal(result.ok, true, JSON.stringify(result.issues, null, 2)); + const packet = JSON.parse(await readFile(outputPath, 'utf8')); + assert.equal(packet.serviceId, 'handoff-compaction'); + assert.equal(packet.decisionAuthority, 'oscar-only'); + } finally { + await fixture.cleanup(); + } +}); + +test('execute-service-packet CLI invokes configured headless executor', async () => { + const fixture = await createGitRunFixture(); + try { + const executorPath = await createFakeExecutor(fixture.tmp, { + changedFile: 'cocoder/PRIORITIES.md', + status: 'PASS' + }); + const packet = await buildServicePacketForExecution(fixture); + const packetPath = path.join(fixture.tmp, 'packet.json'); + await writeFile(packetPath, `${JSON.stringify(packet, null, 2)}\n`); + + const { stdout } = await execFileAsync(process.execPath, [ + cliPath, + 'execute-service-packet', + '--packet', packetPath, + '--repo-root', fixture.tmp, + '--executor-command', executorPath, + '--services-dir', servicesDir, + '--contracts-dir', contractsDir, + '--now', '2026-05-27T12:00:00.000Z' + ]); + const result = JSON.parse(stdout); + assert.equal(result.ok, true, JSON.stringify(result.issues, null, 2)); + assert.equal(result.status, 'PASS'); + assert.equal(result.serviceId, 'handoff-compaction'); + } finally { + await fixture.cleanup(); + } +}); + +test('validate-orchestration-services CLI validates declaration files', async () => { + const { stdout } = await execFileAsync(process.execPath, [ + cliPath, + 'validate-orchestration-services', + '--services-dir', servicesDir, + '--contracts-dir', contractsDir + ]); + const result = JSON.parse(stdout); + assert.equal(result.ok, true, JSON.stringify(result.issues, null, 2)); + assert.ok(result.services.includes('startup-context-audit')); +}); + +async function createGitRunFixture() { + const fixture = await createRunFixture(); + await mkdir(path.join(fixture.tmp, 'cocoder/plans'), { recursive: true }); + await writeFile(path.join(fixture.tmp, 'cocoder/PRIORITIES.md'), '# Priorities\n'); + await writeFile(path.join(fixture.tmp, 'cocoder/SESSION_LOG.md'), '# Session Log\n'); + await writeFile(path.join(fixture.tmp, 'cocoder/plans/2026-05-24-file-state-rebuild-2.md'), '# Plan\n'); + await writeFile(path.join(fixture.tmp, 'outside.txt'), 'baseline\n'); + await execFileAsync('git', ['init'], { cwd: fixture.tmp }); + await execFileAsync('git', ['config', 'user.email', 'test@example.invalid'], { cwd: fixture.tmp }); + await execFileAsync('git', ['config', 'user.name', 'Test Runner'], { cwd: fixture.tmp }); + await execFileAsync('git', ['add', '.'], { cwd: fixture.tmp }); + await execFileAsync('git', ['commit', '-m', 'fixture'], { cwd: fixture.tmp }); + return fixture; +} + +async function buildServicePacketForExecution(fixture) { + const result = await buildOrchestrationServicePacket({ + serviceId: 'handoff-compaction', + runDir: fixture.runDir, + request: { + objective: 'Compact the handoff without changing Oscar decisions.', + oscarDecision: { nextAtom: 'P3.1' }, + allowedWrites: ['cocoder/PRIORITIES.md'] + }, + contractsDir, + servicesDir, + now: '2026-05-27T12:00:00.000Z' + }); + assert.equal(result.ok, true, JSON.stringify(result.issues, null, 2)); + return result.packet; +} + +async function createFakeExecutor(tmp, { changedFile, status }) { + const executorPath = path.join(tmp, 'fake-service-executor.sh'); + await writeFile(executorPath, [ + '#!/bin/sh', + 'set -eu', + `printf 'service update\\n' >> '${changedFile}'`, + 'cat > "$SERVICE_RESULT_PATH" <<\'JSON\'', + JSON.stringify({ + status, + serviceId: 'handoff-compaction', + filesChanged: [changedFile], + checksRun: ['fake-check'], + evidence: ['fake executor wrote requested change'], + residualRisk: [], + diagnosis: 'fake executor completed', + proposedFix: 'None.', + nextAction: 'Return to Oscar.' + }, null, 2), + 'JSON' + ].join('\n')); + await chmod(executorPath, 0o755); + return executorPath; +} + +async function createRunFixture() { + const tmp = await mkdtemp(path.join(os.tmpdir(), 'cocoder-services-')); + const runDir = path.join(tmp, 'runs/run-fixture'); + await mkdir(path.join(runDir, 'jobs/oscar'), { recursive: true }); + await writeFile(path.join(runDir, 'status.json'), `${JSON.stringify({ + runId: 'run-fixture', + status: 'running', + terminal: false, + routeId: 'claude-oscar-dynamic', + jobs: { + oscar: { status: 'PASS', persona: 'oscar', adapter: 'claude' } + } + }, null, 2)}\n`); + await writeFile(path.join(runDir, 'launch.json'), `${JSON.stringify({ + runId: 'run-fixture', + route: { id: 'claude-oscar-dynamic', lead: 'oscar' }, + sessions: [ + { + lane: 'oscar', + persona: 'oscar', + adapter: 'claude', + resultPath: path.join(runDir, 'jobs/oscar/result.json') + } + ] + }, null, 2)}\n`); + await writeFile(path.join(runDir, 'startup-packet.json'), `${JSON.stringify({ + selectedPriority: { slug: 'FILE-STATE-REBUILD-2' }, + route: { id: 'claude-oscar-dynamic' } + }, null, 2)}\n`); + return { + tmp, + runDir, + cleanup: () => rm(tmp, { recursive: true, force: true }) + }; +} From edb5e03e8e77b2804200161c59afab3e3714b254 Mon Sep 17 00:00:00 2001 From: Anthony Franco Date: Wed, 27 May 2026 20:45:52 -0600 Subject: [PATCH 2/2] v0.5 priority: enrich next-start brief for fresh-launch pickup (Phase 1-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make v0.5-orchestration-services launch-ready on this branch so a fresh oscar-lead run picks up the full plan: - README "Next Session Start Here": Phase 1 (land PR #50 — Bob fixes wrap-execution requiredChecks, Oscar rebases onto main + squash-merges), Phase 2 (reconcile PR #51), Phase 3 (adoption + v0.1 carryover/ADR-0011 + archive v0.1-foundation + ghost/dangling guard). Route, broadened-Bob boundary, stop conditions, required tests, founder decisions recorded. - Sequencing DECIDED: v0.5 runs before v0.4 (founder 2026-05-27). - PRIORITIES.md: promote v0.5 to Active with Phase-1 next-action + parser block updated (launch from orchestration-services-import). Context: PR #50 was orphaned (engine + ADR-0009 + this priority never merged to main), leaving a ghost v0.5 row in the route + a dangling ADR-0009 ref. Phase 1 fixes that. Co-Authored-By: Claude Opus 4.7 (1M context) --- cocoder/PRIORITIES.md | 4 +-- .../v0.5-orchestration-services/README.md | 30 +++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/cocoder/PRIORITIES.md b/cocoder/PRIORITIES.md index 4598dbf..1cfc06d 100644 --- a/cocoder/PRIORITIES.md +++ b/cocoder/PRIORITIES.md @@ -16,6 +16,7 @@ Slim index of active and archived priorities. Open a priority's folder for detai | Slug | Description | Status | Canon | Owner | Blocked on | |---|---|---|---|---|---| | [`v0.1-foundation`](./priorities/v0.1-foundation/README.md) | Ship CoCoder v0.1 — extraction, Oz MVP, docs, public publish | Active | Expand — **Sub-Playbook D activated**; D Solve next. Suite **335/335** (+ dashboard 8/8). | Bob + founder | **Next:** D Solve. B/C Refines parallel (founder). | +| [`v0.5-orchestration-services`](./priorities/v0.5-orchestration-services/README.md) | Cheap/fast-model admin delegation — Oscar offloads wrap/compaction/teardown to bounded services | **Active** | Adoption — engine landed (PR #50, this branch) | Bob + founder | **Sequenced before v0.4** (founder 2026-05-27). **Next:** Phase 1 — land PR #50 (Bob fixes `wrap-execution`; Oscar rebases onto `main` + squash-merges). Launch from `orchestration-services-import`. | ## Draft @@ -24,7 +25,6 @@ Slim index of active and archived priorities. Open a priority's folder for detai | [`v0.2-adapter-extensibility`](./priorities/v0.2-adapter-extensibility/README.md) | Beyond local CLI models — cloud APIs (Anthropic Messages, Kimi K2.6), managed sessions (Cursor SDK), etc. | Draft | — | Bob + founder | After v0.1-foundation Complete **and now after v0.3-workspace-lifecycle** (2026-05-26 resequence). Depends on Sub-Playbook C Oz dashboard. Authored 2026-05-22 per founder ask. | | [`v0.3-workspace-lifecycle`](./priorities/v0.3-workspace-lifecycle/README.md) | Onboard into new/existing projects, manage multi-root workspaces, secure project secrets — via Oz | Draft | — | Bob + founder | **Sequenced before v0.2** (2026-05-26). Near-term "Dogfood Loop Enablement" slice first. Depends on Oz dashboard (Sub-Playbook C). ADR-0007 accepted. | | [`v0.4-oz-control-plane`](./priorities/v0.4-oz-control-plane/README.md) | Oz as a real control plane — in-app chat command interface + run oversight/debugger; UI per ADR-0008 | Draft | — | Bob + founder | Founder decision. Depends on the claude.ai/design output + ADR-0008. Stub authored 2026-05-27. | -| [`v0.5-orchestration-services`](./priorities/v0.5-orchestration-services/README.md) | Cheap/fast-model admin delegation — Oscar offloads wrap/compaction/teardown to bounded services | Draft | — | Bob + founder | **Engine landed 2026-05-27** (ADR-0009); adoption (wire into Oscar wrap, live cursor-agent proof, Oz run-detail surfacing) + sequencing pending. | ## Recently Archived @@ -71,4 +71,4 @@ Slim index of active and archived priorities. Open a priority's folder for detai **Owner:** Bob + founder **Summary:** Let Oscar run faster/cheaper models for repeatable admin work (priority/handoff editing, run wrap-up, teardown) via bounded non-persona orchestration services, instead of spending lead-model context. **What:** Declarative services (`packages/core/services/*.json`) + two contracts + `lib/services.mjs` (build/validate/execute packet with deterministic git write-audit) + 5 CLI commands + a headless `cursor-agent-service` adapter. 11 services shipped. Oz unchanged (services run externally, surface as ordinary run artifacts — ADR-0008 preserved). Complements `model-roles.mjs` (build-side cheap models) with lead/admin-side delegation. -**Status:** Draft — **engine landed 2026-05-27** (ADR-0009; core 346/346). Remaining: wire services into Oscar's live wrap/teardown flow, prove headless `cursor-agent` execution end-to-end, confirm Oz run-detail surfacing, and founder sequencing of the `v0.5` slug. See [`priorities/v0.5-orchestration-services/README.md`](./priorities/v0.5-orchestration-services/README.md). +**Status:** **Active — engine landed (PR #50, branch `orchestration-services-import`; ADR-0009; core 346/346). Sequenced BEFORE v0.4 (founder 2026-05-27). Launch this priority from branch `orchestration-services-import`.** **Next (Phase 1):** land PR #50 — Bob fixes `packages/core/services/wrap-execution.json` (drop `orchestrator-commit`/`finalize-run-status` from `requiredChecks`), Oscar rebases onto current `main` + resolves governance conflicts + squash-merges (kills the ghost priority + dangling ADR-0009). Then Phase 2 (reconcile PR #51 / oz-control-plane-design) and Phase 3 (adoption + v0.1 carryover/ADR-0011 + archive v0.1-foundation + ghost/dangling guard). Full brief in [`priorities/v0.5-orchestration-services/README.md`](./priorities/v0.5-orchestration-services/README.md) → "Next Session Start Here". diff --git a/cocoder/priorities/v0.5-orchestration-services/README.md b/cocoder/priorities/v0.5-orchestration-services/README.md index 05207eb..cfd5e2a 100644 --- a/cocoder/priorities/v0.5-orchestration-services/README.md +++ b/cocoder/priorities/v0.5-orchestration-services/README.md @@ -1,7 +1,8 @@ # v0.5 — Orchestration Services (cheap-model admin delegation) -**Status:** Draft — engine landed 2026-05-27; adoption pending. **Owner:** Bob + founder. +**Status:** **Active — engine landed (PR #50, this branch); next = land PR #50 + reconcile + adopt.** **Sequenced BEFORE v0.4-oz-control-plane** (founder, 2026-05-27). **Owner:** Bob + founder (Oscar orchestrates). **Decision:** [ADR-0009](../../decisions/0009-orchestration-services.md). **Relates to:** [ADR-0008](../../decisions/0008-oz-control-plane-architecture.md) (Oz unchanged). +**Launch this priority from branch `orchestration-services-import` (PR #50).** The engine + this priority live here, not yet on `main`; Phase 1 lands them. ## Why @@ -19,7 +20,32 @@ Oscar (the lead orchestrator) was spending expensive lead-model context on repea 1. **Wire services into Oscar's live wrap/teardown flow** — Oscar builds + runs packets during real runs (currently the engine + prompt guidance exist; the runtime wrap path does not yet invoke them automatically). 2. **Prove headless `cursor-agent` execution end-to-end** against a real run (the suite exercises a fake executor; validate the real `cursor-agent --print --trust --force --sandbox disabled` path + a cheap model). 3. **Surface service results in Oz run detail** — service artifacts already land under `/services//`; confirm the Oz run watcher enumerates/labels them (no Oz code change expected; verify only). -4. **Sequencing** — founder to place this relative to v0.2 / v0.3 / v0.4. The `v0.5` slug is provisional (engine is already in `main`-line core). +4. **Sequencing** — **DECIDED 2026-05-27 (founder): runs BEFORE v0.4-oz-control-plane.** The `v0.5` slug is kept (it's the natural home for "orchestration services"); work order is ahead of v0.4 because a hardened, bounded service layer de-risks the v0.4 control-plane build. +5. **Close out v0.1** as a carryover track here (so v0.1-foundation can be archived) — see Next Session Start Here. + +## Next Session Start Here + +> **Why this priority isn't on `main` yet:** the engine + ADR-0009 + this README were imported on PR #50 (`orchestration-services-import`) and **never merged** — that orphaned PR (plus a ghost v0.5 row in the route and a dangling ADR-0009 reference) was an orchestration failure surfaced 2026-05-27. Phase 1 fixes it by landing PR #50. + +**Recommended next atom:** Phase 1 — land PR #50. + +**Route / topology:** `oscar-lead` (Oscar lead + Bob builder). Strict substitution. +**Write boundary:** broadened-Bob — `packages/`, `docs/`, `.github/`, `README.md`, `ARCHITECTURE.md`, `templates/`, `examples/`, `LICENSE`/`NOTICE`; + Oscar governance (`cocoder/PRIORITIES.md`, `priorities/`, `decisions/`, `SESSION_LOG.md`, `plans/`, `tickets/`); exclude `secrets/`, `local/`. + +**Phase 1 — land PR #50 (this branch) → `main`:** +1. **Bob:** fix `packages/core/services/wrap-execution.json` — remove `orchestrator-commit` and `finalize-run-status` from `requiredChecks` (they are Oscar route-control steps, per the CoBuilder prior-fix). *Optional hardening:* add an explicit "do not commit / finalize a run / record supersession" line to `FORBIDDEN_DECISIONS` in `packages/core/lib/services.mjs`. +2. **Oscar:** rebase this branch onto current `main` (it branched off pre-v0.1-ship main), resolve the `PRIORITIES.md` / `SESSION_LOG.md` / `decisions/README.md` conflicts against `main`'s shipped-v0.1 versions (registering the v0.5 row + ADR-0009 so the **ghost priority + dangling ADR resolve**), get CI green, then **squash-merge** PR #50 (required-linear-history; rebase/merge-commit are disabled on `main`). + +**Phase 2 — reconcile `oz-control-plane-design` (PR #51):** rebase it onto the new `main` (its ADR-0009 citation resolves); bring the **general** orchestration infra (routes / profiles / priority-boundaries / ADR-0012 / `session-wrap.md`) to `main`; **leave the v0.4-specific design** (design tree, ADR-0008/0010, v0.4 spec) on the branch for the v0.4 run. **Do not merge v0.4 wholesale yet.** + +**Phase 3 — adoption + v0.1 close-out:** +- Adoption items 1–3 above (wire into live wrap/teardown flow; prove real `cursor-agent` end-to-end; verify Oz run-detail surfacing). +- **v0.1 carryover:** write **ADR-0011 (v0.1 closeout)** (reserved); run Master **P-R1** (two-workspace concurrency) + **P-R3** (recovery test), or **waive the B/C founder Refines** with rationale; then **archive `v0.1-foundation`** (move to `priorities/zArchive/`, update `PRIORITIES.md` + `zArchive/INDEX.md`) — **founder confirms archival; do not self-archive.** +- **Preventive guard:** add a check that flags ghost priorities (in a route but absent from `PRIORITIES.md`) and dangling ADRs (indexed but file-absent), so this fragmentation can't silently recur. + +**Stop conditions:** a service must NEVER commit, finalize a run, or record supersession; do not merge v0.4 wholesale; do not self-archive v0.1 without founder confirmation. +**Required tests:** core suite stays green (346/346-class on this branch; reconciles with main's count at rebase); `validate-orchestration-services` green; PR #50 CI green before squash-merge. +**Founder decisions on record:** sequenced before v0.4 (2026-05-27); `v0.1.0` already tagged + released; D-S1 + external stranger test removed from v0.1 scope. ## Notes