programmatic subagents
0
fork

Configure Feed

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

initial spec

+941
+941
SPEC.md
··· 1 + # mill — Effect-first orchestration runtime (v0 spec) 2 + 3 + Status: **Draft for implementation** 4 + Scope: local CLI + SDK runtime, detached async runs, generic drivers, Effect-only core 5 + 6 + --- 7 + 8 + ## 1) Product definition 9 + 10 + `mill` is a runtime for executing TypeScript orchestration programs that spawn and coordinate AI agents. 11 + 12 + A mill program is regular TS (sequential with `await`, parallel with `Promise.all`), with one injected global API: 13 + 14 + - `mill.spawn(...)` (core) 15 + - extension-contributed APIs (optional) 16 + 17 + `mill` stores orchestration state and structured run events. Agent conversations remain owned by each agent tool; mill keeps `sessionRef` pointers. 18 + 19 + --- 20 + 21 + ## 2) Hard constraints 22 + 23 + 1. **Effect is the only execution system** 24 + - No `async/await` in core runtime modules. 25 + - No raw `Promise` construction. 26 + - No `try/catch` control flow (except inside Effect wrappers where required by external APIs). 27 + 2. **Process execution through Effect platform abstractions** 28 + - Drivers use Effect `Command` / process services. 29 + - On Bun, these are provided by `@effect/platform-bun` (Bun-backed runtime under the hood). 30 + 3. **Minimal CLI surface** 31 + - No `spec` or `template` subcommands in v0. 32 + 4. **Async-by-default runs** 33 + - `mill run <program.ts>` returns `runId` immediately unless `--sync` is passed. 34 + 5. **Drivers are generic infra adapters** 35 + - No vendor-specific driver concepts in core contracts. 36 + - Vendor specifics belong in codecs and config. 37 + 6. **Boundary clarity is mandatory** 38 + - `src/public/**` / `*.api.ts`: user-facing Promise + interface contracts. 39 + - `src/internal/**`, `src/domain/**`, `src/runtime/**`: Effect contracts + Schema domain models. 40 + - Internal interfaces are capability-only (method signatures), never domain shape definitions. 41 + - The boundary must be visible in filenames and enforced via ast-grep. 42 + 7. **Promise bridge is explicit and singular** 43 + - Only `Runtime.runPromise` is allowed as the Effect→Promise bridge. 44 + - It is allowed only at public boundary adapters (`src/public/**`, CLI entry adapters). 45 + - `Effect.runPromise*` and `Runtime.runPromiseExit` are disallowed. 46 + 8. **No shell-string command execution** 47 + - Drivers must construct commands as argument vectors (`Command.make(cmd, ...args)`). 48 + - Shell-eval patterns (`sh -lc`, `bash -lc`, interpolated command strings) are disallowed. 49 + 9. **Environment access is centralized** 50 + - `process.env` reads are allowed only in config/bootstrap loading modules. 51 + - Internal runtime logic receives resolved values via services/config objects. 52 + 10. **Time/random are injected** 53 + - `Date.now()` and `Math.random()` are disallowed in runtime/domain internals. 54 + - Use injected Effect services (`Clock`, `Random`) instead. 55 + 11. **Internal module boundaries are strict** 56 + - Public modules must not import from `src/internal/**` directly. 57 + - Package exports expose only public API entrypoints. 58 + 12. **Terminal state is single-shot** 59 + - Each run/spawn emits exactly one terminal event (`complete` | `failed` | `cancelled`). 60 + - Terminal states are immutable and idempotent. 61 + 62 + --- 63 + 64 + ## 3) CLI surface (v0) 65 + 66 + ```bash 67 + mill run <program.ts> [--json] [--sync] [--driver <name>] [--executor <name>] [--confirm=false] 68 + mill status <runId> [--json] 69 + mill wait <runId> --timeout <seconds> [--json] 70 + mill watch <runId> [--json] [--raw] 71 + mill ls [--json] [--status <status>] 72 + mill inspect <runId>[.<spawnId>] [--json] [--session] 73 + mill cancel <runId> [--json] 74 + mill init 75 + ``` 76 + 77 + Discovery (for humans and agents): 78 + 79 + - `mill` (no subcommand): concise discovery card 80 + - `mill --help`: help text + authoring guidance 81 + - `mill --help --json`: machine-readable discovery payload 82 + 83 + No other commands in v0. 84 + 85 + ### 3.1 Output mode contract 86 + 87 + - `--json` mode: 88 + - `stdout` is machine-readable only (JSON for single response, JSONL for streams like `watch`). 89 + - human-friendly diagnostics/progress may be emitted on `stderr`. 90 + - non-`--json` mode: 91 + - human output on `stdout` is expected. 92 + - `--json` payloads may include `summaryHuman` fields for agent readability without breaking parsers. 93 + 94 + --- 95 + 96 + ## 4) Runtime topology 97 + 98 + ```text 99 + mill program (TS) 100 + -> executor (direct | vm) 101 + -> engine (run lifecycle, API injection, events, persistence) 102 + -> driver (generic) 103 + -> agent process / remote endpoint 104 + 105 + engine events -> watch/inspect/tui/automation 106 + ``` 107 + 108 + All layers are orthogonal: 109 + 110 + - Executor = where program runs 111 + - Driver = how spawns invoke agents 112 + - Extension = hooks + extra API 113 + - Observer = event consumer 114 + 115 + --- 116 + 117 + ## 5) Run model 118 + 119 + ### 5.1 Async default 120 + 121 + `mill run` flow (default): 122 + 123 + 1. Resolve config 124 + 2. Validate program path 125 + 3. Optional interactive confirmation 126 + 4. Allocate `runId`, create run directory, write initial metadata 127 + 5. Start detached worker process 128 + 6. Return immediately (`runId`, `status=running`, paths) 129 + 130 + `--sync` blocks until completion (implemented as submit + wait internally). 131 + 132 + ### 5.2 Run state machine 133 + 134 + ```text 135 + pending -> running -> complete 136 + -> failed 137 + -> cancelled 138 + ``` 139 + 140 + ### 5.3 Storage layout 141 + 142 + ```text 143 + ~/.mill/ 144 + runs/ 145 + <runId>/ 146 + run.json # run metadata snapshot 147 + events.ndjson # tier-1 structured events (append-only) 148 + result.json # final summarized result 149 + program.ts # copied execution input 150 + logs/ 151 + worker.log 152 + spawns/ 153 + <spawnId>.json # optional derived spawn summary 154 + ``` 155 + 156 + --- 157 + 158 + ## 6) Config contract (`mill.config.ts`) 159 + 160 + ```ts 161 + import { defineConfig } from "@mill/core"; 162 + 163 + export default defineConfig({ 164 + defaultDriver: "default", 165 + defaultModel: "openai/gpt-5.3-codex", 166 + defaultExecutor: "direct", 167 + 168 + drivers: { 169 + default: processDriver({ 170 + command: "pi", 171 + args: ["-p"], 172 + codec: piCodec(), 173 + env: {}, 174 + }), 175 + }, 176 + 177 + executors: { 178 + direct: directExecutor(), 179 + vm: vmExecutor({ runtime: "docker", image: "mill-sandbox:latest" }), 180 + }, 181 + 182 + authoring: { 183 + instructions: "Use systemPrompt for WHO and prompt for WHAT. Prefer cheaper models for search and stronger models for synthesis.", 184 + }, 185 + 186 + extensions: [ 187 + // optional 188 + ], 189 + }); 190 + ``` 191 + 192 + ### 6.1 Config resolution order 193 + 194 + 1. `./mill.config.ts` (cwd) 195 + 2. walk upward to repo root 196 + 3. `~/.mill/config.ts` 197 + 4. internal defaults 198 + 199 + ### 6.2 Environment resolution policy 200 + 201 + - Environment variables are read in config/bootstrap only. 202 + - Resolved env values are normalized into config/services and passed downward. 203 + - Runtime/domain modules must not read `process.env` directly. 204 + 205 + --- 206 + 207 + ## 7) Discovery contract (`mill --help --json`) 208 + 209 + `mill --help --json` MUST include enough info for an agent to author a program without extra docs: 210 + 211 + ```json 212 + { 213 + "discoveryVersion": 1, 214 + "programApi": { 215 + "spawnRequired": ["agent", "systemPrompt", "prompt"], 216 + "spawnOptional": ["model"], 217 + "resultFields": ["text", "sessionRef", "agent", "model", "driver", "exitCode", "stopReason"] 218 + }, 219 + "drivers": { 220 + "default": { 221 + "description": "Local process driver", 222 + "modelFormat": "provider/model-id", 223 + "models": ["openai/gpt-5.3-codex", "anthropic/claude-sonnet-4-6"] 224 + } 225 + }, 226 + "authoring": { 227 + "instructions": "...from config..." 228 + }, 229 + "async": { 230 + "submit": "mill run <program.ts> --json", 231 + "status": "mill status <runId> --json", 232 + "wait": "mill wait <runId> --timeout 30 --json" 233 + } 234 + } 235 + ``` 236 + 237 + --- 238 + 239 + ## 8) Boundary contracts: public Promise API, internal Effect core 240 + 241 + Rule of thumb (strict): 242 + 243 + - **User-exposed surface**: Promise-based API + interfaces are allowed. 244 + - **Everything else**: Effect-first (`Effect`, `Stream`, `Layer`) + Schema-defined domain types. 245 + 246 + Concretely: 247 + 248 + - Public boundary (`src/public/**`, ambient `*.d.ts`): 249 + - can expose `Promise<T>` 250 + - can use `interface` for ergonomics 251 + - Internal/domain/runtime (`src/internal/**`, `src/domain/**`, `src/runtime/**`): 252 + - no public Promise contracts 253 + - domain shapes must be defined by `@effect/schema/Schema` 254 + - no interface-based domain modelling 255 + 256 + Effect contracts used internally: 257 + 258 + - effects: `Effect.Effect<A, E, R>` 259 + - streams: `Stream.Stream<A, E, R>` 260 + - layers: `Layer.Layer<ROut, E, RIn>` 261 + - queue/pubsub for event fanout 262 + - schemas via `@effect/schema/Schema` 263 + 264 + ### 8.1 Domain schemas (representative) 265 + 266 + ```ts 267 + import * as Schema from "@effect/schema/Schema"; 268 + 269 + export const RunId = Schema.String.pipe(Schema.brand("RunId")); 270 + export type RunId = Schema.Schema.Type<typeof RunId>; 271 + 272 + export const SpawnId = Schema.String.pipe(Schema.brand("SpawnId")); 273 + export type SpawnId = Schema.Schema.Type<typeof SpawnId>; 274 + 275 + export const RunStatus = Schema.Literal("pending", "running", "complete", "failed", "cancelled"); 276 + export type RunStatus = Schema.Schema.Type<typeof RunStatus>; 277 + 278 + export const SpawnOptions = Schema.Struct({ 279 + agent: Schema.NonEmptyString, 280 + systemPrompt: Schema.NonEmptyString, 281 + prompt: Schema.NonEmptyString, 282 + model: Schema.optional(Schema.NonEmptyString), 283 + }); 284 + 285 + export type SpawnOptions = Schema.Schema.Type<typeof SpawnOptions>; 286 + 287 + export const SpawnResult = Schema.Struct({ 288 + text: Schema.String, 289 + sessionRef: Schema.NonEmptyString, 290 + agent: Schema.NonEmptyString, 291 + model: Schema.NonEmptyString, 292 + driver: Schema.NonEmptyString, 293 + exitCode: Schema.Number, 294 + stopReason: Schema.optional(Schema.String), 295 + errorMessage: Schema.optional(Schema.String), 296 + }); 297 + 298 + export type SpawnResult = Schema.Schema.Type<typeof SpawnResult>; 299 + ``` 300 + 301 + ### 8.2 Error model 302 + 303 + All errors are tagged Effect data errors. 304 + 305 + ```ts 306 + class ConfigError extends Data.TaggedError("ConfigError")<{ message: string }> {} 307 + class RunNotFoundError extends Data.TaggedError("RunNotFoundError")<{ runId: string }> {} 308 + class DriverError extends Data.TaggedError("DriverError")<{ driver: string; message: string }> {} 309 + class ProgramExecutionError extends Data.TaggedError("ProgramExecutionError")<{ runId: string; message: string }> {} 310 + class PersistenceError extends Data.TaggedError("PersistenceError")<{ path: string; message: string }> {} 311 + ``` 312 + 313 + ### 8.3 Core services 314 + 315 + Service contracts may use `interface`, but only for **capabilities** (methods), not domain data modelling. Their methods remain Effect-typed. 316 + 317 + ```ts 318 + interface RunStore { 319 + create(meta: RunMeta): Effect.Effect<void, PersistenceError>; 320 + appendEvent(runId: RunId, event: MillEvent): Effect.Effect<void, PersistenceError>; 321 + setStatus(runId: RunId, status: RunStatus): Effect.Effect<void, PersistenceError>; 322 + setResult(runId: RunId, result: RunResult): Effect.Effect<void, PersistenceError>; 323 + getRun(runId: RunId): Effect.Effect<RunRecord, RunNotFoundError | PersistenceError>; 324 + listRuns(filter?: RunFilter): Effect.Effect<ReadonlyArray<RunRecord>, PersistenceError>; 325 + } 326 + 327 + interface Driver { 328 + readonly name: string; 329 + readonly spawn: ( 330 + input: DriverSpawnInput, 331 + ) => Effect.Effect<DriverSpawnHandle, DriverError, Scope.Scope>; 332 + } 333 + 334 + interface DriverSpawnHandle { 335 + readonly events: Stream.Stream<DriverEvent, DriverError>; 336 + readonly raw: Stream.Stream<Uint8Array, never>; 337 + readonly result: Effect.Effect<SpawnResult, DriverError>; 338 + readonly cancel: Effect.Effect<void, never>; 339 + } 340 + 341 + interface Executor { 342 + readonly name: string; 343 + readonly runProgram: ( 344 + input: ProgramRunInput, 345 + ) => Effect.Effect<ProgramRunHandle, ProgramExecutionError, Scope.Scope>; 346 + } 347 + 348 + interface ProgramRunHandle { 349 + readonly events: Stream.Stream<MillEvent, ProgramExecutionError>; 350 + readonly result: Effect.Effect<RunResult, ProgramExecutionError>; 351 + readonly cancel: Effect.Effect<void, never>; 352 + } 353 + ``` 354 + 355 + ### 8.4 Engine service 356 + 357 + ```ts 358 + interface MillEngine { 359 + submit(input: SubmitRunInput): Effect.Effect<SubmitRunOutput, ConfigError | PersistenceError | ProgramExecutionError>; 360 + runSync(input: SubmitRunInput): Effect.Effect<RunResult, ConfigError | PersistenceError | ProgramExecutionError>; 361 + status(runId: RunId): Effect.Effect<RunRecord, RunNotFoundError | PersistenceError>; 362 + wait(runId: RunId, timeout: Duration.DurationInput): Effect.Effect<RunRecord, RunNotFoundError | PersistenceError>; 363 + watch(runId: RunId): Stream.Stream<MillEvent, RunNotFoundError | PersistenceError>; 364 + cancel(runId: RunId): Effect.Effect<void, RunNotFoundError | PersistenceError>; 365 + inspect(ref: RunOrSpawnRef): Effect.Effect<InspectResult, RunNotFoundError | PersistenceError>; 366 + } 367 + ``` 368 + 369 + ### 8.5 Effect runtime primitives used in mill 370 + 371 + `mill` implementation uses these Effect modules as first-class building blocks: 372 + 373 + - `Effect.gen`, `Effect.scoped`, `Effect.acquireRelease`, `Effect.timeout`, `Effect.retry`, `Effect.interrupt` 374 + - `Fiber` / `FiberSet` for supervised detached run workers 375 + - `Queue` for per-run ordered event buffering 376 + - `PubSub` for fanout to multiple live watchers 377 + - `Stream` for driver output decoding and watch subscriptions 378 + - `Ref` / `SynchronizedRef` for in-memory run registry snapshots 379 + - `Layer` + `Context.Tag` for all services (`RunStore`, `DriverRegistry`, `ExecutorRegistry`, `Clock`, etc.) 380 + - `Runtime` for bridging program-facing Promise API (`mill.spawn(): Promise<...>`) to internal Effects via **only** `Runtime.runPromise` 381 + 382 + Target platform services: 383 + 384 + - `@effect/platform/Command` 385 + - `@effect/platform/FileSystem` 386 + - `@effect/platform/Path` 387 + - `@effect/platform/Terminal` 388 + - `@effect/platform-bun` runtime layer for Bun-backed implementations 389 + 390 + ### 8.6 Package baseline (Effect v4 target) 391 + 392 + `mill` pins to Effect v4-compatible package line: 393 + 394 + ```json 395 + { 396 + "dependencies": { 397 + "effect": "^4.x", 398 + "@effect/platform": "^1.x", 399 + "@effect/platform-bun": "^1.x", 400 + "@effect/schema": "^1.x" 401 + } 402 + } 403 + ``` 404 + 405 + (Exact minor versions are implementation-time decisions; API usage must stay within documented stable modules.) 406 + 407 + ### 8.7 File layout + naming (boundary is visible in filenames) 408 + 409 + ```text 410 + src/ 411 + public/ 412 + mill.api.ts # Promise-based user API 413 + discovery.api.ts # Promise-based CLI/discovery payload builders 414 + types.ts # user-facing interfaces allowed 415 + domain/ 416 + run.schema.ts # Schema-based domain models (no interfaces) 417 + spawn.schema.ts 418 + internal/ 419 + engine.effect.ts # internal Effect programs/services 420 + run-store.effect.ts 421 + driver.effect.ts 422 + runtime/ 423 + worker.effect.ts 424 + ``` 425 + 426 + Naming rules: 427 + 428 + - `*.api.ts` => user boundary (Promise + interfaces allowed) 429 + - `*.schema.ts` => domain data contracts (`Schema` + `Schema.Type` exports) 430 + - `*.effect.ts` => internal runtime/effectful orchestration code 431 + 432 + If a file defines domain entities and is not `*.schema.ts`, it is considered a spec violation. 433 + 434 + ### 8.8 Quick classification examples 435 + 436 + Allowed (public boundary): 437 + 438 + ```ts 439 + // src/public/mill.api.ts 440 + export interface Mill { 441 + spawn(input: SpawnInput): Promise<SpawnOutput>; 442 + } 443 + ``` 444 + 445 + Required (internal): 446 + 447 + ```ts 448 + // src/internal/engine.effect.ts 449 + export const submit = ( 450 + input: SubmitRunInput, 451 + ): Effect.Effect<SubmitRunOutput, SubmitError, RunStore | DriverRegistry> => 452 + Effect.gen(function* () { 453 + // ... 454 + }); 455 + ``` 456 + 457 + Required (domain): 458 + 459 + ```ts 460 + // src/domain/run.schema.ts 461 + export const RunRecord = Schema.Struct({ 462 + id: RunId, 463 + status: RunStatus, 464 + startedAt: Schema.String, 465 + }); 466 + export type RunRecord = Schema.Schema.Type<typeof RunRecord>; 467 + ``` 468 + 469 + Disallowed: 470 + 471 + ```ts 472 + // src/domain/run.ts 473 + export interface RunRecord { // lint error 474 + id: string; 475 + status: string; 476 + } 477 + ``` 478 + 479 + ### 8.9 Promise bridge and decode boundaries 480 + 481 + - Allowed bridge: 482 + - `Runtime.runPromise` only 483 + - Disallowed bridges: 484 + - `Effect.runPromise` 485 + - `Effect.runPromiseExit` 486 + - `Runtime.runPromiseExit` 487 + - Bridge location: 488 + - boundary adapters only (`src/public/**`, CLI boundary entrypoints) 489 + 490 + Decode policy: 491 + 492 + - `JSON.parse` is only allowed in codec/schema decoding modules (`*.codec.ts`, `*.schema.ts`). 493 + - Parsed values must be validated with `Schema.decodeUnknown*` before use. 494 + - Ad-hoc parsing in engine/runtime/business modules is disallowed. 495 + 496 + --- 497 + 498 + ## 9) Event model 499 + 500 + Two tiers: 501 + 502 + ### Tier 1 (structured, persisted) 503 + 504 + Required core events: 505 + 506 + - `run:start` 507 + - `run:status` 508 + - `run:complete` 509 + - `run:failed` 510 + - `run:cancelled` 511 + - `spawn:start` 512 + - `spawn:milestone` 513 + - `spawn:tool_call` 514 + - `spawn:error` 515 + - `spawn:complete` 516 + - `spawn:cancelled` 517 + 518 + All tier-1 events must include: 519 + 520 + - `schemaVersion` (integer, starts at `1`) 521 + - `runId` 522 + - event `type` (discriminant) 523 + - monotonic sequence number 524 + - timestamp 525 + 526 + Encoding/decoding requirements: 527 + 528 + - persisted event payloads are defined as a Schema discriminated union 529 + - writers encode from typed values 530 + - readers decode with `Schema.decodeUnknown*` 531 + - unknown schema versions are surfaced as typed decode errors 532 + 533 + Tier 1 is written to `events.ndjson` and is the source for `watch`, `inspect`, and extensions. 534 + 535 + ### Tier 1 lifecycle invariants 536 + 537 + Exactly one terminal event is allowed per run and per spawn: 538 + 539 + - run terminal set: `run:complete | run:failed | run:cancelled` 540 + - spawn terminal set: `spawn:complete | spawn:error | spawn:cancelled` 541 + 542 + Transition table: 543 + 544 + ```text 545 + run: pending -> running -> complete|failed|cancelled 546 + spawn: pending -> running -> complete|error|cancelled 547 + ``` 548 + 549 + Terminal states have no outgoing transitions. 550 + `mill wait` resolves on first observed terminal event and treats additional terminal events as invariant violations. 551 + 552 + ### Tier 2 (raw passthrough, ephemeral) 553 + 554 + - full raw bytes/text from driver process or remote stream 555 + - available live via `watch --raw` 556 + - not persisted by engine 557 + 558 + --- 559 + 560 + ## 10) Driver architecture 561 + 562 + ## 10.1 Generic driver + codec split 563 + 564 + Core does not encode vendor semantics. 565 + 566 + - `processDriver(...)` and `httpDriver(...)` are generic factories. 567 + - `codec` parses native output into `DriverEvent` + `SpawnResult`. 568 + 569 + ```ts 570 + interface DriverCodec { 571 + readonly decodeEvent: (chunk: Uint8Array) => Effect.Effect<ReadonlyArray<DriverEvent>, CodecError>; 572 + readonly decodeFinal: (aggregate: ReadonlyArray<Uint8Array>) => Effect.Effect<SpawnResult, CodecError>; 573 + readonly modelCatalog: Effect.Effect<ReadonlyArray<string>, never>; 574 + } 575 + ``` 576 + 577 + ## 10.2 Process driver execution (Bun-backed via Effect) 578 + 579 + Driver process spawning MUST be implemented with Effect platform command APIs and Bun context layer. 580 + 581 + Implementation pattern: 582 + 583 + 1. Build command (`Command.make(command, ...args)`) 584 + 2. Apply env/cwd/stdin 585 + 3. Start process via platform `Command` executor 586 + 4. Consume stdout/stderr as `Stream` 587 + 5. Parse via codec to structured events 588 + 6. Await exit code and final decode 589 + 590 + Command safety requirements: 591 + 592 + - commands must be built as arg vectors (`Command.make(cmd, ...args)`) 593 + - `sh -lc`, `bash -lc`, and interpolated shell command strings are disallowed 594 + - untrusted/user-provided values must flow as args, never shell source text 595 + 596 + The implementation layer includes `@effect/platform-bun` runtime context so process operations are backed by Bun spawn internally while preserving typed Effect semantics. 597 + 598 + --- 599 + 600 + ## 11) Executor architecture 601 + 602 + ### 11.1 Direct executor (default) 603 + 604 + - executes the TS program using Bun in local environment 605 + - injects `globalThis.mill` 606 + - enforces scoped lifecycle and cancellation 607 + 608 + ### 11.2 VM executor (optional) 609 + 610 + - same engine contracts 611 + - runs program in sandboxed runtime (docker/firecracker/gvisor) 612 + 613 + Executor has no driver knowledge. 614 + 615 + --- 616 + 617 + ## 12) Program API injected into runtime 618 + 619 + This is a **user-facing boundary**, so Promise-returning signatures are intentional. 620 + 621 + ```ts 622 + declare global { 623 + const mill: { 624 + spawn(opts: SpawnOptions): Promise<SpawnResult>; 625 + // extension APIs merged in at runtime 626 + [key: string]: unknown; 627 + }; 628 + } 629 + ``` 630 + 631 + Runtime validation: 632 + 633 + - `systemPrompt` must be non-empty 634 + - `prompt` must be non-empty 635 + - `agent` must be non-empty 636 + 637 + Behavior: 638 + 639 + - each `spawn` allocates `spawnId` 640 + - engine emits `spawn:start` 641 + - driver handle streams events 642 + - engine maps to tier-1 events and persists 643 + - resolve final `SpawnResult` 644 + 645 + --- 646 + 647 + ## 13) Background worker process 648 + 649 + Internal worker command (private API): 650 + 651 + ```bash 652 + mill _worker --run-id <id> --program <abs-path> --config <resolved-config> [--driver ...] [--executor ...] 653 + ``` 654 + 655 + Worker responsibilities: 656 + 657 + 1. mark run `running` 658 + 2. execute program through engine 659 + 3. append tier-1 events 660 + 4. write final `result.json` 661 + 5. mark terminal status exactly once (idempotent finalize) 662 + 663 + CLI `run` command only submits and detaches worker (unless `--sync`). 664 + 665 + --- 666 + 667 + ## 14) Extensions 668 + 669 + ```ts 670 + interface Extension { 671 + readonly name: string; 672 + readonly setup?: (ctx: ExtensionContext) => Effect.Effect<void, ExtensionError, Scope.Scope>; 673 + readonly onEvent?: (event: MillEvent, ctx: ExtensionContext) => Effect.Effect<void, ExtensionError>; 674 + readonly api?: Record<string, (...args: ReadonlyArray<unknown>) => Promise<unknown>>; 675 + } 676 + ``` 677 + 678 + Rules: 679 + 680 + - Extension failure does not crash engine by default; failure becomes `extension:error` event. 681 + - `api` contributions are namespaced into injected `mill` object. 682 + - Extension hooks (`setup`, `onEvent`) stay Effect-native. 683 + - Extension `api` is user-facing, therefore Promise-based by contract. 684 + - Promise adapters for extension API must use `Runtime.runPromise` as the only bridge. 685 + 686 + --- 687 + 688 + ## 15) Observers 689 + 690 + Observers consume tier-1 stream (and optionally tier-2 live raw stream): 691 + 692 + - `mill watch` 693 + - `mill inspect` 694 + - future TUI/web UI 695 + - automation reading NDJSON 696 + 697 + Observers are read-only; they do not mutate engine state. 698 + 699 + --- 700 + 701 + ## 16) `inspect --session` 702 + 703 + `mill inspect <runId>.<spawnId> --session` resolves the spawn `sessionRef` via the originating driver and opens or prints a pointer to full native session history. 704 + 705 + Engine never normalizes full transcript ownership. 706 + 707 + --- 708 + 709 + ## 17) Cancellation semantics 710 + 711 + `mill cancel <runId>`: 712 + 713 + 1. mark run as cancelling 714 + 2. interrupt worker fiber 715 + 3. propagate cancel to all live spawn handles (`handle.cancel`) 716 + 4. append `run:cancelled` (only if run is not already terminal) 717 + 5. mark terminal state `cancelled` 718 + 719 + Cancellation must be interruption-safe and idempotent. 720 + If run is already terminal, cancellation is a no-op. 721 + 722 + --- 723 + 724 + ## 18) SDK contract (`@mill/core`) 725 + 726 + ```ts 727 + interface CreateEngineInput { 728 + readonly config: MillConfig; 729 + } 730 + 731 + declare const createEngine: ( 732 + input: CreateEngineInput, 733 + ) => Effect.Effect<MillEngine, ConfigError, Scope.Scope>; 734 + ``` 735 + 736 + CLI is a thin wrapper around SDK service methods. 737 + 738 + ### 18.1 Package export boundary 739 + 740 + `package.json` exports must expose only public entrypoints (`src/public/**` build outputs). 741 + 742 + - consumers must not import `src/internal/**` / `src/runtime/**` directly 743 + - internal modules are considered private implementation detail 744 + - CI should fail if an internal path is exported 745 + 746 + --- 747 + 748 + ## 19) Constraint toolchain (cedar-style) 749 + 750 + This is mandatory for mill repo setup. 751 + 752 + ### 19.1 Tooling 753 + 754 + - `ast-grep` (structural guardrails) 755 + - `oxlint` (fast lint) 756 + - `oxfmt` (format) 757 + - `tsgo` (`@typescript/native-preview`) for typecheck 758 + - `bun test` for tests 759 + 760 + ### 19.2 Required files 761 + 762 + ```text 763 + .ast-grep/ 764 + sgconfig.yml 765 + rules/ 766 + no-any.yml 767 + no-as-unknown-as.yml 768 + no-bun-globals.yml 769 + no-date-now-outside-clock.yml 770 + no-dot-then.yml 771 + no-dynamic-import.yml 772 + no-effect-runpromise.yml 773 + no-interface-for-domain-models.yml 774 + no-interface-outside-public.yml 775 + no-json-parse-outside-codec.yml 776 + no-math-random-outside-random.yml 777 + no-node-imports.yml 778 + no-process-env-outside-config.yml 779 + no-promise-outside-public.yml 780 + no-public-import-internal.yml 781 + no-raw-promise.yml 782 + no-runtime-runpromise-outside-boundary.yml 783 + no-shell-string-command.yml 784 + no-stub-functions.yml 785 + no-throw.yml 786 + no-try-catch.yml 787 + tests/ 788 + *.test.yml 789 + .oxlintrc.json 790 + .oxfmtrc.json 791 + scripts/ 792 + check-exports.ts 793 + ``` 794 + 795 + `sgconfig.yml` pattern (cedar-style): 796 + 797 + ```yml 798 + ruleDirs: 799 + - .ast-grep/rules 800 + ``` 801 + 802 + ### 19.3 Required scripts 803 + 804 + ```json 805 + { 806 + "scripts": { 807 + "test": "bun test", 808 + "typecheck": "tsgo --noEmit", 809 + "lint": "oxlint .", 810 + "lint:fix": "oxlint . --fix", 811 + "lint:ast-grep:test": "ast-grep test --config .ast-grep/sgconfig.yml --skip-snapshot-tests", 812 + "lint:ast-grep": "ast-grep scan --config .ast-grep/sgconfig.yml src --error", 813 + "lint:effect": "ast-grep scan --config .ast-grep/sgconfig.yml src/internal src/domain src/runtime --error --filter 'no-(raw-promise|try-catch|throw|dot-then|any|bun-globals|node-imports|dynamic-import)'", 814 + "lint:boundary": "ast-grep scan --config .ast-grep/sgconfig.yml src --error --filter 'no-(interface-outside-public|promise-outside-public|interface-for-domain-models|effect-runpromise|runtime-runpromise-outside-boundary|public-import-internal)'", 815 + "lint:runtime-safety": "ast-grep scan --config .ast-grep/sgconfig.yml src/internal src/domain src/runtime --error --filter 'no-(json-parse-outside-codec|shell-string-command|process-env-outside-config|date-now-outside-clock|math-random-outside-random)'", 816 + "lint:exports": "bun run scripts/check-exports.ts", 817 + "format": "oxfmt . --write", 818 + "format:check": "oxfmt . --check", 819 + "check": "bun run lint:ast-grep:test && bun run lint:effect && bun run lint:boundary && bun run lint:runtime-safety && bun run lint:exports && bun run lint:ast-grep && bun run lint && bun run format:check && bun run typecheck && bun test" 820 + } 821 + } 822 + ``` 823 + 824 + ### 19.4 Baseline lint/format config 825 + 826 + `.oxlintrc.json`: 827 + 828 + ```json 829 + { 830 + "$schema": "./node_modules/oxlint/configuration_schema.json", 831 + "env": { "builtin": true }, 832 + "categories": { "correctness": "error" }, 833 + "ignorePatterns": ["node_modules", ".jj", "dist"] 834 + } 835 + ``` 836 + 837 + `.oxfmtrc.json`: 838 + 839 + ```json 840 + { 841 + "$schema": "./node_modules/oxfmt/configuration_schema.json", 842 + "ignorePatterns": ["node_modules", ".jj", "dist", "SPEC.md"] 843 + } 844 + ``` 845 + 846 + ### 19.5 Guardrail intent 847 + 848 + - ban direct Bun globals (`Bun.spawn`, `Bun.file`, etc.) in core runtime 849 + - ban direct `node:` imports in app modules 850 + - ban untyped throw/catch/promise patterns 851 + - enforce Effect-centric architecture and composability 852 + - enforce boundary policy: 853 + - `no-interface-for-domain-models`: domain entities must come from `Schema` 854 + - `no-interface-outside-public`: interfaces are allowed only in `src/public/**` and `*.d.ts` (plus explicit allowlist files like config declarations) 855 + - `no-promise-outside-public`: Promise-returning contracts are allowed only at user boundary files (`*.api.ts`, `src/public/**`) 856 + - `no-effect-runpromise`: ban `Effect.runPromise*` usage entirely 857 + - `no-runtime-runpromise-outside-boundary`: only `Runtime.runPromise` may bridge, and only in boundary adapters 858 + - `no-public-import-internal`: public API modules cannot import private internals directly 859 + - enforce parsing/process/runtime safety: 860 + - `no-json-parse-outside-codec`: restrict `JSON.parse` to decode modules and require Schema decode 861 + - `no-shell-string-command`: disallow shell-eval process invocation patterns 862 + - `no-process-env-outside-config`: restrict env reads to config/bootstrap 863 + - `no-date-now-outside-clock`: force injected clock usage 864 + - `no-math-random-outside-random`: force injected random service usage 865 + 866 + Practical exception policy: 867 + 868 + - internal service capability interfaces (method-only, Effect return types) are allowed in `*.service.ts` / `*.effect.ts` through explicit ast-grep rule allow patterns 869 + - any interface with data fields in `src/domain/**`, `src/internal/**`, `src/runtime/**` is a lint error 870 + - codec/schema files are allowlisted for parsing operations; all downstream modules consume decoded typed values 871 + 872 + ### 19.6 Required contract tests 873 + 874 + - `--json` mode contract tests: 875 + - stdout contains valid JSON/JSONL only 876 + - human-readable diagnostics are emitted to stderr only 877 + - lifecycle contract tests: 878 + - exactly one terminal event per run 879 + - exactly one terminal event per spawn 880 + - no terminal -> non-terminal transitions 881 + - duplicate terminal emissions are ignored or rejected deterministically 882 + 883 + --- 884 + 885 + ## 20) Invariants 886 + 887 + 1. Every run has append-only tier-1 event log. 888 + 2. Every spawn completion includes `sessionRef`. 889 + 3. Engine persists orchestration state only (not full transcript). 890 + 4. Public user APIs are Promise-based façades; internal APIs remain Effect-typed. 891 + 5. `--json` mode writes machine payloads to `stdout` only; human diagnostics go to `stderr`. 892 + 6. Each run/spawn emits exactly one terminal event and never transitions afterward. 893 + 7. All persisted tier-1 events include `schemaVersion` and decode via Schema unions. 894 + 8. `Runtime.runPromise` is the only permitted Effect→Promise bridge. 895 + 9. Runtime/domain internals do not read `process.env`, `Date.now()`, or `Math.random()` directly. 896 + 10. `mill run` returns immediately by default. 897 + 898 + --- 899 + 900 + ## 21) v0 non-goals 901 + 902 + - hosted control plane / multi-tenant server 903 + - built-in template subcommands 904 + - advanced workflow DSLs beyond plain TS 905 + - driver hot-swapping policies inside program logic 906 + 907 + --- 908 + 909 + ## 22) Implementation order 910 + 911 + 1. Core domain schemas + error model 912 + 2. RunStore + event append persistence 913 + 3. Generic process driver + one codec (pi or claude) 914 + 4. Engine submit/status/wait/watch/cancel 915 + 5. Worker process + detached `run` 916 + 6. `inspect` and `--session` bridge 917 + 7. Extension hooks 918 + 8. Guardrail toolchain + rules/tests 919 + 920 + --- 921 + 922 + ## 23) Canonical program example 923 + 924 + ```ts 925 + const scan = await mill.spawn({ 926 + agent: "scout", 927 + systemPrompt: "You are a code risk analyst. Prioritize highest-impact findings.", 928 + prompt: "Review src/auth and summarize top security and reliability risks.", 929 + model: "openai/gpt-5.3-codex", 930 + }); 931 + 932 + const synth = await mill.spawn({ 933 + agent: "synth", 934 + systemPrompt: "You turn findings into an execution-ready plan.", 935 + prompt: `Create a step-by-step remediation plan from this analysis:\n\n${scan.text}`, 936 + }); 937 + 938 + console.log(synth.text); 939 + ``` 940 + 941 + This remains plain TypeScript orchestration with `await` / `Promise.all` and no DSL.