@hecer/yoke 1.16.0 → 1.18.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.
@@ -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.16.0",
5
+ "version": "1.18.0",
6
6
  "description": "Cross-agent coding harness for Claude, Codex, Gemini, Qwen, OpenCode, Kilo, Pi and Hermes: one curated skill canon 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.16.0",
3
+ "version": "1.18.0",
4
4
  "description": "Cross-agent coding discipline, mechanical gates, and release workflows for eight supported harnesses",
5
5
  "skills": "./canon/skills/",
6
6
  "hooks": "./hooks/hooks.json"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.18.0 — 2026-09-27
4
+
5
+ ### Added
6
+ - Add opt-in continuous exploration with evidence-filtered task discovery, validated PRD additions, isolated implementation, bounded history compaction, automatic provider recovery, stop detection, and `yoke loop pause`.
7
+ - Add optional `--explore-limit=<Nh|Nd|Nw>` durations. Exploration remains unbounded when omitted; when the limit expires, Yoke stops launching work and pauses after active workers finish their normal gates and integration.
8
+
9
+ ### Changed
10
+ - Keep exploration status and narrative focused on newly accepted work, the next planned action, provider recovery, and safe stop state.
11
+ - Process finite-duration runs in bounded task batches so the supervisor can honor the deadline while retaining configured parallel workers.
12
+
13
+ ### Migration and validation limits
14
+ - Exploration is opt-in and requires isolated story worktrees. Use `--explore-limit=12h`, `3d`, or `2w` to bound one invocation; expiry exits with code `3`, and a later resume starts a new duration. Without a limit or a user pause, it continues while the process and machine remain available.
15
+ - Auto-discovered work is limited to repository-evidenced proposals that pass confidence, risk, write-scope, acceptance-criteria and criterion-test checks. This does not guarantee project maturity or model quality; provider credentials, configured gates and machine availability still determine progress.
16
+ - A time limit does not interrupt an active story or integration. Active work finishes through the normal gates before the loop stops, so wall-clock completion can exceed the requested duration by the time needed for that safe boundary.
17
+
18
+ ## 1.17.0 — 2026-09-27
19
+
20
+ ### Added
21
+ - Coordinate weighted worker reservations across concurrent Yoke projects under a user-local pool. The default is three units, `YOKE_MAX_PARALLEL_WORKERS` accepts 1–8, and candidate races consume one unit per simultaneous candidate.
22
+ - Add `yoke prd decompose --story=<id>` as a preview-first planner for splitting eligible stories into two independently scheduled children; `--apply` atomically replaces the parent and rewrites downstream dependencies after hash and schema checks.
23
+ - Report shared capacity and resource waits in loop status and the dashboard. Record integration queue wait and duration, and forecast dependency-aware implementation work alongside a serialized integration lane.
24
+ - Add a deterministic local dispatcher benchmark matrix for dependency chains, independent scopes and conflicting scopes.
25
+
26
+ ### Changed
27
+ - Separate implementation slots from each project's integration queue. A completed candidate frees its implementation slot while retaining collision areas and write scopes through rebase, integrated-tree gates, commit and cleanup.
28
+ - Keep single-worker runs on the serial path and reserve a shared unit around each agent invocation; use the dispatcher for multiple workers and competing candidate runs.
29
+ - Cap explicit and configured per-project concurrency at eight. Automatic concurrency remains conservative and is now bounded by the shared cross-project limit.
30
+
31
+ ### Fixed
32
+ - Release the shared claim-operation lease when pool-state parsing throws, so corrupt records fail closed without leaving later pool access stuck behind a leaked lock.
33
+
34
+ ### Migration and validation limits
35
+ - Existing `loop.parallel` values above 8 must be lowered. The default shared cap can queue previously simultaneous project loops; set `YOKE_MAX_PARALLEL_WORKERS` to 1–8 for the desired user-level ceiling. Existing active workers are not cancelled when the cap is lowered.
36
+ - Decomposition requires at least four structured criteria, at least two non-overlapping write scopes and no shared area. Child assessments/quality declarations need task-specific refresh; write scopes remain advisory.
37
+ - Synthetic benchmark results use fixed local delays and make no model quality, token or provider-cost claims. Local release checks: 1,296 tests passed, two skipped across 143 files; lint/build, docs checks, package dry run, Canon validation and dependency audit passed.
38
+
3
39
  ## 1.16.0 — 2026-09-17
4
40
 
5
41
  ### Added
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  <div align="center">
2
2
 
3
- <h1><img src="https://raw.githubusercontent.com/HECer/yoke/v1.16.0/docs/assets/yoke-logo.png" alt="Yoke" width="100" height="63"></h1>
3
+ <h1><img src="https://raw.githubusercontent.com/HECer/yoke/v1.18.0/docs/assets/yoke-logo.png" alt="Yoke" width="100" height="63"></h1>
4
4
 
5
- <!-- yoke:version:start -->1.16.0<!-- yoke:version:end -->
6
- <!-- yoke:tests:start -->1287<!-- yoke:tests:end -->
5
+ <!-- yoke:version:start -->1.18.0<!-- yoke:version:end -->
6
+ <!-- yoke:tests:start -->1313<!-- yoke:tests:end -->
7
7
  <!-- yoke:skills:start -->34<!-- yoke:skills:end -->
8
8
  <!-- yoke:agents:start -->Claude | Codex | Gemini | Qwen | OpenCode | Kilo | Pi | Hermes<!-- 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-1287%20defined-blue.svg)
20
+ ![Tests](https://img.shields.io/badge/tests-1313%20defined-blue.svg)
21
21
  ![Agents](https://img.shields.io/badge/agents-Claude%20%7C%20Codex%20%7C%20Gemini%20%7C%20Qwen%20%7C%20OpenCode%20%7C%20Kilo%20%7C%20Pi%20%7C%20Hermes-8A2BE2)
22
22
  ![Built with TDD](https://img.shields.io/badge/built%20with-TDD%20%2B%20review-ff69b4.svg)
23
23
 
@@ -69,7 +69,7 @@ in private, content-addressed local artifacts. Existing projects keep their seri
69
69
  safe 2 KiB preview / 8 KiB artifact defaults unless configured otherwise.
70
70
 
71
71
  Yoke 1.4 introduced opt-in parallel workers and a bounded, reference-driven quality gauntlet.
72
- Yoke 1.8.0 uses automatic parallelism for tasks with declared write scopes. See [the 1.4 migration guide](docs/MIGRATING-TO-1.4.md)
72
+ Automatic parallelism was introduced in Yoke 1.8.0. This unreleased branch adds a shared cross-project worker pool. See [the 1.4 migration guide](docs/MIGRATING-TO-1.4.md)
73
73
  for the new flags, configuration, cleanup behavior, and review-verdict contract.
74
74
 
75
75
  Yoke 1.1 is safe-by-default: provider CLIs use autonomous sandbox profiles unless `--unsafe`
@@ -198,6 +198,19 @@ Yoke is meant to be operated *by* your coding agent — after a retrofit, the ag
198
198
 
199
199
  > ⚠️ **Long runs from inside an agent session:** `yoke loop run` has no story cap by default; it continues until every planned story passes or a gate blocks. A multi-story run can therefore outlive most agents' shell-tool timeouts (Claude Code's Bash tool defaults to 2 minutes). If the outer tool call is killed mid-run, you get a stale lock and possibly half-finished state — which *looks* like a hang. Run the loop **in the background** (e.g. Claude Code's `run_in_background`), use `--max=3..5` only when you intentionally want a bounded batch, poll with `yoke loop status`, and after any interrupted run do `yoke loop cleanup` before the next one. A `running` status with no update for 20+ minutes on a claude runner is worth checking — since 0.5.0 the runner streams continuously, so prolonged true silence is no longer normal.
200
200
 
201
+ ### Optional continuous exploration
202
+
203
+ `yoke loop run . --explore` keeps the supervisor alive after the PRD drains. It periodically scans
204
+ for evidence-backed, testable improvements, adds only bounded tasks that pass strict contract checks,
205
+ then implements them through the same isolated loop gates. `--explore-interval=10` changes the
206
+ default 30-minute rescan interval. Exploration runs indefinitely by default; set `--explore-limit=12h`,
207
+ `--explore-limit=3d`, or `--explore-limit=2w` to stop automatically after hours, days, or weeks. At
208
+ expiry, Yoke pauses at a safe task boundary, lets active workers finish their gates and integration,
209
+ and exits with code `3`; run it again to resume. `yoke loop pause .` also stops at a safe boundary.
210
+ Failed providers or stories are retried with backoff, and live status heartbeats while the supervisor
211
+ waits. Use `--max=N` for an intentional story-attempt cap. See the [continuous exploration guide](docs/CONTINUOUS-EXPLORATION.md)
212
+ for task validation, history compaction, recovery and process-lifetime limits.
213
+
201
214
  > ⚠️ **Never kill agent processes by name or command-line pattern** (e.g. every process matching `dangerously-skip-permissions`): on a machine running several yoke projects, that takes down the *healthy* runners of the other projects mid-story — they stall and their loops block. `yoke loop cleanup` is the scoped alternative: each watchdog records its pids in the project's `.yoke/runner.pid`, and cleanup kills exactly those recorded trees — nothing else on the machine.
202
215
 
203
216
  ### Agent cheat sheet — every command is an exit-code contract
@@ -219,7 +232,7 @@ Yoke's CLI is deterministic and chainable by design: an agent (or a shell `&&`)
219
232
  | `yoke prd check [dir]` | PRD lint gate (schema, dependencies, cycles, duplicate ids, acceptance) | `0` valid · `1` violations |
220
233
  | `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 |
221
234
  | `yoke context init\|status [dir]` | Durable context layer (`PROJECT/DECISIONS/KNOWLEDGE/GLOSSARY.md`, optional `CONTEXT-MAP.md`) | `0` |
222
- | `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 |
235
+ | `yoke loop on\|off\|status\|pause\|decision\|answer\|resume\|run\|cleanup [dir]` | Autonomous loop; `run --explore` opts into continuous discovery, implementation and recovery after a backlog drains; `--parallel=N`, bounded reference-driven `--quality`, and blind `--candidates=N` selection remain available; `--max=N` is an intentional cap; `pause` requests a safe-boundary stop | run: `0` complete · `1` blocked/cap · `2` not runnable / already locked · `3` paused |
223
236
  | `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 |
224
237
  | `yoke audit [dir] [--json]` | Dependency, high-confidence secret, and sensitive-change audit | `0` green · `1` blocking findings · `2` not runnable |
225
238
  | `yoke design-scan [dir] [--max=N] [--report]` | Static AI-slop design gate | `0` within budget · `1` over |
@@ -464,11 +477,15 @@ observability.
464
477
 
465
478
  ### Parallel workers and the quality gauntlet
466
479
 
467
- `--parallel=N` dispatches dependency-ready stories concurrently. Claims carry leases, workers use
468
- isolated worktrees, collision areas are serialized, and only a mechanically green candidate enters
469
- the FIFO integration queue. Integration repeats the project gates against the merged tree; a worker
470
- success can never bypass a red integrated result. `yoke loop status` reports the dispatcher,
471
- workers, providers, worktrees, lifecycle, queue, integrations, and reopened stories.
480
+ `--parallel=N` dispatches dependency-ready stories concurrently. The default maximum is three
481
+ shared worker units across Yoke projects; set `YOKE_MAX_PARALLEL_WORKERS=1..8` to change it.
482
+ `--parallel` and `loop.parallel` accept 1–8. Candidate races consume one unit per simultaneous
483
+ candidate. Claims carry leases, workers use isolated worktrees, collision areas and write scopes stay
484
+ reserved through integration, and only a mechanically green candidate enters the FIFO integration
485
+ queue. Integration has its own serialized lane, so it does not occupy an implementation slot while
486
+ unrelated work proceeds. Integrated-tree gates remain mandatory. `yoke loop status` reports local
487
+ workers, resource waits, shared capacity, integrations, and reopened stories. See
488
+ [parallel execution and safe task decomposition](docs/parallel-execution.md).
472
489
 
473
490
  Quality is reference-driven and opt-in. Declare what one story should match:
474
491
 
@@ -655,8 +672,9 @@ cannot overwrite a shared registry file.
655
672
 
656
673
  Routing is not free: stories without a matching rule can add a controller call. Measure it
657
674
  on your own backlog rather than assuming a win. Routing now also runs within asynchronous
658
- parallel workers. Automatic parallelism starts at up to three workers when pending tasks declare
659
- write scopes; unknown scopes and configured tool actions keep execution serial. Isolation is
675
+ parallel workers. Automatic parallelism uses up to the shared worker limit when pending tasks declare
676
+ write scopes; the scheduler still serializes dependencies, collision areas, and overlapping scopes.
677
+ Unknown scopes and configured tool actions keep automatic execution serial. Isolation is
660
678
  on by default. Explicit `--parallel=N`, `--no-routing` and `--no-isolate` remain available.
661
679
  See [execution defaults and dashboard measurement details](docs/VERIFIED-PROJECTS.md#execution-defaults-in-180).
662
680
 
@@ -934,7 +952,7 @@ release provenance.
934
952
  ## 🧪 Development
935
953
 
936
954
  ```bash
937
- npm test # vitest (1287 tests)
955
+ npm test # vitest (1313 tests)
938
956
  npm run build # tsc, no emit errors
939
957
  npm run yoke -- validate canon
940
958
  ```
package/bench/README.md CHANGED
@@ -42,6 +42,7 @@ node bench/run-large.mjs --seed=G:\NN-Developed\Yoke-Testground\yoke-codex-study
42
42
  node bench/analyze-routing-study.mjs
43
43
  node bench/run-matrix.mjs --label=release-1.0
44
44
  node bench/output-compaction.mjs # deterministic local gate-output benchmark; no provider call
45
+ node bench/run-parallel-matrix.mjs # synthetic dispatcher; deterministic local delays, no provider call
45
46
  ```
46
47
 
47
48
  Each run copies the fixture to `bench/.runs/<fixture>-<runner>-<routing>-<stamp>` (or the
@@ -66,6 +67,15 @@ visible test from turning into false benchmark evidence. A seed can provide `ben
66
67
  to declare its fixture identity and hidden-test files. `analyze-routing-study.mjs` validates and
67
68
  aggregates the checked-in three-pair Codex-only study.
68
69
 
70
+ ### Parallel dispatcher matrix
71
+
72
+ `run-parallel-matrix.mjs` exercises the real dispatcher with a deterministic local worker over a
73
+ dependency chain, independent write scopes and conflicting scopes at concurrency 1, 2 and 3. It
74
+ reports wall time, summed worker time, integration queue wait, integration time, attempts and
75
+ accepted results. Fixed delays make scheduler comparisons repeatable; these rows do not measure
76
+ provider latency, token cost, model quality, global cross-project contention or large-repository
77
+ work. Run `npm run build` first so `dist/loop/dispatcher.js` exists.
78
+
69
79
  ## Caveats (read before quoting numbers)
70
80
 
71
81
  - Agent runs are stochastic. Older rows are N=1; the 2026-08-02 Codex-only study uses three
package/bench/RESULTS.md CHANGED
@@ -6,6 +6,29 @@ availability, verdict/blocker, conflicts, wall time, iterations, and final fixtu
6
6
  Fixture `string-kit` (3 stories, 16 pre-written assertions). Methodology and caveats:
7
7
  [README.md](README.md). One row per run — raw JSON in [`results/`](results/).
8
8
 
9
+ ## Synthetic dispatcher matrix (2026-09-27)
10
+
11
+ One local run of `node bench/run-parallel-matrix.mjs` after the shared integration pipeline was
12
+ implemented. Each case uses four stories, fixed 120 ms worker and 60 ms integration delays, and no
13
+ provider calls. The measurements exercise dispatcher scheduling only; they are not model or
14
+ repository performance evidence.
15
+
16
+ | Scenario | Slots | Wall ms | Worker ms (summed) | Integration queue wait ms | Integration ms | Attempts | Accepted |
17
+ |---|---:|---:|---:|---:|---:|---:|---:|
18
+ | Dependency chain | 1 | 788 | 511 | 0 | 271 | 4 | 4 |
19
+ | Dependency chain | 2 | 762 | 498 | 0 | 262 | 4 | 4 |
20
+ | Dependency chain | 3 | 766 | 501 | 0 | 262 | 4 | 4 |
21
+ | Independent scopes | 1 | 566 | 494 | 0 | 274 | 4 | 4 |
22
+ | Independent scopes | 2 | 396 | 512 | 170 | 274 | 4 | 4 |
23
+ | Independent scopes | 3 | 397 | 492 | 258 | 272 | 4 | 4 |
24
+ | Conflicting scopes | 1 | 743 | 492 | 0 | 251 | 4 | 4 |
25
+ | Conflicting scopes | 2 | 753 | 492 | 0 | 262 | 4 | 4 |
26
+ | Conflicting scopes | 3 | 742 | 493 | 0 | 247 | 4 | 4 |
27
+
28
+ The independent-scope case was about 30% shorter at two or three slots in this one synthetic run.
29
+ Dependency and conflict cases remained effectively serial, as their constraints require. Repeat the
30
+ matrix before drawing conclusions from small timing differences.
31
+
9
32
  ## Runs
10
33
 
11
34
  | Date | Runner | Model (reported) | Result | Wall-clock | Input tok | Output tok | First-pass stories | src LOC |
@@ -0,0 +1,33 @@
1
+ {
2
+ "workerDurationMs": 120,
3
+ "integrationDurationMs": 60,
4
+ "scenarios": [
5
+ {
6
+ "id": "dependency-chain",
7
+ "stories": [
8
+ { "id": "chain-1", "writes": ["src/chain/one.ts"] },
9
+ { "id": "chain-2", "needs": ["chain-1"], "writes": ["src/chain/two.ts"] },
10
+ { "id": "chain-3", "needs": ["chain-2"], "writes": ["src/chain/three.ts"] },
11
+ { "id": "chain-4", "needs": ["chain-3"], "writes": ["src/chain/four.ts"] }
12
+ ]
13
+ },
14
+ {
15
+ "id": "independent-scopes",
16
+ "stories": [
17
+ { "id": "independent-1", "writes": ["src/independent/one.ts"] },
18
+ { "id": "independent-2", "writes": ["src/independent/two.ts"] },
19
+ { "id": "independent-3", "writes": ["src/independent/three.ts"] },
20
+ { "id": "independent-4", "writes": ["src/independent/four.ts"] }
21
+ ]
22
+ },
23
+ {
24
+ "id": "conflicting-scopes",
25
+ "stories": [
26
+ { "id": "conflict-1", "writes": ["src/shared/registry.ts"] },
27
+ { "id": "conflict-2", "writes": ["src/shared/registry.ts"] },
28
+ { "id": "conflict-3", "writes": ["src/shared/registry.ts"] },
29
+ { "id": "conflict-4", "writes": ["src/shared/registry.ts"] }
30
+ ]
31
+ }
32
+ ]
33
+ }
@@ -0,0 +1,71 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { performance } from 'node:perf_hooks'
3
+ import { createDispatcher } from '../dist/loop/dispatcher.js'
4
+
5
+ const fixture = JSON.parse(readFileSync(new URL('./fixtures/parallel-work/scenarios.json', import.meta.url), 'utf8'))
6
+ const delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
7
+
8
+ async function runCase(scenario, maxConcurrency) {
9
+ let workerTimeMs = 0
10
+ let integrationQueueWaitMs = 0
11
+ let integrationTimeMs = 0
12
+ const dispatcher = createDispatcher({
13
+ targetDir: process.cwd(),
14
+ stories: scenario.stories.map(story => ({ priority: 1, acceptance: [], passes: false, ...story })),
15
+ maxConcurrency,
16
+ maxIterations: Infinity,
17
+ worker: async input => {
18
+ const started = performance.now()
19
+ await delay(fixture.workerDurationMs)
20
+ workerTimeMs += performance.now() - started
21
+ return {
22
+ kind: 'candidate',
23
+ storyId: input.story.id,
24
+ worktree: input.worktree.path,
25
+ baseCommit: input.worktree.baseCommit,
26
+ provider: input.provider,
27
+ summary: 'synthetic worker complete',
28
+ evidence: { criteria: [], verify: { passed: true, summary: 'synthetic' } },
29
+ routing: { outcome: 'pending-integration', recordOutcome() {} },
30
+ }
31
+ },
32
+ claims: { acquire: () => true, heartbeat() {}, release: () => true },
33
+ worktrees: {
34
+ create: input => ({ path: `virtual/${input.story.id}`, baseCommit: `base-${input.story.id}` }),
35
+ remove() {},
36
+ },
37
+ git: {
38
+ isClean: () => true,
39
+ rebase: () => ({ kind: 'rebased', expectedHead: 'synthetic-head' }),
40
+ commit() {},
41
+ integrate: async () => { await delay(fixture.integrationDurationMs) },
42
+ },
43
+ gates: { verify: () => ({ passed: true, summary: 'synthetic gate passed' }) },
44
+ onIntegrationMetrics: (_worker, queueWait, integrationMs) => {
45
+ if (integrationMs === undefined) integrationQueueWaitMs += queueWait
46
+ else integrationTimeMs += integrationMs
47
+ },
48
+ })
49
+ const started = performance.now()
50
+ const result = await dispatcher.run()
51
+ return {
52
+ scenario: scenario.id,
53
+ workers: maxConcurrency,
54
+ wallMs: Math.round(performance.now() - started),
55
+ workerMs: Math.round(workerTimeMs),
56
+ queueWaitMs: Math.round(integrationQueueWaitMs),
57
+ integrationMs: Math.round(integrationTimeMs),
58
+ attempts: result.iterations,
59
+ accepted: result.integrated.length,
60
+ status: result.status,
61
+ }
62
+ }
63
+
64
+ console.log('Synthetic dispatcher matrix; fixed local delays, no model calls or provider quality claims.')
65
+ console.log('scenario,workers,wall_ms,worker_ms,integration_queue_wait_ms,integration_ms,attempts,accepted,status')
66
+ for (const scenario of fixture.scenarios) {
67
+ for (const workers of [1, 2, 3]) {
68
+ const result = await runCase(scenario, workers)
69
+ console.log([result.scenario, result.workers, result.wallMs, result.workerMs, result.queueWaitMs, result.integrationMs, result.attempts, result.accepted, result.status].join(','))
70
+ }
71
+ }
@@ -4,7 +4,8 @@ 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] [--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.
7
+ - `yoke loop run [--max=N] [--parallel=N] [--isolate] [--explore] [--explore-interval=N] [--decision-policy=auto|critical] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=blocking|advisory] [--quality-unbounded] [--candidates=N]` — run until the backlog is green or a gate blocks; `--explore` opts into persistent discovery and recovery after each backlog drains.
8
+ - `yoke loop pause` — request a safe-boundary pause from a running loop or exploration supervisor.
8
9
  - `yoke change add --idea="..."` — queue a product change at any time, including while the loop is running.
9
10
  - `yoke loop decision` / `yoke loop answer --choice=<id>` — inspect and answer a structured critical stop.
10
11
 
@@ -13,6 +14,10 @@ story is fast-forwarded to the main tree. Pass `--review` or `--reviewer=<provid
13
14
  a separate, schema-validated review. Pass `--parallel=N` to dispatch ready, non-colliding stories
14
15
  concurrently. Pass `--json` for NDJSON status on stdout.
15
16
 
17
+ With `--explore`, `--explore-limit=12h|3d|2w` optionally stops one invocation after the selected
18
+ duration. The default is unbounded. Expiry stops new work and pauses after active stories complete
19
+ their normal gates and integration; exit code `3` can be resumed with another `yoke loop run`.
20
+
16
21
  Stories may declare a reference, candidate artifact, rubric, and blocking/advisory quality policy.
17
22
  `--quality` runs a read-only blind critic plus bounded repair before review; every repair reruns the
18
23
  mechanical gates. `--candidates=N` requires quality declarations and dispatches multiple isolated
@@ -48,12 +53,24 @@ For each story:
48
53
  7. Only after all gates pass, mark the story `passes: true`, log the decision, and commit
49
54
  atomically. A failed commit restores the PRD state.
50
55
  8. When all current stories pass, run optional `completion.command` against the integrated
51
- system. Only a green result reports `complete`; otherwise the loop blocks. This readiness
52
- result is ephemeral, not a release and not a freeze on future changes.
56
+ system. Only a green result reports `complete`; otherwise the loop blocks. In `--explore` mode
57
+ a green result starts a read-only exploration pass instead of ending the supervisor. A red
58
+ completion gate is supplied as evidence to the explorer so it can propose work to resolve the
59
+ blocker; the gate is never skipped.
60
+ 9. Exploration proposals require repository-file evidence, confidence of at least 0.8, low/medium
61
+ risk, disjoint declared write scopes, and two to five structured acceptance criteria with
62
+ criterion-targeted approved test commands. Yoke validates and commits accepted PRD changes
63
+ before isolated implementation. Passed exploration stories are compacted from the active PRD;
64
+ their full prior contracts remain in Git history and a bounded recent fingerprint ledger
65
+ prevents near-term duplication.
53
66
 
54
67
  A supervisor can pause the loop by creating `.yoke/loop.pause`. The running story finishes;
55
68
  the dispatcher latches the signal, stops launching new workers, lets active workers reach safe
56
69
  terminal proof/cleanup, and exits with code `3` before another story is integrated.
70
+ `--explore` also checks this signal while waiting between scans and during retry backoff. Without
71
+ an explicit `--max` cap, draining the current PRD does not stop exploration; the supervisor waits,
72
+ retries recoverable failures with backoff, and reports the next action. It cannot survive forced
73
+ process or machine termination.
57
74
 
58
75
  State lives outside model context: PRD, git, and the ignored `.yoke/changes/` inbox. All are
59
76
  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.16.0
2
+ version: 1.18.0
3
3
  agents: [claude, codex, gemini, qwen, opencode, kilo, pi, hermes]
4
4
  skills:
5
5
  - { id: tdd, path: skills/tdd, kind: methodology, invocation: auto }
package/dist/cli.js CHANGED
@@ -9,12 +9,14 @@ import { createProjectGoal, readProjectGoal, runProjectGoal, pauseProjectGoal, g
9
9
  import { validateCanon } from './canon/validate.js';
10
10
  import { runRetrofit } from './retrofit/command.js';
11
11
  import { setLoopEnabled, loopStatus, runLoopCommand } from './loop/run-command.js';
12
+ import { requestLoopPause } from './loop/loop.js';
12
13
  import { runContextInit, runContextStatus } from './context/command.js';
13
14
  import { runReview } from './review/command.js';
14
15
  import { scanDir } from './scan/design.js';
15
16
  import { runNew } from './new/command.js';
16
17
  import { runPrdDraft, runPrdCheck } from './prd/command.js';
17
18
  import { runPrdAssess } from './prd/assess.js';
19
+ import { runPrdDecompose } from './prd/decompose.js';
18
20
  import { runLoopCleanup } from './loop/cleanup.js';
19
21
  import { runFlowSmoke } from './smoke/command.js';
20
22
  import { maybeNotifyUpdate, currentYokeVersion } from './update/check.js';
@@ -102,6 +104,33 @@ export function parseQualityFlags(args) {
102
104
  },
103
105
  };
104
106
  }
107
+ export function parseExploreLimit(value) {
108
+ const match = /^(\d+)\s*(h|hours?|d|days?|w|weeks?)$/i.exec(value.trim());
109
+ if (!match)
110
+ return { ok: false, error: `Invalid --explore-limit value: ${value} (expected a positive duration such as 12h, 3d, or 2w)` };
111
+ const amount = Number(match[1]);
112
+ const unit = match[2].toLowerCase();
113
+ const unitMs = unit.startsWith('h') ? 60 * 60_000 : unit.startsWith('d') ? 24 * 60 * 60_000 : 7 * 24 * 60 * 60_000;
114
+ const milliseconds = amount * unitMs;
115
+ if (!Number.isSafeInteger(amount) || amount <= 0 || !Number.isSafeInteger(milliseconds) || Date.now() + milliseconds > 8_640_000_000_000_000) {
116
+ return { ok: false, error: `Invalid --explore-limit value: ${value} (duration must be positive and within the supported date range)` };
117
+ }
118
+ return { ok: true, milliseconds };
119
+ }
120
+ function parseExploreLimitFlag(args, explore) {
121
+ const values = args.filter(arg => arg.startsWith('--explore-limit='));
122
+ if (args.includes('--explore-limit'))
123
+ return { ok: false, error: '--explore-limit requires a duration, for example --explore-limit=12h' };
124
+ if (values.length > 1)
125
+ return { ok: false, error: 'Use --explore-limit only once per invocation' };
126
+ const raw = values[0];
127
+ if (!raw)
128
+ return { ok: true };
129
+ if (!explore)
130
+ return { ok: false, error: '--explore-limit requires --explore' };
131
+ const parsed = parseExploreLimit(raw.slice('--explore-limit='.length));
132
+ return parsed.ok ? { ok: true, milliseconds: parsed.milliseconds } : parsed;
133
+ }
105
134
  export function main(argv) {
106
135
  const [cmd, ...rest] = argv;
107
136
  switch (cmd) {
@@ -357,6 +386,11 @@ export function main(argv) {
357
386
  console.log(loopStatus(targetDir));
358
387
  return 0;
359
388
  }
389
+ if (sub === 'pause') {
390
+ requestLoopPause(targetDir);
391
+ console.log('Pause requested at the next safe story or exploration boundary.');
392
+ return 0;
393
+ }
360
394
  if (sub === 'cleanup')
361
395
  return runLoopCleanup(targetDir, {
362
396
  removeWorktrees: rest.includes('--remove-worktrees'),
@@ -413,7 +447,23 @@ export function main(argv) {
413
447
  console.error(qualityFlags.error);
414
448
  return 1;
415
449
  }
416
- return runLoopCommand(targetDir, { ...resumeOptions, ...qualityFlags.options });
450
+ const explore = rest.includes('--explore');
451
+ const exploreLimit = parseExploreLimitFlag(rest, explore);
452
+ if (!exploreLimit.ok) {
453
+ console.error(exploreLimit.error);
454
+ return 1;
455
+ }
456
+ const exploreArg = rest.find(a => a.startsWith('--explore-interval='));
457
+ if (exploreArg && !explore) {
458
+ console.error('--explore-interval requires --explore');
459
+ return 1;
460
+ }
461
+ const exploreIntervalMinutes = exploreArg ? Number(exploreArg.slice('--explore-interval='.length)) : undefined;
462
+ if (exploreIntervalMinutes !== undefined && (!Number.isInteger(exploreIntervalMinutes) || exploreIntervalMinutes < 1 || exploreIntervalMinutes > 1_440)) {
463
+ console.error(`Invalid --explore-interval value: ${exploreArg} (expected 1..1440 minutes)`);
464
+ return 1;
465
+ }
466
+ return runLoopCommand(targetDir, { ...resumeOptions, ...qualityFlags.options, ...(explore ? { explore: true } : {}), ...(exploreIntervalMinutes !== undefined ? { exploreIntervalMinutes } : {}), ...(exploreLimit.milliseconds !== undefined ? { exploreLimitMs: exploreLimit.milliseconds } : {}) });
417
467
  }
418
468
  if (sub === 'answer') {
419
469
  const choice = rest.find(a => a.startsWith('--choice='))?.slice('--choice='.length);
@@ -511,8 +561,8 @@ export function main(argv) {
511
561
  const permissions = rest.includes('--unsafe') ? 'unsafe' : undefined;
512
562
  const parallelArg = rest.find(a => a.startsWith('--parallel='));
513
563
  const parallel = parallelArg && parallelArg !== '--parallel=auto' ? Number(parallelArg.slice('--parallel='.length)) : undefined;
514
- if (parallel !== undefined && (!Number.isInteger(parallel) || parallel < 1)) {
515
- console.error(`Invalid --parallel value: ${parallelArg}`);
564
+ if (parallel !== undefined && (!Number.isInteger(parallel) || parallel < 1 || parallel > 8)) {
565
+ console.error(`Invalid --parallel value: ${parallelArg} (expected 1..8)`);
516
566
  return 1;
517
567
  }
518
568
  const json = rest.includes('--json');
@@ -542,9 +592,29 @@ export function main(argv) {
542
592
  console.error(qualityFlags.error);
543
593
  return 1;
544
594
  }
545
- return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, resumeWorktree: rest.includes('--resume-worktree'), parallel, parallelAuto: parallelArg === '--parallel=auto', reviewer, review, allowSelfReview, timeoutMinutes, json, routing, onAmbiguity: oaArg, decisionPolicy: dpArg, permissions, ...qualityFlags.options });
595
+ const explore = rest.includes('--explore');
596
+ const exploreLimit = parseExploreLimitFlag(rest, explore);
597
+ if (!exploreLimit.ok) {
598
+ console.error(exploreLimit.error);
599
+ return 1;
600
+ }
601
+ const exploreArg = rest.find(a => a.startsWith('--explore-interval='));
602
+ if (exploreArg && !explore) {
603
+ console.error('--explore-interval requires --explore');
604
+ return 1;
605
+ }
606
+ const exploreIntervalMinutes = exploreArg ? Number(exploreArg.slice('--explore-interval='.length)) : undefined;
607
+ if (exploreIntervalMinutes !== undefined && (!Number.isInteger(exploreIntervalMinutes) || exploreIntervalMinutes < 1 || exploreIntervalMinutes > 1_440)) {
608
+ console.error(`Invalid --explore-interval value: ${exploreArg} (expected 1..1440 minutes)`);
609
+ return 1;
610
+ }
611
+ if (explore && rest.includes('--resume-worktree')) {
612
+ console.error('--explore starts its own safe resume cycle; remove --resume-worktree and re-run it after resolving the retained story.');
613
+ return 1;
614
+ }
615
+ return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, resumeWorktree: rest.includes('--resume-worktree'), parallel, parallelAuto: parallelArg === '--parallel=auto', reviewer, review, allowSelfReview, timeoutMinutes, json, routing, onAmbiguity: oaArg, decisionPolicy: dpArg, permissions, ...(explore ? { explore: true } : {}), ...(exploreIntervalMinutes !== undefined ? { exploreIntervalMinutes } : {}), ...(exploreLimit.milliseconds !== undefined ? { exploreLimitMs: exploreLimit.milliseconds } : {}), ...qualityFlags.options });
546
616
  }
547
- console.log(`usage: yoke loop <on|off|status|decision|answer|resume [--discard] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N]|cleanup [--remove-worktrees] [--discard-stale-recovery]|run [--max=N] [--parallel=<auto|N>] [--runner=<${AGENT_LIST}>] [--reviewer=<${AGENT_LIST}>] [--review] [--allow-self-review] [--routing|--no-routing] [--isolate|--no-isolate] [--unsafe] [--timeout=<minutes>] [--decision-policy=<auto|critical>] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N] [--json]> [targetDir]`);
617
+ console.log(`usage: yoke loop <on|off|status|pause|decision|answer|resume [--discard] [--explore] [--explore-interval=<minutes>] [--explore-limit=<Nh|Nd|Nw>] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N]|cleanup [--remove-worktrees] [--discard-stale-recovery]|run [--max=N] [--explore] [--explore-interval=<minutes>] [--explore-limit=<Nh|Nd|Nw>] [--parallel=<auto|N>] [--runner=<${AGENT_LIST}>] [--reviewer=<${AGENT_LIST}>] [--review] [--allow-self-review] [--routing|--no-routing] [--isolate|--no-isolate] [--unsafe] [--timeout=<minutes>] [--decision-policy=<auto|critical>] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N] [--json]> [targetDir]`);
548
618
  return 1;
549
619
  }
550
620
  case 'new': {
@@ -581,6 +651,25 @@ export function main(argv) {
581
651
  }
582
652
  return runPrdAssess(targetDir, { runner: runner, story: rest.find(a => a.startsWith('--story='))?.slice('--story='.length), reassess: rest.includes('--reassess') });
583
653
  }
654
+ if (sub === 'decompose') {
655
+ const story = rest.find(a => a.startsWith('--story='))?.slice('--story='.length);
656
+ if (!story) {
657
+ console.error('usage: yoke prd decompose [dir] --story=<id> [--apply] [--runner=<agent>] [--timeout=<minutes>]');
658
+ return 1;
659
+ }
660
+ const runner = rest.find(a => a.startsWith('--runner='))?.slice('--runner='.length);
661
+ if (runner && !SUPPORTED_AGENTS.includes(runner)) {
662
+ console.error('Invalid planning runner');
663
+ return 1;
664
+ }
665
+ const timeoutArg = rest.find(a => a.startsWith('--timeout='));
666
+ const timeoutMinutes = timeoutArg ? Number(timeoutArg.slice('--timeout='.length)) : undefined;
667
+ if (timeoutMinutes !== undefined && (!Number.isFinite(timeoutMinutes) || timeoutMinutes < 0)) {
668
+ console.error(`Invalid --timeout value: ${timeoutArg}`);
669
+ return 1;
670
+ }
671
+ return runPrdDecompose(targetDir, { story, apply: rest.includes('--apply'), runner: runner, timeoutMinutes });
672
+ }
584
673
  if (sub === 'draft') {
585
674
  const idea = rest.find(a => a.startsWith('--idea='))?.slice('--idea='.length);
586
675
  if (!idea) {
@@ -607,7 +696,7 @@ export function main(argv) {
607
696
  }
608
697
  if (sub === 'check')
609
698
  return runPrdCheck(targetDir);
610
- console.log(`usage: yoke prd <draft|check|assess> [dir] [--idea="..."] [--runner=<${AGENT_LIST}>] [--story=<id>] [--reassess] [--force] [--timeout=<minutes>]`);
699
+ console.log(`usage: yoke prd <draft|check|assess|decompose> [dir] [--idea="..."] [--runner=<${AGENT_LIST}>] [--story=<id>] [--apply] [--reassess] [--force] [--timeout=<minutes>]`);
611
700
  return 1;
612
701
  }
613
702
  case 'context': {
@@ -671,7 +760,7 @@ export function main(argv) {
671
760
  return runUpgrade();
672
761
  default:
673
762
  console.log('Project workflows: yoke check [dir] [--json|--protect] | goal set|run|resume|pause|status|handoff|budget [dir] | projects add|list|remove | dashboard [dir] [--port=N]');
674
- console.log(`usage: yoke <setup [dir] | new <dir> [--idea="..."] | validate [canonDir] | retrofit [targetDir] [--agent=${AGENT_LIST}|all] [--code-graph=graphify|serena] [--code-intelligence=off|shadow|active] [--loop] | change <add|status> [dir] | code-intelligence-server --workspace=<dir> --mode=<mode> | prd <draft|check|assess> [dir] | loop <on|off|status|decision|answer|resume|run|cleanup> | context <init|status> | review [dir] | design-scan [dir] | flow-smoke [dir] | upgrade>`);
763
+ console.log(`usage: yoke <setup [dir] | new <dir> [--idea="..."] | validate [canonDir] | retrofit [targetDir] [--agent=${AGENT_LIST}|all] [--code-graph=graphify|serena] [--code-intelligence=off|shadow|active] [--loop] | change <add|status> [dir] | code-intelligence-server --workspace=<dir> --mode=<mode> | prd <draft|check|assess|decompose> [dir] | loop <on|off|status|decision|answer|resume|run|cleanup> | context <init|status> | review [dir] | design-scan [dir] | flow-smoke [dir] | upgrade>`);
675
764
  return cmd ? 1 : 0;
676
765
  }
677
766
  }
@@ -101,7 +101,9 @@ function renderNow(p){
101
101
  summary.append(el('p','Last status: '+(status.updatedAt||'Unknown')+(state.stale?' · stale; activity is not confirmed':''),'muted'));
102
102
  if(status.progress)summary.append(el('p','Backlog: '+status.progress.passed+'/'+status.progress.total+' tasks accepted · not overall product completion','muted'));
103
103
  for(const proc of status.supervision||[])summary.append(row('Provider PID '+(proc.childPid||'not started'),['Attempt: '+((proc.retry||0)+1),'Supervisor heartbeat: '+proc.heartbeatAt,'Last output: '+(proc.lastOutputAt||'none'),'Last successful tool/edit: '+(proc.lastProgressAt||'none'),proc.reason].filter(Boolean).join(' · '),proc.state+' · '+(proc.liveness||'identity unverified')));
104
- summary.append(el('p','Reported workers: '+workers.length+' / '+(status.parallel?.maxConcurrency||1)+' · queue: '+tokenText(status.parallel?.queuedCandidates),'muted'));
104
+ summary.append(el('p','Reported workers: '+workers.length+' / '+(status.parallel?.maxConcurrency||1)+' · '+(status.parallel?.waitingWorkers||0)+' waiting · integrations queued: '+tokenText(status.parallel?.queuedIntegrations??status.parallel?.queuedCandidates),'muted'));
105
+ const pool=status.parallel?.globalPool;if(pool)summary.append(el('p','Shared worker pool: '+pool.activeUnits+' / '+pool.limit+' units · implementation '+pool.activeByRole.implementation+' · integration '+pool.activeByRole.integration+' · '+pool.waitingWorkers+' waiting across Yoke projects.','muted'));
106
+ if(p.estimate?.available)summary.append(el('p','Forecast: up to '+p.estimate.maxConcurrency+' implementation slots · '+p.estimate.globalLimit+' shared units · '+p.estimate.workerUnitsPerStory+' unit(s) per story · one serial integration lane · '+duration(p.estimate.implementationWorkMs)+' worker time · '+duration(p.estimate.integrationLaneMs)+' integration · '+duration(p.estimate.integrationQueueWaitMs)+' queue wait'+(p.estimate.integrationSampleCount?' · '+p.estimate.integrationSampleCount+' integration samples':' · integration history unavailable')+'.','muted'));
105
107
  if(goal.pendingAttempt)summary.append(row('Goal attempt',goal.pendingAttempt.provider+' · requested model '+(goal.pendingAttempt.model||'provider default')+' · started '+goal.pendingAttempt.startedAt,state.stale?'unconfirmed':'running'));
106
108
  if(status.execution&&status.state==='running')summary.append(el('p',state.stale?'Execution was reported but is now unconfirmed.':status.execution.provider+' · requested model '+(status.execution.requestedModel||'provider default')+' · elapsed '+duration(Date.now()-Date.parse(status.execution.startedAt))));
107
109
  if(status.story&&!workers.length)summary.append(row(status.storyTitle||status.story,status.phase||'Phase unknown',reportedState));
@@ -19,7 +19,8 @@ import { DASHBOARD_LIMITS, DashboardChangePayloadSchema, DashboardControlPayload
19
19
  const text = z.string().max(16000);
20
20
  const number = z.number().finite().nonnegative();
21
21
  const Goal = z.object({ objective: text, status: z.enum(['active', 'running', 'paused', 'blocked', 'complete']), reason: text.optional(), pendingAttempt: z.object({ provider: text, model: text.optional(), startedAt: text }).optional(), attempts: z.array(z.object({ durationMs: number.optional(), provider: text.optional(), success: z.boolean().optional(), summary: text.optional(), inputTokens: number.optional(), outputTokens: number.optional() })).max(200).default([]) });
22
- const Status = z.object({ state: text, phase: text.optional(), reason: text.optional(), progress: z.object({ passed: number, total: number }).optional(), tokens: z.object({ inputTokens: number, outputTokens: number, totalCostUsd: number.optional(), measurementComplete: z.boolean().optional(), model: text.optional(), calls: z.array(z.object({ usageAvailable: z.boolean().optional() })).max(10000).optional() }).optional(), measurement: z.object({ costAvailable: z.enum(['unknown', 'partial', 'measured']), measuredCalls: number.optional(), unknownCalls: number.optional(), unmeasuredAttempts: number.optional() }).passthrough().optional(), parallel: z.object({ maxConcurrency: number }).passthrough().optional() }).passthrough();
22
+ const PoolStatus = z.object({ limit: number, activeUnits: number, activeWorkers: number, waitingWorkers: number, activeByRole: z.object({ implementation: number, integration: number }), oldestWaitMs: number });
23
+ const Status = z.object({ state: text, phase: text.optional(), reason: text.optional(), progress: z.object({ passed: number, total: number }).optional(), tokens: z.object({ inputTokens: number, outputTokens: number, totalCostUsd: number.optional(), measurementComplete: z.boolean().optional(), model: text.optional(), calls: z.array(z.object({ usageAvailable: z.boolean().optional() })).max(10000).optional() }).optional(), measurement: z.object({ costAvailable: z.enum(['unknown', 'partial', 'measured']), measuredCalls: number.optional(), unknownCalls: number.optional(), unmeasuredAttempts: number.optional() }).passthrough().optional(), parallel: z.object({ maxConcurrency: number, workerUnitsPerStory: number.optional(), globalPool: PoolStatus.optional() }).passthrough().optional() }).passthrough();
23
24
  const Stories = z.array(z.object({ id: text, title: text, passes: z.boolean(), priority: number.optional(), area: text.optional(), writes: z.array(text).optional(), needs: z.array(text).optional() })).max(2000);
24
25
  const Check = z.object({ id: text, status: z.enum(['passed', 'failed', 'unverified']), generatedAt: text, summary: text, criteria: z.array(z.object({ id: text, text, status: z.enum(['passed', 'failed', 'unverified']), summary: text })).max(500) });
25
26
  const Durations = z.array(z.object({ storyId: text, ms: number.positive() })).max(5000);
@@ -122,6 +123,7 @@ function snapshot(project, detail) {
122
123
  const status = read('.yoke/loop-status.json', Status);
123
124
  const stories = detail ? read('.yoke/prd.yaml', Stories, true) ?? [] : [];
124
125
  const history = detail ? read('.yoke/story-durations.json', Durations) ?? [] : [];
126
+ const events = detail && !project.error ? readEvents(project.root, DASHBOARD_LIMITS.events) : [];
125
127
  let check = null;
126
128
  if (detail && !project.error) {
127
129
  try {
@@ -140,7 +142,14 @@ function snapshot(project, detail) {
140
142
  errors.push(error.message);
141
143
  }
142
144
  }
143
- return { ...project, goal, status, errors, ...(detail ? { stories, check, events: project.error ? [] : readEvents(project.root, DASHBOARD_LIMITS.events), estimate: estimateSchedule(stories, Math.max(1, status?.parallel?.maxConcurrency ?? 1), history) } : {}) };
145
+ const localCapacity = Math.max(1, status?.parallel?.maxConcurrency ?? 1);
146
+ const estimate = estimateSchedule(stories, localCapacity, history, {
147
+ integrationMs: events.filter(event => event.type === 'phase-ended' && event.phase === 'integration').map(event => event.durationMs ?? 0),
148
+ integrationQueueWaitMs: events.filter(event => event.type === 'phase-ended' && event.phase === 'integration-queue').map(event => event.durationMs ?? 0),
149
+ globalLimit: Math.max(1, status?.parallel?.globalPool?.limit ?? localCapacity),
150
+ workerUnitsPerStory: Math.max(1, status?.parallel?.workerUnitsPerStory ?? 1),
151
+ });
152
+ return { ...project, goal, status, errors, ...(detail ? { stories, check, events, estimate } : {}) };
144
153
  }
145
154
  export async function startDashboard(options = {}) {
146
155
  const token = randomBytes(32).toString('hex');