@hecer/yoke 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +22 -0
  4. package/README.md +67 -15
  5. package/TODOS.md +0 -3
  6. package/canon/loop/loop-spec.md +22 -8
  7. package/canon/manifest.yaml +1 -1
  8. package/dist/agents/contracts.js +50 -0
  9. package/dist/agents/process-incarnation.js +15 -0
  10. package/dist/agents/process-record.js +65 -0
  11. package/dist/agents/process-streams.js +40 -0
  12. package/dist/agents/process.js +177 -0
  13. package/dist/agents/providers.js +10 -7
  14. package/dist/agents/telemetry.js +62 -0
  15. package/dist/cli.js +55 -3
  16. package/dist/loop/candidate-boundaries.js +43 -0
  17. package/dist/loop/candidate-cleanup.js +98 -0
  18. package/dist/loop/candidate-contracts.js +1 -0
  19. package/dist/loop/candidate-selection.js +84 -0
  20. package/dist/loop/candidates.js +228 -0
  21. package/dist/loop/claim-lease.js +131 -0
  22. package/dist/loop/claims.js +177 -40
  23. package/dist/loop/cleanup.js +117 -15
  24. package/dist/loop/decision.js +31 -0
  25. package/dist/loop/dispatcher.js +334 -0
  26. package/dist/loop/loop.js +109 -16
  27. package/dist/loop/merge-queue.js +12 -6
  28. package/dist/loop/parallel-adapters.js +185 -0
  29. package/dist/loop/parallel-command.js +287 -0
  30. package/dist/loop/parallel.js +2 -4
  31. package/dist/loop/prd.js +4 -1
  32. package/dist/loop/reporter.js +86 -5
  33. package/dist/loop/run-command.js +204 -51
  34. package/dist/loop/runner.js +67 -32
  35. package/dist/loop/watchdog.js +67 -8
  36. package/dist/loop/worker-cancellation.js +17 -0
  37. package/dist/loop/worker-cleanup.js +23 -0
  38. package/dist/loop/worker-contracts.js +1 -0
  39. package/dist/loop/worker.js +254 -0
  40. package/dist/quality/artifacts.js +59 -0
  41. package/dist/quality/candidate-comparison.js +130 -0
  42. package/dist/quality/command.js +316 -0
  43. package/dist/quality/loop.js +86 -0
  44. package/dist/quality/process-command.js +57 -0
  45. package/dist/quality/reference.js +187 -0
  46. package/dist/quality/repair.js +11 -0
  47. package/dist/quality/runner.js +66 -0
  48. package/dist/quality/types.js +60 -0
  49. package/dist/quality/verdict.js +142 -0
  50. package/dist/retrofit/config.js +4 -2
  51. package/dist/retrofit/gitignore.js +3 -0
  52. package/dist/review/command.js +27 -38
  53. package/dist/review/verdict.js +38 -7
  54. package/docs/MIGRATING-TO-1.4.md +70 -0
  55. package/docs/PUBLISHING.md +16 -2
  56. package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -0
  57. package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
  58. package/gemini-extension.json +1 -1
  59. package/package.json +1 -1
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "yoke",
4
4
  "displayName": "Yoke",
5
- "version": "1.3.0",
5
+ "version": "1.4.0",
6
6
  "description": "Cross-agent coding harness: one curated skill canon (TDD, brainstorming, plans, reviews, shipping, design verification) plus mechanical safety gates and an autonomous loop via the yoke CLI.",
7
7
  "author": { "name": "HECer", "url": "https://github.com/HECer" },
8
8
  "homepage": "https://github.com/HECer/yoke#readme",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yoke",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Cross-agent coding discipline, mechanical gates, and release workflows",
5
5
  "skills": "./canon/skills/",
6
6
  "hooks": "./hooks/hooks.json"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.0 — 2026-08-15
4
+
5
+ ### Added
6
+ - `yoke loop run --parallel=N` now executes dependency-ready, non-colliding stories through real provider subprocess workers, isolated worktrees, leased claims, and a FIFO integration queue with fresh integrated-system gates.
7
+ - Reference-driven quality declarations can collect screenshots, files, command output, or benchmark results and run a schema-validated blind critic with bounded repair rounds, elapsed-time limits, blocking or advisory policy, and retained proof.
8
+ - `--candidates=N` can fan out up to five isolated implementations, discard mechanically red candidates, select one green candidate through identity-blind pairwise comparison, and preserve selected/loser evidence before cleanup.
9
+ - Loop status now exposes dispatcher, worker, integrator, candidate lifecycle, worktree, queue, integration, reopen, quality-round, repair-budget, and trusted provider/model provenance data.
10
+
11
+ ### Changed
12
+ - Provider subprocesses use explicit lifecycle contracts and incarnation-aware process records so worker cancellation and cleanup target only the process tree Yoke actually started.
13
+ - Parallel and candidate runs disable adaptive routing, honor story-level provider affinity, latch pause requests across the whole dispatcher, and rerun quality plus review after integration.
14
+ - `yoke loop cleanup` retains Yoke worktrees unless `--remove-worktrees` is explicit, while still reaping recorded orphan runners and stale locks safely.
15
+
16
+ ### Fixed
17
+ - Expired claims, worker crashes, merge conflicts, pause races, and integration failures now release ownership deterministically, retain terminal proof, and reopen stories without leaking worktrees or marking false completion.
18
+ - Quality repair fails closed on malformed critic output, reference drift, provider/model provenance mismatch, candidate identity leakage, unavailable critics, exhausted limits, and mechanically red repairs.
19
+ - The watchdog resolves its TypeScript loader from both source and built npm layouts on Node 20+, and read-only Codex comparisons can run in disposable candidate worktrees without weakening normal repository checks.
20
+
21
+ ### Security
22
+ - Blind comparison requests expose only opaque labels and digests while binding every verdict to the trusted judge provider, model, prompt, rubric, reference, and candidate provenance.
23
+ - Cleanup and cancellation use project-scoped leases, owner tokens, PID birth/incarnation checks, and recorded process handles rather than machine-wide process-name matching.
24
+
3
25
  ## 1.3.0 — 2026-08-09
4
26
 
5
27
  ### Added
package/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  # 🐂 Yoke
4
4
 
5
- <!-- yoke:version:start -->1.3.0<!-- yoke:version:end -->
6
- <!-- yoke:tests:start -->657<!-- yoke:tests:end -->
5
+ <!-- yoke:version:start -->1.4.0<!-- yoke:version:end -->
6
+ <!-- yoke:tests:start -->928<!-- yoke:tests:end -->
7
7
  <!-- yoke:skills:start -->29<!-- yoke:skills:end -->
8
8
  <!-- yoke:agents:start -->Claude | Codex | Gemini<!-- yoke:agents:end -->
9
9
 
@@ -17,7 +17,7 @@
17
17
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](#-license)
18
18
  ![Node](https://img.shields.io/badge/node-%E2%89%A520-339933?logo=node.js&logoColor=white)
19
19
  ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)
20
- ![Tests](https://img.shields.io/badge/tests-657%20passing-brightgreen.svg)
20
+ ![Tests](https://img.shields.io/badge/tests-928%20passing-brightgreen.svg)
21
21
  ![Agents](https://img.shields.io/badge/agents-Claude%20%7C%20Codex%20%7C%20Gemini-8A2BE2)
22
22
  ![Built with TDD](https://img.shields.io/badge/built%20with-TDD%20%2B%20review-ff69b4.svg)
23
23
 
@@ -25,7 +25,11 @@
25
25
 
26
26
  </div>
27
27
 
28
- > **TL;DR** — `yoke setup .` asks six questions and installs the native harness for your agent. `yoke new my-app --idea="..."` bootstraps a project and drafts its story backlog. `yoke loop run my-app --isolate --review` then implements it story by story behind hard gates: **clean tree → acceptance criteria → your real tests green → an independent model approves → commit**. If any gate is red, nothing is committed. When a story is done, there's a photo of it in `.yoke/proof/<story>/`.
28
+ > **TL;DR** — `yoke setup .` asks six questions and installs the native harness for your agent. `yoke new my-app --idea="..."` bootstraps a project and drafts its story backlog. `yoke loop run my-app --isolate --review` then implements it behind hard gates: **clean tree → acceptance criteria → your real tests green → an independent model approves → commit**. Add `--parallel=N` for dependency-aware workers, or declare a reference and add `--quality` for a bounded critic/repair gauntlet. If any blocking gate is red, nothing is committed. Proof lives in `.yoke/proof/<story>/`.
29
+
30
+ Yoke 1.4 adds opt-in parallel workers and a bounded, reference-driven quality gauntlet without
31
+ changing existing serial loop defaults. See [the 1.4 migration guide](docs/MIGRATING-TO-1.4.md)
32
+ for the new flags, configuration, cleanup behavior, and review-verdict contract.
29
33
 
30
34
  Yoke 1.1 is safe-by-default: provider CLIs use autonomous sandbox profiles unless `--unsafe`
31
35
  is explicit; reviews require a schema-valid verdict and a different model unless
@@ -72,7 +76,7 @@ $ ls reading-app/.yoke/proof/STORY-2/
72
76
  home.png list.png # photographic evidence, labelled per story
73
77
  ```
74
78
 
75
- Every claim in that transcript is enforced by code paths with tests behind them — 657 of them, and this repo was built by its own loop and gates ([how it was built](#-why--how-it-was-built)).
79
+ Every claim in that transcript is enforced by code paths with tests behind them — 928 of them, and this repo was built by its own loop and gates ([how it was built](#-why--how-it-was-built)).
76
80
 
77
81
  ## 🚀 Quickstart
78
82
 
@@ -87,7 +91,7 @@ yoke loop on my-app && yoke loop run my-app --isolate
87
91
  # — or retrofit an existing project —
88
92
  yoke setup /path/to/project # interactive: agents, graph, loop, runner, decisions, routing
89
93
  yoke validate canon # sanity-check the canon
90
- yoke loop run /path/to/project --isolate --reviewer=codex --max=20
94
+ yoke loop run /path/to/project --isolate --parallel=3 --reviewer=codex --max=20
91
95
  ```
92
96
 
93
97
  > Requires Node ≥ 20 and git. No global install? `node /path/to/yoke/dist/cli.js …` or `npm --prefix /path/to/yoke run yoke -- …` work too. The MCP tools (rtk, graphify/Serena, Playwright MCP) are wired by Yoke but installed separately — the generated config is a clearly-labelled, adjustable template.
@@ -156,7 +160,7 @@ Yoke's CLI is deterministic and chainable by design: an agent (or a shell `&&`)
156
160
  | `yoke prd check [dir]` | PRD lint gate (schema, dependencies, cycles, duplicate ids, acceptance) | `0` valid · `1` violations |
157
161
  | `yoke change add\|status [dir] [--idea=]` | Queue a change at any time; the loop turns it into append-only stories at the next safe boundary | `0` · `1` invalid inbox/request |
158
162
  | `yoke context init\|status [dir]` | Durable context layer (`PROJECT/DECISIONS/KNOWLEDGE.md`) | `0` |
159
- | `yoke loop on\|off\|status\|decision\|answer\|resume\|run\|cleanup [dir]` | Autonomous loop; `run` is unlimited by default and `--max=N` creates an intentional batch cap; `decision` shows a critical stop, `answer` records it and resumes, `resume` retries a failed restart with the preserved safety options | run: `0` complete · `1` blocked/cap · `2` not runnable / already locked · `3` paused |
163
+ | `yoke loop on\|off\|status\|decision\|answer\|resume\|run\|cleanup [dir]` | Autonomous loop; `run` supports `--parallel=N`, bounded reference-driven `--quality`, and blind `--candidates=N` selection; `--max=N` creates an intentional batch cap; `cleanup` retains worktrees unless `--remove-worktrees` is explicit | run: `0` complete · `1` blocked/cap · `2` not runnable / already locked · `3` paused |
160
164
  | `yoke review [dir] [--reviewer=] [--base=] [--focus=] [--json] [--allow-self-review]` | An independent model writes a schema-valid verdict | `0` approved · `1` findings/invalid verdict · `2` no independent reviewer |
161
165
  | `yoke audit [dir] [--json]` | Dependency, high-confidence secret, and sensitive-change audit | `0` green · `1` blocking findings · `2` not runnable |
162
166
  | `yoke design-scan [dir] [--max=N] [--report]` | Static AI-slop design gate | `0` within budget · `1` over |
@@ -337,6 +341,7 @@ yoke loop run . \
337
341
  --runner=codex \ # implement with Codex…
338
342
  --reviewer=claude \ # …review with Claude (role separation)
339
343
  --isolate \ # each story in a throwaway git worktree
344
+ --parallel=3 \ # run dependency-ready, non-colliding stories concurrently
340
345
  --decision-policy=critical # pause only for high-impact decisions; routine choices stay autonomous
341
346
  # Optional: add --max=20 only when this run should stop after a bounded batch.
342
347
  yoke loop off . # disable
@@ -370,6 +375,49 @@ closes the mechanical false-done paths—targeted evidence, coverage review, cle
370
375
  and integrated journeys—while the project still owns the correctness of its tests and production
371
376
  observability.
372
377
 
378
+ ### Parallel workers and the quality gauntlet
379
+
380
+ `--parallel=N` dispatches dependency-ready stories concurrently. Claims carry leases, workers use
381
+ isolated worktrees, collision areas are serialized, and only a mechanically green candidate enters
382
+ the FIFO integration queue. Integration repeats the project gates against the merged tree; a worker
383
+ success can never bypass a red integrated result. `yoke loop status` reports the dispatcher,
384
+ workers, providers, worktrees, lifecycle, queue, integrations, and reopened stories.
385
+
386
+ Quality is reference-driven and opt-in. Declare what one story should match:
387
+
388
+ ```yaml
389
+ quality:
390
+ reference: { name: approved-home, source: design/home.png, kind: file }
391
+ candidate: { kind: screenshots, paths: [.yoke/proof/STORY-1/home.png] }
392
+ rubric: Match the approved layout, hierarchy, spacing, and states.
393
+ policy: blocking # or advisory
394
+ ```
395
+
396
+ Configure project defaults, then enable the gauntlet for a run:
397
+
398
+ ```yaml
399
+ quality:
400
+ enabled: false # keep opt-in, or make it the project default
401
+ policy: blocking
402
+ maxRounds: 3
403
+ maxMinutes: 60
404
+ consistencyChecks: 2
405
+ maxParallelCandidates: 2
406
+ critic: { agent: codex, model: gpt-5.6-sol } # model required for --candidates
407
+ repair: { agent: claude }
408
+ ```
409
+
410
+ ```bash
411
+ yoke loop run . --quality --quality-rounds=3 --quality-minutes=60
412
+ yoke loop run . --quality --candidates=2 # blind pairwise selection; stories need quality declarations
413
+ ```
414
+
415
+ The critic compares opaque candidate/reference labels, writes schema-validated provenance, and
416
+ cannot modify the project. Blocking findings enter a bounded repair loop and rerun every mechanical
417
+ gate; advisory findings are retained without blocking. `--quality-policy=`, `--no-quality`, and
418
+ `--quality-unbounded` override defaults for one run. Unbounded mode is explicit and warned because
419
+ it removes repair limits, not Yoke's watchdog, isolation, verification, or commit safety.
420
+
373
421
  State lives **outside the model context** — the PRD file plus git — so each iteration is fresh.
374
422
  Use `yoke change add` at any time. Its ignored append-only inbox is consumed at the next story
375
423
  boundary. A separate coverage pass must confirm that every requested outcome maps to behavioral
@@ -393,6 +441,8 @@ Every iteration emits token-free, harness-side feedback (Node console + local fi
393
441
  implementing · iteration 20 · 19/45 (42%) · updated 30s ago
394
442
  ~1h44m remaining (Ø 4m/story)
395
443
  ```
444
+ - **Parallel + quality detail** — active workers include provider, candidate ID, worktree,
445
+ lifecycle, phase, quality round, and repair budget; the integrator is shown separately.
396
446
  - **`.yoke/loop.log`** — an append-only timeline of every phase transition.
397
447
  - **`--json`** — machine mode for supervisors: every status write is *also* emitted as one
398
448
  NDJSON line on stdout (`{"type":"status","state":"running","phase":"verifying",…}` — the
@@ -561,10 +611,11 @@ stale takeover is serialized by `.yoke/loop.lock.takeover`. A second invocation
561
611
  `Another loop is already running here (pid …). If that is wrong, run: yoke loop cleanup`. A lock
562
612
  whose holder process is dead is taken over automatically (with a warning).
563
613
 
564
- **`yoke loop cleanup [dir]`** removes what a crashed loop leaves behind: every worktree under
565
- `.yoke/worktrees/` (via `git worktree remove --force` + `prune` — user-created worktrees are
566
- never touched) and a **stale** lock file. A live lock is reported and left alone. Exits `0`
567
- when everything cleaned, `1` if any removal failed. If a machine/process crash leaves the cleanup
614
+ **`yoke loop cleanup [dir]`** reaps only runner process trees recorded by this project and removes
615
+ a stale lock. Yoke-created worktrees are **retained by default** and listed in the output; pass
616
+ `--remove-worktrees` to remove `.yoke/worktrees/*` with `git worktree remove --force` + `prune`.
617
+ User-created worktrees are never touched. A live lock is reported and left alone. Exits `0` when
618
+ cleanup succeeds, `1` if any requested removal fails. If a machine/process crash leaves the cleanup
568
619
  recovery lease itself behind, an operator can run
569
620
  `yoke loop cleanup . --discard-stale-recovery`; Yoke refuses while its recorded PID is alive, and
570
621
  the force flag must not be run concurrently.
@@ -725,6 +776,7 @@ src/
725
776
  change/ # append-only change inbox · planning · independent coverage review
726
777
  retrofit/ # detect · plan · apply · planners (claude/codex/gemini) · tools
727
778
  loop/ # prd · gates · runner · verify · git/worktree · loop · run-command · lock · cleanup
779
+ quality/ # reference collection · blind critic · bounded repair · candidate comparison
728
780
  new/ # yoke new — greenfield bootstrap
729
781
  prd/ # yoke prd draft|check — idea → stories + lint gate
730
782
  review/ # yoke review — cross-model diff gate
@@ -736,14 +788,14 @@ docs/superpowers/ # the spec and every component's implementation plan
736
788
 
737
789
  ## 🗺️ Roadmap
738
790
 
739
- Yoke 1.1's completed release work moved to the changelog. Remaining, explicitly scoped work
740
- is tracked in [`TODOS.md`](TODOS.md), including provider subprocess wiring for the tested
741
- parallel dispatcher, broader benchmark samples, native output schemas, and release provenance.
791
+ Completed release work lives in the changelog. Remaining, explicitly scoped work is tracked in
792
+ [`TODOS.md`](TODOS.md), including broader benchmark samples, native output schemas, and signed
793
+ release provenance.
742
794
 
743
795
  ## 🧪 Development
744
796
 
745
797
  ```bash
746
- npm test # vitest (657 tests)
798
+ npm test # vitest (928 tests)
747
799
  npm run build # tsc, no emit errors
748
800
  npm run yoke -- validate canon
749
801
  ```
package/TODOS.md CHANGED
@@ -1,8 +1,5 @@
1
1
  # Yoke follow-up work
2
2
 
3
- - Wire the tested async parallel dispatcher to provider subprocess workers. Until then the CLI
4
- rejects `--parallel=N` for `N > 1`; scheduler, claims, and merge queue APIs are available
5
- without claiming a CLI speed-up.
6
3
  - Add provider-native output schemas when all three CLIs expose compatible stable APIs.
7
4
  - Expand benchmark fixtures and collect multiple authenticated samples per provider/model.
8
5
  - Add signed provenance and attestations to npm and GitHub releases.
@@ -4,13 +4,21 @@ The autonomous loop is optional and toggle-able:
4
4
 
5
5
  - `yoke loop on` / `yoke loop off` — enable or disable it in `.yoke/config.yaml`.
6
6
  - `yoke loop status` — show enabled state and backlog progress.
7
- - `yoke loop run [--max=N] [--isolate] [--decision-policy=auto|critical]` — run until the current backlog is green or a gate blocks.
7
+ - `yoke loop run [--max=N] [--parallel=N] [--isolate] [--decision-policy=auto|critical] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=blocking|advisory] [--quality-unbounded] [--candidates=N]` — run until the current backlog is green or a gate blocks.
8
8
  - `yoke change add --idea="..."` — queue a product change at any time, including while the loop is running.
9
9
  - `yoke loop decision` / `yoke loop answer --choice=<id>` — inspect and answer a structured critical stop.
10
10
 
11
11
  Pass `--isolate` to implement each story in a fresh git worktree. Only a verified, committed
12
12
  story is fast-forwarded to the main tree. Pass `--review` or `--reviewer=<provider>` to require
13
- a separate, schema-validated review. Pass `--json` for NDJSON status on stdout.
13
+ a separate, schema-validated review. Pass `--parallel=N` to dispatch ready, non-colliding stories
14
+ concurrently. Pass `--json` for NDJSON status on stdout.
15
+
16
+ Stories may declare a reference, candidate artifact, rubric, and blocking/advisory quality policy.
17
+ `--quality` runs a read-only blind critic plus bounded repair before review; every repair reruns the
18
+ mechanical gates. `--candidates=N` requires quality declarations and dispatches multiple isolated
19
+ implementations, rejects mechanically red candidates, selects one green candidate through opaque
20
+ pairwise handles, and retains every candidate's terminal proof before cleanup. Parallel/candidate
21
+ runs do not combine with adaptive routing.
14
22
 
15
23
  At every story boundary, Yoke consumes at most one queued change. The configured Claude,
16
24
  Codex, or Gemini provider may propose only new stories in a separate runtime file. A fresh
@@ -21,7 +29,8 @@ stories untouched. The request stays pending on any failure or uncovered outcome
21
29
  For each story:
22
30
 
23
31
  1. Require a clean git worktree.
24
- 2. Pick the highest-priority ready unfinished story.
32
+ 2. Pick the highest-priority ready unfinished story, or claim multiple dependency-ready stories
33
+ whose collision areas do not overlap when parallel dispatch is enabled.
25
34
  3. Stop the line if acceptance is empty. With `verify.requireCriteria: true`, every criterion
26
35
  must be structured. Every structured criterion, including in compatible legacy projects,
27
36
  must use a single approved test command containing its criterion ID and no shell operators.
@@ -30,16 +39,21 @@ For each story:
30
39
  material cost, compliance, or irreversible choices may pause for a human decision.
31
40
  5. Run every structured criterion's targeted commands and write
32
41
  `.yoke/proof/<story>/evidence.json`. Then run project-wide `verify.command` (or detected
33
- `npm test`). Performance, audit, and independent review gates follow when configured. Any
34
- failure blocks, and no proof command runs after review.
35
- 6. Only after all gates pass, mark the story `passes: true`, log the decision, and commit
42
+ `npm test`). Performance and audit follow when configured. If quality is enabled, collect the
43
+ declared artifact, run the blind critic, and repair within the configured round/time bounds.
44
+ Independent review follows. Any blocking failure stops the candidate.
45
+ 6. Parallel workers enqueue green candidate commits. The integrator applies one at a time and
46
+ reruns mechanical gates, fresh quality, and review against the integrated tree. Failed
47
+ integration reopens the story and retains proof.
48
+ 7. Only after all gates pass, mark the story `passes: true`, log the decision, and commit
36
49
  atomically. A failed commit restores the PRD state.
37
- 7. When all current stories pass, run optional `completion.command` against the integrated
50
+ 8. When all current stories pass, run optional `completion.command` against the integrated
38
51
  system. Only a green result reports `complete`; otherwise the loop blocks. This readiness
39
52
  result is ephemeral, not a release and not a freeze on future changes.
40
53
 
41
54
  A supervisor can pause the loop by creating `.yoke/loop.pause`. The running story finishes;
42
- the signal is consumed at the next story boundary and the process exits with code `3`.
55
+ the dispatcher latches the signal, stops launching new workers, lets active workers reach safe
56
+ terminal proof/cleanup, and exits with code `3` before another story is integrated.
43
57
 
44
58
  State lives outside model context: PRD, git, and the ignored `.yoke/changes/` inbox. All are
45
59
  re-read at story boundaries, so a request queued mid-run becomes additional stories without a
@@ -1,5 +1,5 @@
1
1
  name: yoke-canon
2
- version: 1.2.0
2
+ version: 1.4.0
3
3
  agents: [claude, codex, gemini]
4
4
  skills:
5
5
  - { id: tdd, path: skills/tdd, kind: methodology }
@@ -0,0 +1,50 @@
1
+ import { z } from 'zod';
2
+ export const AgentSchema = z.enum(['claude', 'codex', 'gemini']);
3
+ export const PermissionProfileSchema = z.enum(['safe', 'unsafe', 'read-only']);
4
+ export const ModelSelectionSchema = z.object({
5
+ model: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/).optional(),
6
+ reasoningEffort: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/).optional(),
7
+ nativeMultiAgent: z.boolean().optional(),
8
+ bare: z.boolean().optional(),
9
+ });
10
+ export const AgentInvocationSchema = z.object({
11
+ command: z.string().min(1),
12
+ args: z.array(z.string()),
13
+ input: z.string(),
14
+ cwd: z.string().min(1),
15
+ });
16
+ const ProviderTokenUsageSchema = z.object({
17
+ inputTokens: z.number().nonnegative(),
18
+ cachedInputTokens: z.number().nonnegative().optional(),
19
+ cacheWriteInputTokens: z.number().nonnegative().optional(),
20
+ outputTokens: z.number().nonnegative(),
21
+ reasoningOutputTokens: z.number().nonnegative().optional(),
22
+ totalCostUsd: z.number().nonnegative().optional(),
23
+ model: z.string().min(1).optional(),
24
+ });
25
+ export const ProviderTelemetrySchema = z.object({
26
+ usageAvailable: z.boolean(),
27
+ tokens: ProviderTokenUsageSchema.optional(),
28
+ }).superRefine((telemetry, ctx) => {
29
+ if (telemetry.usageAvailable && !telemetry.tokens) {
30
+ ctx.addIssue({ code: 'custom', path: ['tokens'], message: 'usageAvailable telemetry requires token totals' });
31
+ }
32
+ });
33
+ const MachineRoleSchema = z.enum([
34
+ 'route',
35
+ 'review',
36
+ 'quality',
37
+ 'decomposition',
38
+ 'candidate-selection',
39
+ 'telemetry',
40
+ ]);
41
+ export const MachineEnvelopeSchema = z.object({
42
+ schemaVersion: z.literal(1),
43
+ provider: AgentSchema,
44
+ model: z.string().min(1).optional(),
45
+ role: MachineRoleSchema,
46
+ durationMs: z.number().int().nonnegative(),
47
+ permissions: PermissionProfileSchema,
48
+ usage: ProviderTokenUsageSchema.optional(),
49
+ raw: z.record(z.unknown()).optional(),
50
+ });
@@ -0,0 +1,15 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ const queryProcessIdentity = (command, args, options) => execFileSync(command, args, { stdio: 'pipe', ...options }).toString();
3
+ export function processIncarnation(pid, platform = process.platform, query = queryProcessIdentity) {
4
+ try {
5
+ if (platform === 'win32') {
6
+ const output = query('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', '(Get-CimInstance Win32_Process -Filter "ProcessId = $env:YOKE_PROCESS_PID").CreationDate'], { env: { ...process.env, YOKE_PROCESS_PID: String(pid) } }).trim();
7
+ return output ? `win32:${output}` : undefined;
8
+ }
9
+ const output = query('ps', ['-o', 'lstart=', '-p', String(pid)]).trim();
10
+ return output ? `posix:${output}` : undefined;
11
+ }
12
+ catch {
13
+ return undefined;
14
+ }
15
+ }
@@ -0,0 +1,65 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { linkSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ export function createProviderProcessRecord(targetDir, childPid, workerId, startedAt = new Date().toISOString()) {
5
+ const worker = workerId?.replace(/[^A-Za-z0-9_-]/gu, '_') || 'call';
6
+ return {
7
+ path: join(targetDir, '.yoke', 'provider-processes', `${worker}-${randomUUID()}.json`),
8
+ version: 1,
9
+ owner: 'provider-process',
10
+ targetDir,
11
+ childPid,
12
+ startedAt,
13
+ ...(workerId ? { workerId } : {}),
14
+ };
15
+ }
16
+ export const filesystemProviderProcessRecordAdapter = {
17
+ publish(record) {
18
+ const directory = join(record.targetDir, '.yoke', 'provider-processes');
19
+ mkdirSync(directory, { recursive: true });
20
+ const temporary = `${record.path}.${randomUUID()}.tmp`;
21
+ writeFileSync(temporary, JSON.stringify({
22
+ version: record.version,
23
+ owner: record.owner,
24
+ targetDir: record.targetDir,
25
+ childPid: record.childPid,
26
+ startedAt: record.startedAt,
27
+ ...(record.workerId ? { workerId: record.workerId } : {}),
28
+ }), { flag: 'wx' });
29
+ try {
30
+ linkSync(temporary, record.path);
31
+ }
32
+ finally {
33
+ rmSync(temporary, { force: true });
34
+ }
35
+ },
36
+ remove(path) {
37
+ rmSync(path, { force: true });
38
+ },
39
+ };
40
+ function isRecord(value) {
41
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
42
+ }
43
+ export function parseProviderProcessRecord(value) {
44
+ if (!isRecord(value))
45
+ return null;
46
+ const workerId = value.workerId;
47
+ if (value.version !== 1 ||
48
+ value.owner !== 'provider-process' ||
49
+ typeof value.targetDir !== 'string' ||
50
+ typeof value.childPid !== 'number' ||
51
+ !Number.isInteger(value.childPid) ||
52
+ value.childPid <= 0 ||
53
+ typeof value.startedAt !== 'string' ||
54
+ value.startedAt.length === 0 ||
55
+ (workerId !== undefined && typeof workerId !== 'string'))
56
+ return null;
57
+ return {
58
+ version: 1,
59
+ owner: 'provider-process',
60
+ targetDir: value.targetDir,
61
+ childPid: value.childPid,
62
+ startedAt: value.startedAt,
63
+ ...(typeof workerId === 'string' ? { workerId } : {}),
64
+ };
65
+ }
@@ -0,0 +1,40 @@
1
+ import { parseProviderTelemetry } from './telemetry.js';
2
+ export function createBoundedOutput(limitBytes) {
3
+ let text = '';
4
+ let truncated = false;
5
+ return {
6
+ append(next) {
7
+ const combined = `${text}${next}`;
8
+ if (Buffer.byteLength(combined) <= limitBytes) {
9
+ text = combined;
10
+ return;
11
+ }
12
+ text = Buffer.from(combined).subarray(-limitBytes).toString('utf8');
13
+ truncated = true;
14
+ },
15
+ get text() { return text; },
16
+ get truncated() { return truncated; },
17
+ };
18
+ }
19
+ export function createTelemetryAccumulator(agent) {
20
+ let trailing = '';
21
+ let telemetry = { usageAvailable: false };
22
+ const update = (lines) => {
23
+ const next = parseProviderTelemetry(agent, [...lines]);
24
+ if (next.usageAvailable)
25
+ telemetry = next;
26
+ };
27
+ return {
28
+ append(text) {
29
+ const parts = `${trailing}${text}`.split(/\r?\n/u);
30
+ trailing = parts.pop() ?? '';
31
+ update(parts);
32
+ },
33
+ finish() {
34
+ if (trailing)
35
+ update([trailing]);
36
+ trailing = '';
37
+ return telemetry;
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,177 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { resolve } from 'node:path';
3
+ import { killProcessTreeForCleanup } from '../loop/watchdog.js';
4
+ import { createProviderProcessRecord, filesystemProviderProcessRecordAdapter, } from './process-record.js';
5
+ import { createBoundedOutput, createTelemetryAccumulator } from './process-streams.js';
6
+ import { processIncarnation } from './process-incarnation.js';
7
+ function cancellationReason(signal) {
8
+ return typeof signal.reason === 'string' && signal.reason.length > 0
9
+ ? signal.reason
10
+ : 'provider process cancellation requested';
11
+ }
12
+ export function providerSpawnOptions(invocation, platform = process.platform) {
13
+ const windowsCommandShim = !/[\\/]/u.test(invocation.command) || /\.(?:bat|cmd)$/iu.test(invocation.command);
14
+ return {
15
+ command: invocation.command,
16
+ args: invocation.args,
17
+ cwd: invocation.cwd,
18
+ shell: platform === 'win32' && windowsCommandShim,
19
+ detached: platform !== 'win32',
20
+ };
21
+ }
22
+ export function startProviderProcess(agent, invocation, options = {}) {
23
+ const spawnOptions = providerSpawnOptions(invocation);
24
+ const child = spawn(spawnOptions.command, [...spawnOptions.args], {
25
+ cwd: spawnOptions.cwd,
26
+ shell: spawnOptions.shell,
27
+ stdio: ['pipe', 'pipe', 'pipe'],
28
+ detached: spawnOptions.detached,
29
+ });
30
+ const targetDir = resolve(invocation.cwd);
31
+ const pid = child.pid;
32
+ const startedAt = pid === undefined ? `unverified:${new Date().toISOString()}` : processIncarnation(pid) ?? `unverified:${new Date().toISOString()}`;
33
+ const record = createProviderProcessRecord(targetDir, pid ?? 0, options.workerId, startedAt);
34
+ const recordAdapter = options.recordAdapter ?? filesystemProviderProcessRecordAdapter;
35
+ const terminateProcessTree = options.terminateProcessTree ?? ((processPid) => killProcessTreeForCleanup(processPid));
36
+ let recordPublished = false;
37
+ const stdout = createBoundedOutput(options.outputLimitBytes ?? 1_048_576);
38
+ const stderr = createBoundedOutput(options.outputLimitBytes ?? 1_048_576);
39
+ const telemetry = createTelemetryAccumulator(agent);
40
+ const idleTimeoutMs = options.idleTimeoutMs ?? 0;
41
+ const terminationGraceMs = options.terminationGraceMs ?? 5_000;
42
+ let termination;
43
+ let idleTimer;
44
+ let forceTimer;
45
+ let recordFailure;
46
+ let terminationConfirmed = false;
47
+ let settled = false;
48
+ let resolveCompletion = () => { };
49
+ const completion = new Promise(resolveCompletionValue => {
50
+ resolveCompletion = resolveCompletionValue;
51
+ });
52
+ const removeRecord = () => {
53
+ if (recordPublished)
54
+ recordAdapter.remove(record.path);
55
+ };
56
+ const clearTimers = () => {
57
+ if (idleTimer)
58
+ clearTimeout(idleTimer);
59
+ if (forceTimer)
60
+ clearTimeout(forceTimer);
61
+ idleTimer = undefined;
62
+ forceTimer = undefined;
63
+ };
64
+ const finish = (result) => {
65
+ if (settled)
66
+ return;
67
+ settled = true;
68
+ clearTimers();
69
+ options.signal?.removeEventListener('abort', onAbort);
70
+ if (!termination || terminationConfirmed)
71
+ removeRecord();
72
+ resolveCompletion(result);
73
+ };
74
+ const evidence = () => ({
75
+ invocation,
76
+ pid,
77
+ stdout: stdout.text,
78
+ stderr: stderr.text,
79
+ stdoutTruncated: stdout.truncated,
80
+ stderrTruncated: stderr.truncated,
81
+ telemetry: telemetry.finish(),
82
+ });
83
+ const finalize = (exitCode) => {
84
+ const details = evidence();
85
+ if (recordFailure) {
86
+ finish({ ...details, kind: 'spawn-failed', error: recordFailure });
87
+ return;
88
+ }
89
+ if (termination?.kind === 'timed-out') {
90
+ finish({ ...details, kind: 'timed-out', reason: termination.reason });
91
+ return;
92
+ }
93
+ if (termination?.kind === 'cancelled') {
94
+ finish({ ...details, kind: 'cancelled', reason: termination.reason });
95
+ return;
96
+ }
97
+ if (exitCode === 0) {
98
+ finish({ ...details, kind: 'succeeded', exitCode });
99
+ return;
100
+ }
101
+ finish({ ...details, kind: 'failed', exitCode });
102
+ };
103
+ const terminate = (next) => {
104
+ if (termination || settled)
105
+ return false;
106
+ termination = next;
107
+ if (pid !== undefined)
108
+ terminationConfirmed = terminateProcessTree(pid, false);
109
+ forceTimer = setTimeout(() => {
110
+ if (pid !== undefined && !settled)
111
+ terminationConfirmed = terminateProcessTree(pid, true);
112
+ }, terminationGraceMs);
113
+ return true;
114
+ };
115
+ const armIdleTimer = () => {
116
+ if (idleTimeoutMs <= 0 || termination || settled)
117
+ return;
118
+ if (idleTimer)
119
+ clearTimeout(idleTimer);
120
+ idleTimer = setTimeout(() => {
121
+ terminate({ kind: 'timed-out', reason: 'provider process produced no output before its idle timeout' });
122
+ }, idleTimeoutMs);
123
+ };
124
+ const onAbort = () => {
125
+ if (options.signal)
126
+ terminate({ kind: 'cancelled', reason: cancellationReason(options.signal) });
127
+ };
128
+ const onOutput = (stream, chunk) => {
129
+ const text = String(chunk);
130
+ if (stream === 'stdout') {
131
+ stdout.append(text);
132
+ telemetry.append(text);
133
+ }
134
+ else {
135
+ stderr.append(text);
136
+ }
137
+ options.onOutput?.({ stream, text });
138
+ armIdleTimer();
139
+ };
140
+ child.stdout?.on('data', chunk => { onOutput('stdout', chunk); });
141
+ child.stderr?.on('data', chunk => { onOutput('stderr', chunk); });
142
+ child.stdin?.on('error', () => { });
143
+ child.on('close', code => { finalize(code); });
144
+ child.on('error', error => {
145
+ finish({ ...evidence(), kind: 'spawn-failed', error: recordFailure ?? error.message });
146
+ });
147
+ if (pid !== undefined) {
148
+ try {
149
+ recordAdapter.publish(record);
150
+ recordPublished = true;
151
+ }
152
+ catch (error) {
153
+ const reason = error instanceof Error ? error.message : String(error);
154
+ recordFailure = `process ownership record failure: ${reason}`;
155
+ child.stdin?.end();
156
+ terminate({ kind: 'cancelled', reason: recordFailure });
157
+ }
158
+ }
159
+ const handle = {
160
+ pid,
161
+ invocation,
162
+ recordPath: record.path,
163
+ completion,
164
+ cancel(reason) {
165
+ return terminate({ kind: 'cancelled', reason });
166
+ },
167
+ };
168
+ if (recordFailure)
169
+ return handle;
170
+ child.stdin?.end(invocation.input);
171
+ if (options.signal?.aborted)
172
+ onAbort();
173
+ else
174
+ options.signal?.addEventListener('abort', onAbort, { once: true });
175
+ armIdleTimer();
176
+ return handle;
177
+ }