programmatic subagents
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(s1): Discovery + Config Resolution + Driver Catalog

+1266 -48
+1
bun.lock
··· 25 25 }, 26 26 "dependencies": { 27 27 "@mill/core": "workspace:*", 28 + "@mill/driver-pi": "workspace:*", 28 29 }, 29 30 }, 30 31 "packages/core": {
+160
docs/exec-plans/active/vertical-slices.json
··· 1 + { 2 + "slices": [ 3 + { 4 + "id": "S1", 5 + "title": "Discovery + Config Resolution + Driver Catalog", 6 + "goal": "Ship a reliable authoring/discovery entrypoint so humans and agents can self-serve usage via `mill --help --json`.", 7 + "acceptanceCriteria": [ 8 + "Test intent: unit (payload/config), integration (cli↔core), e2e (`mill --help --json`).", 9 + "`mill --help --json` returns `discoveryVersion: 1` and required fields from SPEC §7 (`programApi`, `drivers`, `authoring`, `async`).", 10 + "Config resolution order follows SPEC §6.1 (cwd, upward walk, `~/.mill/config.ts`, defaults) and is covered by tests.", 11 + "`--json` mode writes machine payload to stdout only; human diagnostics stay on stderr.", 12 + "Driver model list in discovery is sourced via driver codec catalog path, not hardcoded in CLI." 13 + ], 14 + "deliverables": [ 15 + "`packages/core/src/public/discovery.api.ts` plus config loader internals.", 16 + "`packages/cli/src/public/index.api.ts` routing for discovery/help modes.", 17 + "`packages/driver-pi/src/public/index.api.ts` catalog-backed registration surface." 18 + ], 19 + "testCommands": [ 20 + "bun test packages/core/src/public/discovery.api.test.ts", 21 + "bun test packages/cli/src/public/index.api.test.ts", 22 + "bun test packages/driver-pi/src" 23 + ] 24 + }, 25 + { 26 + "id": "S2", 27 + "title": "Sync Run Vertical Path (`run --sync`) with Persisted Tier-1 Events", 28 + "goal": "Enable one complete deterministic execution path from CLI to engine to driver with persisted run artifacts.", 29 + "acceptanceCriteria": [ 30 + "Test intent: unit (schema/decode/store), integration (engine↔driver), e2e (`run --sync`).", 31 + "`mill run <program.ts> --sync --json` executes a program with injected `mill.spawn` and returns structured result.", 32 + "Run directory includes `run.json`, `events.ndjson` (append-only), and `result.json` per SPEC §5.3.", 33 + "Persisted events decode through Schema discriminated union and include `schemaVersion`, `runId`, sequence, and timestamp.", 34 + "`spawn:complete` payload includes non-empty `sessionRef` (SPEC invariant #2)." 35 + ], 36 + "deliverables": [ 37 + "`packages/core/src/domain/*.schema.ts` for run/spawn/event unions.", 38 + "`packages/core/src/internal/run-store.effect.ts` and sync lifecycle in `engine.effect.ts`.", 39 + "CLI handlers for `run --sync` and `status` in `packages/cli`.", 40 + "`packages/driver-pi` codec + process-driver implementation using `Command.make(cmd, ...args)`." 41 + ], 42 + "testCommands": [ 43 + "bun test packages/core/src/domain", 44 + "bun test packages/core/src/internal", 45 + "bun test packages/cli/src", 46 + "bun test packages/driver-pi/src" 47 + ] 48 + }, 49 + { 50 + "id": "S3", 51 + "title": "Wait Semantics + Terminal Single-Shot Invariants", 52 + "goal": "Enforce lifecycle correctness guarantees before detached execution complexity is added.", 53 + "acceptanceCriteria": [ 54 + "Test intent: unit (transition guards), integration (wait over persisted/live events), e2e (`wait` timeout/terminal behavior).", 55 + "`mill wait <runId> --timeout <seconds>` resolves on first terminal event and never permits terminal->non-terminal transitions.", 56 + "Duplicate terminal emissions are deterministically ignored or rejected per documented policy.", 57 + "Exactly one terminal event per run and per spawn is enforced in tests (SPEC §9.6 and invariant #6).", 58 + "Timeout behavior is deterministic and surfaced as typed error/output contract." 59 + ], 60 + "deliverables": [ 61 + "Core lifecycle transition guards and engine `wait` implementation.", 62 + "CLI `wait` command with JSON/non-JSON output parity.", 63 + "Driver-pi test fixtures for malformed or duplicate terminal event sequences." 64 + ], 65 + "testCommands": [ 66 + "bun test packages/core/src/internal", 67 + "bun test packages/cli/src/public", 68 + "bun test packages/driver-pi/src" 69 + ] 70 + }, 71 + { 72 + "id": "S4", 73 + "title": "Async Detached Run Lifecycle", 74 + "goal": "Implement async-by-default submission with private worker process semantics.", 75 + "acceptanceCriteria": [ 76 + "Test intent: integration-heavy plus e2e process lifecycle (`run` submit -> `status`/`wait` completion).", 77 + "`mill run <program.ts> --json` returns immediately with `runId` and running/pending state unless `--sync` is used.", 78 + "Worker command follows private API contract (`mill _worker --run-id ...`) and performs idempotent finalize.", 79 + "Program copy and worker log artifacts are written under run directory (`program.ts`, `logs/worker.log`).", 80 + "`--sync` is implemented as submit + wait composition over the same lifecycle implementation." 81 + ], 82 + "deliverables": [ 83 + "`packages/core/src/runtime/worker.effect.ts` detached worker runtime.", 84 + "CLI async submit wiring and private worker entrypoint.", 85 + "Detached worker integration path exercising `packages/driver-pi`." 86 + ], 87 + "testCommands": [ 88 + "bun test packages/core/src/runtime", 89 + "bun test packages/core/src/internal", 90 + "bun test packages/cli/src/bin", 91 + "bun test packages/driver-pi/src" 92 + ] 93 + }, 94 + { 95 + "id": "S5", 96 + "title": "Driver/Executor Selection + Extension API Injection", 97 + "goal": "Reach configurable runtime composition while preserving strict boundary contracts.", 98 + "acceptanceCriteria": [ 99 + "Test intent: unit (registry/bridge), integration (selected driver/executor path), e2e (`run --driver ... --executor ...`).", 100 + "CLI resolves configured defaults and explicit `--driver/--executor` overrides correctly.", 101 + "Extension `api` methods are injected onto `globalThis.mill` and bridge through `Runtime.runPromise` only at boundary adapters.", 102 + "Extension hook failures emit structured error events without crashing the run by default.", 103 + "Discovery/help metadata reflects registered drivers and authoring guidance from resolved config." 104 + ], 105 + "deliverables": [ 106 + "Core driver/executor registries and extension hook/event plumbing.", 107 + "CLI flag handling for driver/executor selection and `init` skeleton.", 108 + "Public adapters in `driver-claude` and `driver-codex` aligned to generic contracts." 109 + ], 110 + "testCommands": [ 111 + "bun test packages/core/src/public", 112 + "bun test packages/core/src/internal", 113 + "bun test packages/cli/src", 114 + "bun test packages/driver-pi/src packages/driver-claude/src packages/driver-codex/src" 115 + ] 116 + }, 117 + { 118 + "id": "S6", 119 + "title": "Guardrail + ast-grep Boundary Enforcement", 120 + "goal": "Lock architecture constraints so future slices cannot regress Effect and boundary safety.", 121 + "acceptanceCriteria": [ 122 + "Test intent: rule-level unit tests plus integration tests that run guardrail scans from a Bun test harness.", 123 + "Required ast-grep rules from SPEC §19 exist and pass against positive/negative fixtures.", 124 + "Boundary rules enforce Promise/interface location, public->internal import bans, and bridge restrictions (`Runtime.runPromise` boundary-only).", 125 + "Runtime safety rules enforce no shell-string execution, no internal `process.env`/`Date.now`/`Math.random`, and JSON parse only in codec/schema modules.", 126 + "Export boundary check prevents internal path exports from any package." 127 + ], 128 + "deliverables": [ 129 + "Expanded `.ast-grep/rules/*` and `.ast-grep/tests/*` to full required set.", 130 + "Guardrail validation harness callable from Bun tests.", 131 + "`scripts/check-exports.ts` coverage for all workspace packages." 132 + ], 133 + "testCommands": ["bun test .ast-grep/tests", "bun test scripts", "bun test"] 134 + }, 135 + { 136 + "id": "S7", 137 + "title": "Final Hardening: inspect/session/cancel/watch Semantics", 138 + "goal": "Complete observer/control semantics for robust long-running orchestration operations.", 139 + "acceptanceCriteria": [ 140 + "Test intent: integration plus e2e command matrix across inspect/session/cancel/watch with concurrent runs.", 141 + "`watch --json` emits valid JSONL tier-1 events; `watch --raw` streams tier-2 raw passthrough without persistence.", 142 + "`inspect <runId>[.<spawnId>] --json` returns decoded persisted data; `--session` resolves driver-owned session pointer.", 143 + "`cancel <runId>` is interruption-safe, idempotent, and no-op for already terminal runs; emits at most one `run:cancelled` terminal event.", 144 + "`ls` and `status` remain consistent with terminal invariants and persisted snapshots after cancellations/completions." 145 + ], 146 + "deliverables": [ 147 + "Core observer stream + inspect/cancel implementations tied to event log and in-memory fanout.", 148 + "CLI handlers for `watch`, `inspect`, `cancel`, and `ls` with strict JSON stdout contract.", 149 + "Driver session bridge interface plus driver-pi implementation for `inspect --session`." 150 + ], 151 + "testCommands": [ 152 + "bun test packages/core/src/internal", 153 + "bun test packages/core/src/runtime", 154 + "bun test packages/cli/src", 155 + "bun test packages/driver-pi/src", 156 + "bun test" 157 + ] 158 + } 159 + ] 160 + }
+259
docs/exec-plans/active/vertical-slices.md
··· 1 + # Vertical Slices Plan (v0) 2 + 3 + This plan sequences integrated, testable slices from highest-leverage foundation to full v0 behavior. 4 + 5 + Each slice intentionally spans: 6 + 7 + - `@mill/core` (engine/domain/runtime) 8 + - `@mill/cli` (user-facing commands) 9 + - at least one driver package (`@mill/driver-pi`, then multi-driver where relevant) 10 + 11 + --- 12 + 13 + ## S1 — Discovery + Config Resolution + Driver Catalog 14 + 15 + **Goal** 16 + Ship a reliable authoring/discovery entrypoint so humans/agents can self-serve usage via `mill --help --json`. 17 + 18 + **Package span** 19 + 20 + - core: discovery payload builder + config resolution service 21 + - cli: `mill`, `--help`, `--help --json` output adapters 22 + - driver-pi: model catalog surfaced into discovery 23 + 24 + **Acceptance criteria** 25 + 26 + 1. **Test intent:** unit (payload/config), integration (cli↔core), e2e (`mill --help --json`). 27 + 2. `mill --help --json` returns `discoveryVersion: 1` and required fields from SPEC §7 (`programApi`, `drivers`, `authoring`, `async`). 28 + 3. Config resolution order follows SPEC §6.1 (cwd, upward, `~/.mill/config.ts`, defaults) and is covered by tests. 29 + 4. `--json` mode writes machine payload to stdout only; human diagnostics stay on stderr. 30 + 5. Driver model list in discovery is sourced via driver codec catalog path, not hardcoded in CLI. 31 + 32 + **Deliverables** 33 + 34 + - `packages/core/src/public/discovery.api.ts` + config loader internals. 35 + - `packages/cli/src/public/index.api.ts` command routing for discovery modes. 36 + - `packages/driver-pi/src/public/index.api.ts` exposes catalog-backed registration. 37 + 38 + **Test commands** 39 + 40 + - `bun test packages/core/src/public/discovery.api.test.ts` 41 + - `bun test packages/core/src/public/config-loader.api.test.ts` 42 + - `bun test packages/cli/src/public/index.api.test.ts` 43 + - `bun test packages/cli/src/public/index.e2e.test.ts` 44 + - `bun test packages/driver-pi/src` 45 + 46 + **Status (2026-02-23)** 47 + 48 + - ✅ Implemented `resolveConfig` with SPEC §6.1 lookup order: cwd → upward (repo-root bounded) → `~/.mill/config.ts` → defaults. 49 + - ✅ Hardened config resolution to skip upward lookup when no repo root is detected (prevents unrelated parent config capture outside repos). 50 + - ✅ Implemented discovery payload builder with required SPEC §7 fields and `discoveryVersion: 1`. 51 + - ✅ CLI routing now supports `mill`, `--help`, `--help --json` with JSON on stdout and human help on stdout in non-JSON mode (stderr reserved for diagnostics). 52 + - ✅ Driver discovery models flow through driver codec catalog (`driver.codec.modelCatalog`) via `@mill/driver-pi` registration. 53 + - ✅ Added unit/integration/e2e coverage for config resolution, discovery payload, CLI wiring, and `mill --help --json` command path. 54 + - ✅ Extended config-loader tests + implementation to support computed `authoring.instructions` const-expression forms in `mill.config.ts` (not just inline string literals), while preserving SPEC §6.1 resolution order. 55 + - ✅ Re-ran full workspace `bun test` after S1 hardening; suite remains green. 56 + 57 + --- 58 + 59 + ## S2 — Sync Run Vertical Path (`run --sync`) with Persisted Tier-1 Events 60 + 61 + **Goal** 62 + Enable one complete, deterministic execution path from CLI to engine to driver with persisted run artifacts. 63 + 64 + **Package span** 65 + 66 + - core: run/store/event schemas + engine `runSync/submit/status` 67 + - cli: `run --sync`, `status` 68 + - driver-pi: process driver + codec -> tier-1 event mapping 69 + 70 + **Acceptance criteria** 71 + 72 + 1. **Test intent:** unit (schema/decode/store), integration (engine↔driver), e2e (`run --sync`). 73 + 2. `mill run <program.ts> --sync --json` executes a program with injected `mill.spawn` and returns structured result. 74 + 3. Run directory includes `run.json`, `events.ndjson` (append-only), and `result.json` per SPEC §5.3. 75 + 4. Persisted events decode through Schema discriminated union and include `schemaVersion`, `runId`, sequence, timestamp. 76 + 5. `spawn:complete` payload includes non-empty `sessionRef` (SPEC invariant #2). 77 + 78 + **Deliverables** 79 + 80 + - `packages/core/src/domain/*.schema.ts` for run/spawn/event unions. 81 + - `packages/core/src/internal/run-store.effect.ts`, `engine.effect.ts` sync lifecycle. 82 + - `packages/cli` command handlers for `run --sync` and `status` JSON/human output. 83 + - `packages/driver-pi` codec + process-driver implementation using `Command.make(cmd, ...args)`. 84 + 85 + **Test commands** 86 + 87 + - `bun test packages/core/src/domain` 88 + - `bun test packages/core/src/internal` 89 + - `bun test packages/cli/src` 90 + - `bun test packages/driver-pi/src` 91 + 92 + --- 93 + 94 + ## S3 — Wait Semantics + Terminal Single-Shot Invariants 95 + 96 + **Goal** 97 + Enforce lifecycle correctness guarantees before detached execution complexity is added. 98 + 99 + **Package span** 100 + 101 + - core: state machine guards + terminal-event idempotence/rejection + `wait` 102 + - cli: `wait <runId> --timeout <seconds>` 103 + - driver-pi: deterministic fixture stream to simulate duplicate/late terminal events 104 + 105 + **Acceptance criteria** 106 + 107 + 1. **Test intent:** unit (transition guards), integration (wait over persisted/live events), e2e (`wait` timeout/terminal behavior). 108 + 2. `wait` resolves on first terminal event and never transitions terminal->non-terminal afterward. 109 + 3. Duplicate terminal emissions are deterministically ignored or rejected per documented policy. 110 + 4. Exactly one terminal event per run and per spawn is enforced in tests (SPEC §9.6 + invariant #6). 111 + 5. Timeout behavior is deterministic and surfaced as typed error/output contract. 112 + 113 + **Deliverables** 114 + 115 + - Core lifecycle transition guard module + engine wait implementation. 116 + - CLI `wait` command with JSON/non-JSON output parity. 117 + - Driver test fixtures for malformed or duplicate terminal event sequences. 118 + 119 + **Test commands** 120 + 121 + - `bun test packages/core/src/internal` 122 + - `bun test packages/cli/src/public` 123 + - `bun test packages/driver-pi/src` 124 + 125 + --- 126 + 127 + ## S4 — Async Detached Run Lifecycle (Dedicated) 128 + 129 + **Goal** 130 + Implement async-by-default submission with private worker process semantics. 131 + 132 + **Package span** 133 + 134 + - core/runtime: worker orchestration, submit metadata, status updates 135 + - cli: `run` (default async), `_worker` private command path, `status` 136 + - driver-pi: exercised in worker-executed program spawns 137 + 138 + **Acceptance criteria** 139 + 140 + 1. **Test intent:** integration-heavy + e2e process lifecycle (`run` submit -> `status`/`wait` completion). 141 + 2. `mill run <program.ts> --json` returns immediately with `runId` and running/pending state unless `--sync` is used. 142 + 3. Worker command follows private API contract (`mill _worker --run-id ...`) and performs idempotent finalize. 143 + 4. Program copy and worker log artifacts are written under run directory (`program.ts`, `logs/worker.log`). 144 + 5. `--sync` is implemented as submit + wait composition, sharing lifecycle logic. 145 + 146 + **Deliverables** 147 + 148 + - `packages/core/src/runtime/worker.effect.ts` detached worker runtime. 149 + - CLI wiring for async submit path and private worker entrypoint. 150 + - Driver-pi execution exercised by detached worker integration tests. 151 + 152 + **Test commands** 153 + 154 + - `bun test packages/core/src/runtime` 155 + - `bun test packages/core/src/internal` 156 + - `bun test packages/cli/src/bin` 157 + - `bun test packages/driver-pi/src` 158 + 159 + --- 160 + 161 + ## S5 — Driver/Executor Selection + Extension API Injection 162 + 163 + **Goal** 164 + Reach configurable runtime composition while preserving strict boundary contracts. 165 + 166 + **Package span** 167 + 168 + - core: driver/executor registries, extension hooks, runtime API injection bridge 169 + - cli: `--driver`, `--executor`, `init` baseline scaffolding path 170 + - drivers: pi + claude + codex package registration surfaces 171 + 172 + **Acceptance criteria** 173 + 174 + 1. **Test intent:** unit (registry/bridge), integration (selected driver/executor path), e2e (`run --driver ... --executor ...`). 175 + 2. CLI resolves configured defaults and explicit `--driver/--executor` overrides correctly. 176 + 3. Extension `api` methods are injected onto `globalThis.mill` and bridge via `Runtime.runPromise` only at boundary adapters. 177 + 4. Extension hook failures emit structured error events without crashing the run by default. 178 + 5. Discovery/help metadata reflects registered drivers and authoring guidance from resolved config. 179 + 180 + **Deliverables** 181 + 182 + - Core registries + extension hook/event plumbing. 183 + - CLI flag handling for driver/executor selection and `init` skeleton. 184 + - Public adapters in `driver-claude` and `driver-codex` aligned to generic contracts (no vendor logic in core). 185 + 186 + **Test commands** 187 + 188 + - `bun test packages/core/src/public` 189 + - `bun test packages/core/src/internal` 190 + - `bun test packages/cli/src` 191 + - `bun test packages/driver-pi/src packages/driver-claude/src packages/driver-codex/src` 192 + 193 + --- 194 + 195 + ## S6 — Guardrail + ast-grep Boundary Enforcement (Dedicated) 196 + 197 + **Goal** 198 + Lock architecture constraints so future slices cannot regress Effect/boundary safety. 199 + 200 + **Package span** 201 + 202 + - core: boundary-compliant module names/contracts validated by rules 203 + - cli: boundary adapter checks + no internal imports from public API 204 + - driver packages: process safety and decode/env/time/random constraints validated across at least driver-pi 205 + 206 + **Acceptance criteria** 207 + 208 + 1. **Test intent:** rule-level unit tests + integration tests that run guardrail scans from Bun test harness. 209 + 2. Required ast-grep rules from SPEC §19 exist and pass against positive/negative fixtures. 210 + 3. Boundary rules enforce: Promise/interface only in public boundary, no public->internal imports, bridge restrictions (`Runtime.runPromise` only at boundary). 211 + 4. Runtime safety rules enforce: no shell-string execution, no internal `process.env`/`Date.now`/`Math.random`, JSON parse only in codec/schema modules. 212 + 5. Export boundary check prevents internal path exports from any package. 213 + 214 + **Deliverables** 215 + 216 + - `.ast-grep/rules/*`, `.ast-grep/tests/*` expanded to full required set. 217 + - Guardrail validation test harness invoked from Bun tests. 218 + - `scripts/check-exports.ts` coverage across all workspace packages. 219 + 220 + **Test commands** 221 + 222 + - `bun test .ast-grep/tests` 223 + - `bun test scripts` 224 + - `bun test` 225 + 226 + --- 227 + 228 + ## S7 — Final Hardening: inspect/session/cancel/watch Semantics (Dedicated Final Slice) 229 + 230 + **Goal** 231 + Complete observer/control semantics for robust long-running orchestration operations. 232 + 233 + **Package span** 234 + 235 + - core: `watch`, `inspect`, `cancel`, session-ref resolution, interruption-safe cancellation 236 + - cli: `watch`, `inspect [--session]`, `cancel`, `ls` 237 + - driver-pi (and optionally others): `sessionRef` opener/locator bridge for `inspect --session` 238 + 239 + **Acceptance criteria** 240 + 241 + 1. **Test intent:** integration + e2e command matrix across inspect/session/cancel/watch with concurrent runs. 242 + 2. `watch --json` emits valid JSONL tier-1 events; `watch --raw` streams tier-2 raw passthrough without persistence. 243 + 3. `inspect <runId>[.<spawnId>] --json` returns decoded persisted data; `--session` resolves driver-owned session pointer. 244 + 4. `cancel <runId>` is interruption-safe, idempotent, and no-op for already terminal runs; emits at most one `run:cancelled` terminal event. 245 + 5. `ls`/`status` remain consistent with terminal invariants and persisted snapshots after cancellations/completions. 246 + 247 + **Deliverables** 248 + 249 + - Core observer stream and inspect/cancel implementations tied to event log + in-memory fanout. 250 + - CLI command handlers for `watch`, `inspect`, `cancel`, `ls` with strict JSON stdout contract. 251 + - Driver session bridge interface + driver-pi implementation for session lookup/open. 252 + 253 + **Test commands** 254 + 255 + - `bun test packages/core/src/internal` 256 + - `bun test packages/core/src/runtime` 257 + - `bun test packages/cli/src` 258 + - `bun test packages/driver-pi/src` 259 + - `bun test`
+2 -1
packages/cli/package.json
··· 9 9 ".": "./src/public/index.api.ts" 10 10 }, 11 11 "dependencies": { 12 - "@mill/core": "workspace:*" 12 + "@mill/core": "workspace:*", 13 + "@mill/driver-pi": "workspace:*" 13 14 } 14 15 }
+78 -6
packages/cli/src/public/index.api.test.ts
··· 1 - import { describe, expect, it, spyOn } from "bun:test"; 1 + import { describe, expect, it } from "bun:test"; 2 + import * as Schema from "@effect/schema/Schema"; 2 3 import { runCli } from "./index.api"; 3 4 5 + const DiscoveryEnvelope = Schema.parseJson( 6 + Schema.Struct({ 7 + discoveryVersion: Schema.Number, 8 + programApi: Schema.Struct({ 9 + spawnRequired: Schema.Array(Schema.String), 10 + spawnOptional: Schema.Array(Schema.String), 11 + resultFields: Schema.Array(Schema.String), 12 + }), 13 + drivers: Schema.Record({ 14 + key: Schema.String, 15 + value: Schema.Struct({ 16 + description: Schema.String, 17 + modelFormat: Schema.String, 18 + models: Schema.Array(Schema.String), 19 + }), 20 + }), 21 + authoring: Schema.Struct({ 22 + instructions: Schema.String, 23 + }), 24 + async: Schema.Struct({ 25 + submit: Schema.String, 26 + status: Schema.String, 27 + wait: Schema.String, 28 + }), 29 + }), 30 + ); 31 + 4 32 describe("runCli", () => { 5 - it("prints discovery help in json mode", async () => { 6 - const logSpy = spyOn(console, "log").mockImplementation(() => {}); 33 + it("writes machine payload to stdout only in --json mode", async () => { 34 + const stdout: Array<string> = []; 35 + const stderr: Array<string> = []; 7 36 8 - const code = await runCli(["--help", "--json"]); 37 + const code = await runCli(["--help", "--json"], { 38 + cwd: "/workspace/repo", 39 + homeDirectory: "/Users/tester", 40 + pathExists: async () => false, 41 + io: { 42 + stdout: (line) => { 43 + stdout.push(line); 44 + }, 45 + stderr: (line) => { 46 + stderr.push(line); 47 + }, 48 + }, 49 + }); 9 50 10 51 expect(code).toBe(0); 11 - expect(logSpy).toHaveBeenCalledTimes(1); 52 + expect(stdout).toHaveLength(1); 53 + expect(stderr).toHaveLength(0); 54 + 55 + const payload = Schema.decodeUnknownSync(DiscoveryEnvelope)(stdout[0]); 56 + expect(payload.discoveryVersion).toBe(1); 57 + expect(payload.drivers.default?.models).toEqual([ 58 + "openai/gpt-5.3-codex", 59 + "anthropic/claude-sonnet-4-6", 60 + ]); 61 + expect(payload.programApi.spawnRequired).toEqual(["agent", "systemPrompt", "prompt"]); 62 + }); 12 63 13 - logSpy.mockRestore(); 64 + it("routes human help text to stdout in non-json mode", async () => { 65 + const stdout: Array<string> = []; 66 + const stderr: Array<string> = []; 67 + 68 + const code = await runCli(["--help"], { 69 + cwd: "/workspace/repo", 70 + homeDirectory: "/Users/tester", 71 + pathExists: async () => false, 72 + io: { 73 + stdout: (line) => { 74 + stdout.push(line); 75 + }, 76 + stderr: (line) => { 77 + stderr.push(line); 78 + }, 79 + }, 80 + }); 81 + 82 + expect(code).toBe(0); 83 + expect(stdout).toHaveLength(1); 84 + expect(stderr).toHaveLength(0); 85 + expect(stdout[0]).toContain("mill — Effect-first orchestration runtime"); 14 86 }); 15 87 });
+69 -12
packages/cli/src/public/index.api.ts
··· 1 - import { createDiscoveryPayload } from "@mill/core"; 1 + import { 2 + createDiscoveryPayload, 3 + defineConfig, 4 + processDriver, 5 + type ConfigOverrides, 6 + } from "@mill/core"; 7 + import { createPiDriverRegistration } from "@mill/driver-pi"; 8 + 9 + interface CliIo { 10 + readonly stdout: (line: string) => void; 11 + readonly stderr: (line: string) => void; 12 + } 13 + 14 + interface RunCliOptions { 15 + readonly cwd?: string; 16 + readonly homeDirectory?: string; 17 + readonly pathExists?: (path: string) => Promise<boolean>; 18 + readonly loadConfigOverrides?: (path: string) => Promise<ConfigOverrides>; 19 + readonly io?: CliIo; 20 + } 21 + 22 + const defaultIo: CliIo = { 23 + stdout: (line) => { 24 + console.log(line); 25 + }, 26 + stderr: (line) => { 27 + console.error(line); 28 + }, 29 + }; 30 + 31 + const defaultConfig = defineConfig({ 32 + defaultDriver: "default", 33 + defaultModel: "openai/gpt-5.3-codex", 34 + drivers: { 35 + default: processDriver(createPiDriverRegistration()), 36 + }, 37 + authoring: { 38 + instructions: 39 + "Use systemPrompt for WHO and prompt for WHAT. Prefer cheaper models for search and stronger models for synthesis.", 40 + }, 41 + }); 2 42 3 - export const runCli = async (argv: ReadonlyArray<string>): Promise<number> => { 43 + export const runCli = async ( 44 + argv: ReadonlyArray<string>, 45 + options?: RunCliOptions, 46 + ): Promise<number> => { 47 + const io = options?.io ?? defaultIo; 4 48 const showHelp = argv.length === 0 || argv.includes("--help"); 5 49 6 50 if (showHelp) { 7 - const payload = await createDiscoveryPayload(); 8 - const output = argv.includes("--json") 9 - ? JSON.stringify(payload) 10 - : [ 11 - "mill — Effect-first orchestration runtime", 12 - "", 13 - "Run `mill --help --json` for machine-readable discovery.", 14 - ].join("\n"); 15 - console.log(output); 51 + const payload = await createDiscoveryPayload({ 52 + defaults: defaultConfig, 53 + cwd: options?.cwd, 54 + homeDirectory: options?.homeDirectory, 55 + pathExists: options?.pathExists, 56 + loadConfigOverrides: options?.loadConfigOverrides, 57 + }); 58 + 59 + if (argv.includes("--json")) { 60 + io.stdout(JSON.stringify(payload)); 61 + return 0; 62 + } 63 + 64 + io.stdout( 65 + [ 66 + "mill — Effect-first orchestration runtime", 67 + "", 68 + `Authoring guidance: ${payload.authoring.instructions}`, 69 + "", 70 + "Run `mill --help --json` for machine-readable discovery.", 71 + ].join("\n"), 72 + ); 16 73 return 0; 17 74 } 18 75 19 - console.log("v0 scaffold: only help/discovery is wired in this foundation stage."); 76 + io.stderr("v0 scaffold: only help/discovery is wired in this foundation stage."); 20 77 return 0; 21 78 };
+57
packages/cli/src/public/index.e2e.test.ts
··· 1 + import { describe, expect, it } from "bun:test"; 2 + import * as Command from "@effect/platform/Command"; 3 + import * as Schema from "@effect/schema/Schema"; 4 + import * as BunContext from "@effect/platform-bun/BunContext"; 5 + import { Effect, Runtime } from "effect"; 6 + 7 + const runtime = Runtime.defaultRuntime; 8 + 9 + const DiscoveryEnvelope = Schema.parseJson( 10 + Schema.Struct({ 11 + discoveryVersion: Schema.Number, 12 + programApi: Schema.Struct({ 13 + spawnRequired: Schema.Array(Schema.String), 14 + spawnOptional: Schema.Array(Schema.String), 15 + resultFields: Schema.Array(Schema.String), 16 + }), 17 + drivers: Schema.Record({ 18 + key: Schema.String, 19 + value: Schema.Struct({ 20 + description: Schema.String, 21 + modelFormat: Schema.String, 22 + models: Schema.Array(Schema.String), 23 + }), 24 + }), 25 + authoring: Schema.Struct({ 26 + instructions: Schema.String, 27 + }), 28 + async: Schema.Struct({ 29 + submit: Schema.String, 30 + status: Schema.String, 31 + wait: Schema.String, 32 + }), 33 + }), 34 + ); 35 + 36 + describe("mill --help --json (e2e)", () => { 37 + it("returns discovery contract payload on stdout", async () => { 38 + const output = await Runtime.runPromise(runtime)( 39 + Effect.provide( 40 + Command.string( 41 + Command.make("bun", "run", "packages/cli/src/bin/mill.ts", "--help", "--json"), 42 + ), 43 + BunContext.layer, 44 + ), 45 + ); 46 + 47 + const payload = Schema.decodeUnknownSync(DiscoveryEnvelope)(output); 48 + expect(payload.discoveryVersion).toBe(1); 49 + expect(payload.programApi.spawnRequired).toEqual(["agent", "systemPrompt", "prompt"]); 50 + expect(payload.drivers.default?.models).toEqual([ 51 + "openai/gpt-5.3-codex", 52 + "anthropic/claude-sonnet-4-6", 53 + ]); 54 + expect(payload.authoring.instructions.length).toBeGreaterThan(0); 55 + expect(payload.async.submit).toBe("mill run <program.ts> --json"); 56 + }); 57 + });
+187
packages/core/src/public/config-loader.api.test.ts
··· 1 + import { describe, expect, it } from "bun:test"; 2 + import { mkdtemp, rm, writeFile } from "node:fs/promises"; 3 + import { tmpdir } from "node:os"; 4 + import { join } from "node:path"; 5 + import { Effect } from "effect"; 6 + import { resolveConfig } from "./config-loader.api"; 7 + import type { MillConfig } from "./types"; 8 + 9 + const makeDefaults = (): MillConfig => ({ 10 + defaultDriver: "default", 11 + defaultModel: "openai/gpt-5.3-codex", 12 + drivers: { 13 + default: { 14 + description: "Catalog-backed test driver", 15 + modelFormat: "provider/model-id", 16 + process: { 17 + command: "pi", 18 + args: ["-p"], 19 + env: {}, 20 + }, 21 + codec: { 22 + modelCatalog: Effect.succeed(["provider/model-a"]), 23 + }, 24 + }, 25 + }, 26 + authoring: { 27 + instructions: "from-defaults", 28 + }, 29 + }); 30 + 31 + describe("resolveConfig", () => { 32 + it("prefers ./mill.config.ts from cwd", async () => { 33 + const resolved = await resolveConfig({ 34 + defaults: makeDefaults(), 35 + cwd: "/workspace/repo/app", 36 + homeDirectory: "/Users/tester", 37 + pathExists: async (path) => path === "/workspace/repo/app/mill.config.ts", 38 + loadConfigOverrides: async (path) => ({ 39 + authoringInstructions: `loaded:${path}`, 40 + }), 41 + }); 42 + 43 + expect(resolved.source).toBe("cwd"); 44 + expect(resolved.configPath).toBe("/workspace/repo/app/mill.config.ts"); 45 + expect(resolved.config.authoring.instructions).toBe( 46 + "loaded:/workspace/repo/app/mill.config.ts", 47 + ); 48 + }); 49 + 50 + it("walks upward to repo root when cwd config is missing", async () => { 51 + const resolved = await resolveConfig({ 52 + defaults: makeDefaults(), 53 + cwd: "/workspace/repo/packages/cli", 54 + homeDirectory: "/Users/tester", 55 + pathExists: async (path) => 56 + path === "/workspace/repo/.jj" || path === "/workspace/repo/mill.config.ts", 57 + loadConfigOverrides: async (path) => ({ 58 + authoringInstructions: `loaded:${path}`, 59 + }), 60 + }); 61 + 62 + expect(resolved.source).toBe("upward"); 63 + expect(resolved.configPath).toBe("/workspace/repo/mill.config.ts"); 64 + expect(resolved.config.authoring.instructions).toBe("loaded:/workspace/repo/mill.config.ts"); 65 + }); 66 + 67 + it("uses ~/.mill/config.ts when no project config is found", async () => { 68 + const resolved = await resolveConfig({ 69 + defaults: makeDefaults(), 70 + cwd: "/workspace/repo/packages/cli", 71 + homeDirectory: "/Users/tester", 72 + pathExists: async (path) => path === "/Users/tester/.mill/config.ts", 73 + loadConfigOverrides: async (path) => ({ 74 + authoringInstructions: `loaded:${path}`, 75 + }), 76 + }); 77 + 78 + expect(resolved.source).toBe("home"); 79 + expect(resolved.configPath).toBe("/Users/tester/.mill/config.ts"); 80 + expect(resolved.config.authoring.instructions).toBe("loaded:/Users/tester/.mill/config.ts"); 81 + }); 82 + 83 + it("falls back to internal defaults", async () => { 84 + const resolved = await resolveConfig({ 85 + defaults: makeDefaults(), 86 + cwd: "/workspace/repo/packages/cli", 87 + homeDirectory: "/Users/tester", 88 + pathExists: async () => false, 89 + }); 90 + 91 + expect(resolved.source).toBe("defaults"); 92 + expect(resolved.configPath).toBeUndefined(); 93 + expect(resolved.config.authoring.instructions).toBe("from-defaults"); 94 + }); 95 + 96 + it("stops upward search at repo root", async () => { 97 + const resolved = await resolveConfig({ 98 + defaults: makeDefaults(), 99 + cwd: "/workspace/repo/packages/cli", 100 + homeDirectory: "/Users/tester", 101 + pathExists: async (path) => 102 + path === "/workspace/repo/.jj" || 103 + path === "/workspace/mill.config.ts" || 104 + path === "/Users/tester/.mill/config.ts", 105 + loadConfigOverrides: async (path) => ({ 106 + authoringInstructions: `loaded:${path}`, 107 + }), 108 + }); 109 + 110 + expect(resolved.source).toBe("home"); 111 + expect(resolved.configPath).toBe("/Users/tester/.mill/config.ts"); 112 + expect(resolved.config.authoring.instructions).toBe("loaded:/Users/tester/.mill/config.ts"); 113 + }); 114 + 115 + it("does not search above repo root when cwd is repo root", async () => { 116 + const resolved = await resolveConfig({ 117 + defaults: makeDefaults(), 118 + cwd: "/workspace/repo", 119 + homeDirectory: "/Users/tester", 120 + pathExists: async (path) => 121 + path === "/workspace/repo/.jj" || 122 + path === "/workspace/mill.config.ts" || 123 + path === "/Users/tester/.mill/config.ts", 124 + loadConfigOverrides: async (path) => ({ 125 + authoringInstructions: `loaded:${path}`, 126 + }), 127 + }); 128 + 129 + expect(resolved.source).toBe("home"); 130 + expect(resolved.configPath).toBe("/Users/tester/.mill/config.ts"); 131 + }); 132 + 133 + it("skips upward config lookup when cwd is outside a repo", async () => { 134 + const resolved = await resolveConfig({ 135 + defaults: makeDefaults(), 136 + cwd: "/scratch/playground/app", 137 + homeDirectory: "/Users/tester", 138 + pathExists: async (path) => 139 + path === "/scratch/mill.config.ts" || path === "/Users/tester/.mill/config.ts", 140 + loadConfigOverrides: async (path) => ({ 141 + authoringInstructions: `loaded:${path}`, 142 + }), 143 + }); 144 + 145 + expect(resolved.source).toBe("home"); 146 + expect(resolved.configPath).toBe("/Users/tester/.mill/config.ts"); 147 + expect(resolved.config.authoring.instructions).toBe("loaded:/Users/tester/.mill/config.ts"); 148 + }); 149 + 150 + it("loads computed overrides from mill.config.ts const expressions", async () => { 151 + const tempDirectory = await mkdtemp(join(tmpdir(), "mill-config-loader-")); 152 + const configPath = join(tempDirectory, "mill.config.ts"); 153 + 154 + await writeFile( 155 + configPath, 156 + [ 157 + 'const instructions = [`Use systemPrompt for WHO.`, `Use prompt for WHAT.`].join(" ");', 158 + "export default {", 159 + ' defaultDriver: "pi-local" as const,', 160 + ' defaultModel: "openai/gpt-5.3-codex" as const,', 161 + " authoring: {", 162 + " instructions,", 163 + " },", 164 + "};", 165 + ].join("\n"), 166 + "utf-8", 167 + ); 168 + 169 + try { 170 + const resolved = await resolveConfig({ 171 + defaults: makeDefaults(), 172 + cwd: tempDirectory, 173 + homeDirectory: join(tempDirectory, "missing-home"), 174 + }); 175 + 176 + expect(resolved.source).toBe("cwd"); 177 + expect(resolved.configPath).toBe(configPath); 178 + expect(resolved.config.defaultDriver).toBe("pi-local"); 179 + expect(resolved.config.defaultModel).toBe("openai/gpt-5.3-codex"); 180 + expect(resolved.config.authoring.instructions).toBe( 181 + "Use systemPrompt for WHO. Use prompt for WHAT.", 182 + ); 183 + } finally { 184 + await rm(tempDirectory, { recursive: true, force: true }); 185 + } 186 + }); 187 + });
+251
packages/core/src/public/config-loader.api.ts
··· 1 + import * as FileSystem from "@effect/platform/FileSystem"; 2 + import * as BunContext from "@effect/platform-bun/BunContext"; 3 + import { Effect, Runtime } from "effect"; 4 + import type { 5 + ConfigOverrides, 6 + DriverRegistration, 7 + MillConfig, 8 + ResolvedConfig, 9 + ResolveConfigOptions, 10 + } from "./types"; 11 + 12 + const runtime = Runtime.defaultRuntime; 13 + 14 + const CONFIG_FILE_NAME = "mill.config.ts"; 15 + const HOME_CONFIG_PATH = ".mill/config.ts"; 16 + 17 + const runWithBunContext = <A, E>(effect: Effect.Effect<A, E, BunContext.BunContext>): Promise<A> => 18 + Runtime.runPromise(runtime)(Effect.provide(effect, BunContext.layer)); 19 + 20 + const defaultPathExists = async (path: string): Promise<boolean> => 21 + runWithBunContext(Effect.flatMap(FileSystem.FileSystem, (fileSystem) => fileSystem.exists(path))); 22 + 23 + const extractConfigString = (source: string, key: string): string | undefined => { 24 + const match = new RegExp(`${key}\\s*:\\s*["']([^"'\\n]+)["']`).exec(source); 25 + return match?.[1]; 26 + }; 27 + 28 + const extractConstStringValue = (source: string, identifier: string): string | undefined => { 29 + const escapedIdentifier = identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 30 + const directStringMatch = new RegExp( 31 + `const\\s+${escapedIdentifier}\\s*=\\s*(["'\\"])(([\\s\\S]*?))\\1\\s*;?`, 32 + ).exec(source); 33 + 34 + if (directStringMatch !== null) { 35 + return directStringMatch[2]; 36 + } 37 + 38 + const joinedArrayMatch = new RegExp( 39 + `const\\s+${escapedIdentifier}\\s*=\\s*\\[([\\s\\S]*?)\\]\\.join\\((["'])((?:[\\s\\S]*?))\\2\\)\\s*;?`, 40 + ).exec(source); 41 + 42 + if (joinedArrayMatch === null) { 43 + return undefined; 44 + } 45 + 46 + const values = Array.from(joinedArrayMatch[1].matchAll(/["'`]([^"'`]+)["'`]/g)).map( 47 + (match) => match[1], 48 + ); 49 + 50 + if (values.length === 0) { 51 + return undefined; 52 + } 53 + 54 + return values.join(joinedArrayMatch[3]); 55 + }; 56 + 57 + const extractAuthoringInstructions = (source: string): string | undefined => { 58 + const directInstructions = extractConfigString(source, "instructions"); 59 + 60 + if (directInstructions !== undefined) { 61 + return directInstructions; 62 + } 63 + 64 + const authoringBlockMatch = /authoring\s*:\s*\{([\s\S]*?)\}/.exec(source); 65 + 66 + if (authoringBlockMatch === null) { 67 + return undefined; 68 + } 69 + 70 + const authoringBlock = authoringBlockMatch[1]; 71 + const explicitIdentifierMatch = /instructions\s*:\s*([A-Za-z_$][\w$]*)/.exec(authoringBlock); 72 + 73 + if (explicitIdentifierMatch !== null) { 74 + return extractConstStringValue(source, explicitIdentifierMatch[1]); 75 + } 76 + 77 + const hasShorthandInstructions = /\binstructions\b\s*(?:,|$)/.test(authoringBlock); 78 + 79 + if (!hasShorthandInstructions) { 80 + return undefined; 81 + } 82 + 83 + return extractConstStringValue(source, "instructions"); 84 + }; 85 + 86 + const parseConfigOverridesFromText = (source: string): ConfigOverrides => ({ 87 + defaultDriver: extractConfigString(source, "defaultDriver"), 88 + defaultModel: extractConfigString(source, "defaultModel"), 89 + authoringInstructions: extractAuthoringInstructions(source), 90 + }); 91 + 92 + const readConfigSource = async (path: string): Promise<string> => 93 + runWithBunContext( 94 + Effect.catchAll( 95 + Effect.flatMap(FileSystem.FileSystem, (fileSystem) => 96 + fileSystem.readFileString(path, "utf-8"), 97 + ), 98 + () => Effect.succeed(""), 99 + ), 100 + ); 101 + 102 + const defaultLoadConfigOverrides = async (path: string): Promise<ConfigOverrides> => { 103 + const source = await readConfigSource(path); 104 + 105 + return parseConfigOverridesFromText(source); 106 + }; 107 + 108 + const normalizePath = (path: string): string => { 109 + if (path.length <= 1) { 110 + return path; 111 + } 112 + return path.endsWith("/") ? path.slice(0, -1) : path; 113 + }; 114 + 115 + const dirname = (path: string): string => { 116 + const normalized = normalizePath(path); 117 + 118 + if (normalized === "/") { 119 + return "/"; 120 + } 121 + 122 + const index = normalized.lastIndexOf("/"); 123 + 124 + if (index <= 0) { 125 + return "/"; 126 + } 127 + 128 + return normalized.slice(0, index); 129 + }; 130 + 131 + const joinPath = (base: string, child: string): string => 132 + normalizePath(base) === "/" ? `/${child}` : `${normalizePath(base)}/${child}`; 133 + 134 + const findRepoRoot = async ( 135 + startDirectory: string, 136 + pathExists: (path: string) => Promise<boolean>, 137 + ): Promise<string | undefined> => { 138 + let current = normalizePath(startDirectory); 139 + 140 + while (true) { 141 + const isJjRepoRoot = await pathExists(joinPath(current, ".jj")); 142 + const isGitRepoRoot = await pathExists(joinPath(current, ".git")); 143 + 144 + if (isJjRepoRoot || isGitRepoRoot) { 145 + return current; 146 + } 147 + 148 + const parent = dirname(current); 149 + 150 + if (parent === current) { 151 + return undefined; 152 + } 153 + 154 + current = parent; 155 + } 156 + }; 157 + 158 + const mergeConfig = (defaults: MillConfig, overrides: ConfigOverrides): MillConfig => ({ 159 + ...defaults, 160 + defaultDriver: overrides.defaultDriver ?? defaults.defaultDriver, 161 + defaultModel: overrides.defaultModel ?? defaults.defaultModel, 162 + authoring: { 163 + instructions: overrides.authoringInstructions ?? defaults.authoring.instructions, 164 + }, 165 + }); 166 + 167 + const resolveConfigPath = async ( 168 + cwd: string, 169 + homeDirectory: string | undefined, 170 + pathExists: (path: string) => Promise<boolean>, 171 + ): Promise<{ source: "cwd" | "upward" | "home"; path: string } | undefined> => { 172 + const normalizedCwd = normalizePath(cwd); 173 + const cwdConfig = joinPath(normalizedCwd, CONFIG_FILE_NAME); 174 + 175 + if (await pathExists(cwdConfig)) { 176 + return { 177 + source: "cwd", 178 + path: cwdConfig, 179 + }; 180 + } 181 + 182 + const repoRoot = await findRepoRoot(normalizedCwd, pathExists); 183 + 184 + if (repoRoot !== undefined) { 185 + let current = repoRoot === normalizedCwd ? normalizedCwd : dirname(normalizedCwd); 186 + 187 + while (current !== normalizedCwd) { 188 + const candidate = joinPath(current, CONFIG_FILE_NAME); 189 + 190 + if (await pathExists(candidate)) { 191 + return { 192 + source: "upward", 193 + path: candidate, 194 + }; 195 + } 196 + 197 + if (current === repoRoot) { 198 + break; 199 + } 200 + 201 + const parent = dirname(current); 202 + 203 + if (parent === current) { 204 + break; 205 + } 206 + 207 + current = parent; 208 + } 209 + } 210 + 211 + if (homeDirectory !== undefined && homeDirectory.length > 0) { 212 + const homeConfig = joinPath(homeDirectory, HOME_CONFIG_PATH); 213 + 214 + if (await pathExists(homeConfig)) { 215 + return { 216 + source: "home", 217 + path: homeConfig, 218 + }; 219 + } 220 + } 221 + 222 + return undefined; 223 + }; 224 + 225 + export const defineConfig = <T extends MillConfig>(config: T): T => config; 226 + 227 + export const processDriver = <T extends DriverRegistration>(driver: T): T => driver; 228 + 229 + export const resolveConfig = async (options: ResolveConfigOptions): Promise<ResolvedConfig> => { 230 + const cwd = options.cwd ?? process.cwd(); 231 + const homeDirectory = options.homeDirectory ?? process.env.HOME; 232 + const pathExists = options.pathExists ?? defaultPathExists; 233 + const loadConfigOverrides = options.loadConfigOverrides ?? defaultLoadConfigOverrides; 234 + 235 + const resolvedPath = await resolveConfigPath(cwd, homeDirectory, pathExists); 236 + 237 + if (resolvedPath === undefined) { 238 + return { 239 + source: "defaults", 240 + config: options.defaults, 241 + }; 242 + } 243 + 244 + const overrides = await loadConfigOverrides(resolvedPath.path); 245 + 246 + return { 247 + source: resolvedPath.source, 248 + configPath: resolvedPath.path, 249 + config: mergeConfig(options.defaults, overrides), 250 + }; 251 + };
+73 -4
packages/core/src/public/discovery.api.test.ts
··· 1 1 import { describe, expect, it } from "bun:test"; 2 + import { Effect } from "effect"; 2 3 import { createDiscoveryPayload } from "./discovery.api"; 4 + import type { MillConfig } from "./types"; 5 + 6 + const makeDefaults = (): MillConfig => ({ 7 + defaultDriver: "default", 8 + defaultModel: "openai/gpt-5.3-codex", 9 + drivers: { 10 + default: { 11 + description: "Catalog-backed test driver", 12 + modelFormat: "provider/model-id", 13 + process: { 14 + command: "pi", 15 + args: ["-p"], 16 + env: {}, 17 + }, 18 + codec: { 19 + modelCatalog: Effect.succeed(["provider/model-a", "provider/model-b"]), 20 + }, 21 + }, 22 + }, 23 + authoring: { 24 + instructions: "from-defaults", 25 + }, 26 + }); 3 27 4 28 describe("createDiscoveryPayload", () => { 5 - it("returns discovery contract v1", async () => { 6 - const payload = await createDiscoveryPayload(); 29 + it("returns discovery contract v1 with required SPEC §7 fields", async () => { 30 + const payload = await createDiscoveryPayload({ 31 + defaults: makeDefaults(), 32 + cwd: "/repo", 33 + homeDirectory: "/home/tester", 34 + pathExists: async () => false, 35 + }); 7 36 8 37 expect(payload.discoveryVersion).toBe(1); 9 - expect(payload.async.submit).toContain("mill run"); 10 - expect(payload.programApi.spawnRequired).toContain("agent"); 38 + expect(payload.programApi.spawnRequired).toEqual(["agent", "systemPrompt", "prompt"]); 39 + expect(payload.programApi.spawnOptional).toEqual(["model"]); 40 + expect(payload.programApi.resultFields).toEqual([ 41 + "text", 42 + "sessionRef", 43 + "agent", 44 + "model", 45 + "driver", 46 + "exitCode", 47 + "stopReason", 48 + ]); 49 + expect(payload.authoring.instructions).toBe("from-defaults"); 50 + expect(payload.async).toEqual({ 51 + submit: "mill run <program.ts> --json", 52 + status: "mill status <runId> --json", 53 + wait: "mill wait <runId> --timeout 30 --json", 54 + }); 55 + }); 56 + 57 + it("sources driver models from the driver codec catalog", async () => { 58 + const payload = await createDiscoveryPayload({ 59 + defaults: makeDefaults(), 60 + cwd: "/repo", 61 + homeDirectory: "/home/tester", 62 + pathExists: async () => false, 63 + }); 64 + 65 + expect(payload.drivers.default?.models).toEqual(["provider/model-a", "provider/model-b"]); 66 + }); 67 + 68 + it("applies authoring instructions from resolved config overrides", async () => { 69 + const payload = await createDiscoveryPayload({ 70 + defaults: makeDefaults(), 71 + cwd: "/repo", 72 + homeDirectory: "/home/tester", 73 + pathExists: async (path) => path === "/repo/mill.config.ts", 74 + loadConfigOverrides: async () => ({ 75 + authoringInstructions: "from-cwd-config", 76 + }), 77 + }); 78 + 79 + expect(payload.authoring.instructions).toBe("from-cwd-config"); 11 80 }); 12 81 });
+52 -24
packages/core/src/public/discovery.api.ts
··· 1 - import type { DiscoveryPayload } from "./types"; 1 + import { Effect, Runtime } from "effect"; 2 + import type { DiscoveryPayload, DriverRegistration, ResolveConfigOptions } from "./types"; 3 + import { resolveConfig } from "./config-loader.api"; 2 4 3 - export const createDiscoveryPayload = async (): Promise<DiscoveryPayload> => ({ 4 - discoveryVersion: 1, 5 - programApi: { 6 - spawnRequired: ["agent", "systemPrompt", "prompt"], 7 - spawnOptional: ["model"], 8 - resultFields: ["text", "sessionRef", "agent", "model", "driver", "exitCode", "stopReason"], 9 - }, 10 - drivers: { 11 - default: { 12 - description: "Local process driver", 13 - modelFormat: "provider/model-id", 14 - models: ["openai/gpt-5.3-codex", "anthropic/claude-sonnet-4-6"], 5 + const runtime = Runtime.defaultRuntime; 6 + 7 + const buildDiscoveryDrivers = ( 8 + drivers: Readonly<Record<string, DriverRegistration>>, 9 + ): Effect.Effect<DiscoveryPayload["drivers"]> => 10 + Effect.map( 11 + Effect.forEach(Object.entries(drivers), ([driverName, registration]) => 12 + Effect.map( 13 + registration.codec.modelCatalog, 14 + (models) => 15 + [ 16 + driverName, 17 + { 18 + description: registration.description, 19 + modelFormat: registration.modelFormat, 20 + models, 21 + }, 22 + ] as const, 23 + ), 24 + ), 25 + (entries) => Object.fromEntries(entries), 26 + ); 27 + 28 + export const createDiscoveryPayload = async ( 29 + options: ResolveConfigOptions, 30 + ): Promise<DiscoveryPayload> => { 31 + const resolvedConfig = await resolveConfig(options); 32 + 33 + const drivers = await Runtime.runPromise(runtime)( 34 + buildDiscoveryDrivers(resolvedConfig.config.drivers), 35 + ); 36 + 37 + return { 38 + discoveryVersion: 1, 39 + programApi: { 40 + spawnRequired: ["agent", "systemPrompt", "prompt"], 41 + spawnOptional: ["model"], 42 + resultFields: ["text", "sessionRef", "agent", "model", "driver", "exitCode", "stopReason"], 15 43 }, 16 - }, 17 - authoring: { 18 - instructions: 19 - "Use systemPrompt for WHO and prompt for WHAT. Prefer cheaper models for search and stronger models for synthesis.", 20 - }, 21 - async: { 22 - submit: "mill run <program.ts> --json", 23 - status: "mill status <runId> --json", 24 - wait: "mill wait <runId> --timeout 30 --json", 25 - }, 26 - }); 44 + drivers, 45 + authoring: { 46 + instructions: resolvedConfig.config.authoring.instructions, 47 + }, 48 + async: { 49 + submit: "mill run <program.ts> --json", 50 + status: "mill status <runId> --json", 51 + wait: "mill wait <runId> --timeout 30 --json", 52 + }, 53 + }; 54 + };
+1
packages/core/src/public/index.api.ts
··· 1 1 export * from "./mill.api"; 2 + export * from "./config-loader.api"; 2 3 export * from "./discovery.api"; 3 4 export * from "./types";
+44
packages/core/src/public/types.ts
··· 1 + import type * as Effect from "effect/Effect"; 2 + 1 3 export interface SpawnInput { 2 4 readonly agent: string; 3 5 readonly systemPrompt: string; ··· 24 26 readonly command: string; 25 27 readonly args: ReadonlyArray<string>; 26 28 readonly env?: Readonly<Record<string, string>>; 29 + } 30 + 31 + export interface DriverCodec { 32 + readonly modelCatalog: Effect.Effect<ReadonlyArray<string>, never>; 33 + } 34 + 35 + export interface DriverRegistration { 36 + readonly description: string; 37 + readonly modelFormat: string; 38 + readonly process: DriverProcessConfig; 39 + readonly codec: DriverCodec; 40 + } 41 + 42 + export interface MillConfig { 43 + readonly defaultDriver: string; 44 + readonly defaultModel: string; 45 + readonly drivers: Readonly<Record<string, DriverRegistration>>; 46 + readonly authoring: { 47 + readonly instructions: string; 48 + }; 27 49 } 28 50 29 51 export interface DiscoveryPayload { ··· 52 74 readonly wait: string; 53 75 }; 54 76 } 77 + 78 + export type ConfigSource = "cwd" | "upward" | "home" | "defaults"; 79 + 80 + export interface ConfigOverrides { 81 + readonly defaultDriver?: string; 82 + readonly defaultModel?: string; 83 + readonly authoringInstructions?: string; 84 + } 85 + 86 + export interface ResolvedConfig { 87 + readonly source: ConfigSource; 88 + readonly configPath?: string; 89 + readonly config: MillConfig; 90 + } 91 + 92 + export interface ResolveConfigOptions { 93 + readonly defaults: MillConfig; 94 + readonly cwd?: string; 95 + readonly homeDirectory?: string; 96 + readonly pathExists?: (path: string) => Promise<boolean>; 97 + readonly loadConfigOverrides?: (path: string) => Promise<ConfigOverrides>; 98 + }
+17
packages/driver-pi/src/public/index.api.test.ts
··· 1 + import { describe, expect, it } from "bun:test"; 2 + import { Runtime } from "effect"; 3 + import { createPiDriverRegistration } from "./index.api"; 4 + 5 + const runtime = Runtime.defaultRuntime; 6 + 7 + describe("createPiDriverRegistration", () => { 8 + it("exposes catalog-backed model discovery via codec", async () => { 9 + const driver = createPiDriverRegistration(); 10 + 11 + const models = await Runtime.runPromise(runtime)(driver.codec.modelCatalog); 12 + 13 + expect(models).toEqual(["openai/gpt-5.3-codex", "anthropic/claude-sonnet-4-6"]); 14 + expect(driver.process.command).toBe("pi"); 15 + expect(driver.process.args).toEqual(["-p"]); 16 + }); 17 + });
+15 -1
packages/driver-pi/src/public/index.api.ts
··· 1 - import type { DriverProcessConfig } from "@mill/core"; 1 + import { Effect } from "effect"; 2 + import type { DriverCodec, DriverProcessConfig, DriverRegistration } from "@mill/core"; 3 + 4 + const PI_MODELS: ReadonlyArray<string> = ["openai/gpt-5.3-codex", "anthropic/claude-sonnet-4-6"]; 5 + 6 + export const createPiCodec = (): DriverCodec => ({ 7 + modelCatalog: Effect.succeed(PI_MODELS), 8 + }); 2 9 3 10 export const createPiDriverConfig = (): DriverProcessConfig => ({ 4 11 command: "pi", 5 12 args: ["-p"], 6 13 env: {}, 7 14 }); 15 + 16 + export const createPiDriverRegistration = (): DriverRegistration => ({ 17 + description: "Local process driver", 18 + modelFormat: "provider/model-id", 19 + process: createPiDriverConfig(), 20 + codec: createPiCodec(), 21 + });