@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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +22 -0
- package/README.md +67 -15
- package/TODOS.md +0 -3
- package/canon/loop/loop-spec.md +22 -8
- package/canon/manifest.yaml +1 -1
- package/dist/agents/contracts.js +50 -0
- package/dist/agents/process-incarnation.js +15 -0
- package/dist/agents/process-record.js +65 -0
- package/dist/agents/process-streams.js +40 -0
- package/dist/agents/process.js +177 -0
- package/dist/agents/providers.js +10 -7
- package/dist/agents/telemetry.js +62 -0
- package/dist/cli.js +55 -3
- package/dist/loop/candidate-boundaries.js +43 -0
- package/dist/loop/candidate-cleanup.js +98 -0
- package/dist/loop/candidate-contracts.js +1 -0
- package/dist/loop/candidate-selection.js +84 -0
- package/dist/loop/candidates.js +228 -0
- package/dist/loop/claim-lease.js +131 -0
- package/dist/loop/claims.js +177 -40
- package/dist/loop/cleanup.js +117 -15
- package/dist/loop/decision.js +31 -0
- package/dist/loop/dispatcher.js +334 -0
- package/dist/loop/loop.js +109 -16
- package/dist/loop/merge-queue.js +12 -6
- package/dist/loop/parallel-adapters.js +185 -0
- package/dist/loop/parallel-command.js +287 -0
- package/dist/loop/parallel.js +2 -4
- package/dist/loop/prd.js +4 -1
- package/dist/loop/reporter.js +86 -5
- package/dist/loop/run-command.js +204 -51
- package/dist/loop/runner.js +67 -32
- package/dist/loop/watchdog.js +67 -8
- package/dist/loop/worker-cancellation.js +17 -0
- package/dist/loop/worker-cleanup.js +23 -0
- package/dist/loop/worker-contracts.js +1 -0
- package/dist/loop/worker.js +254 -0
- package/dist/quality/artifacts.js +59 -0
- package/dist/quality/candidate-comparison.js +130 -0
- package/dist/quality/command.js +316 -0
- package/dist/quality/loop.js +86 -0
- package/dist/quality/process-command.js +57 -0
- package/dist/quality/reference.js +187 -0
- package/dist/quality/repair.js +11 -0
- package/dist/quality/runner.js +66 -0
- package/dist/quality/types.js +60 -0
- package/dist/quality/verdict.js +142 -0
- package/dist/retrofit/config.js +4 -2
- package/dist/retrofit/gitignore.js +3 -0
- package/dist/review/command.js +27 -38
- package/dist/review/verdict.js +38 -7
- package/docs/MIGRATING-TO-1.4.md +70 -0
- package/docs/PUBLISHING.md +16 -2
- package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -0
- package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
- package/gemini-extension.json +1 -1
- 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.
|
|
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",
|
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.
|
|
6
|
-
<!-- yoke:tests:start -->
|
|
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)
|
|
18
18
|

|
|
19
19
|

|
|
20
|
-

|
|
21
21
|

|
|
22
22
|

|
|
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
|
|
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 —
|
|
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`
|
|
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]`**
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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 (
|
|
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.
|
package/canon/loop/loop-spec.md
CHANGED
|
@@ -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 `--
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/canon/manifest.yaml
CHANGED
|
@@ -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
|
+
}
|