@hecer/yoke 1.9.0 → 1.10.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 (41) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +22 -1
  4. package/README.md +8 -6
  5. package/canon/loop/prd.schema.md +6 -0
  6. package/dist/agents/process-incarnation.js +1 -1
  7. package/dist/agents/process.js +74 -6
  8. package/dist/agents/supervision.js +153 -0
  9. package/dist/agents/windows-launch.js +80 -0
  10. package/dist/change/inbox.js +21 -5
  11. package/dist/cli.js +11 -2
  12. package/dist/dashboard/page.js +102 -8
  13. package/dist/dashboard/panels.js +84 -8
  14. package/dist/goals/command.js +2 -0
  15. package/dist/loop/git.js +12 -4
  16. package/dist/loop/loop.js +8 -4
  17. package/dist/loop/parallel-adapters.js +2 -3
  18. package/dist/loop/parallel-command.js +5 -0
  19. package/dist/loop/prd.js +2 -0
  20. package/dist/loop/reporter.js +4 -1
  21. package/dist/loop/run-command.js +11 -2
  22. package/dist/loop/runner.js +22 -26
  23. package/dist/loop/watchdog.js +87 -11
  24. package/dist/loop/worker.js +5 -3
  25. package/dist/prd/assess.js +145 -0
  26. package/dist/prd/command.js +59 -21
  27. package/dist/retrofit/config.js +11 -0
  28. package/dist/routing/assessment.js +1 -1
  29. package/dist/routing/capability.js +25 -13
  30. package/dist/routing/contracts.js +60 -0
  31. package/dist/routing/planning.js +12 -0
  32. package/dist/routing/router.js +50 -15
  33. package/dist/setup/command.js +2 -0
  34. package/docs/BATCH-PLANNING-VALIDATION.md +67 -0
  35. package/docs/CAPABILITY-ROUTING.md +31 -3
  36. package/docs/DASHBOARD-EVOLUTION.md +33 -0
  37. package/docs/PRODUCT-DIRECTION-2026-09-05.md +18 -0
  38. package/docs/WINDOWS-RUNNER-VALIDATION.md +104 -0
  39. package/docs/assets/yoke-logo.png +0 -0
  40. package/gemini-extension.json +1 -1
  41. 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.9.0",
5
+ "version": "1.10.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.9.0",
3
+ "version": "1.10.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,6 +1,27 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 1.10.0 — 2026-09-06
4
+
5
+ ### Added
6
+ - Adopt the Yoke wordmark in the GitHub and npm README.
7
+ - Prepare task assessments in one bounded planning call with `yoke prd assess`; draft and inbox planning bind assessments to requirements, upstream dependencies and the approved brief.
8
+ - Separate `planning.agent`/`model`/`reasoningEffort` from execution settings. New setups require prepared assessments and block missing worker profiles; existing configurations retain on-demand planning and parent fallback.
9
+ - Add selective reassessment, planning input limits and `routing.maxTier` to bound automatic model selection and escalation.
10
+ - Add attention-first project search and status filters, with separate goal/loop states and explicit stale activity warnings.
11
+ - Restore dashboard views and UTC period controls through URL links, browser history and refresh; cancel obsolete requests and bound project comparison concurrency.
12
+ - Compare recorded tokens, accepted tasks and reported costs against the preceding equal-duration period without inventing missing measurements or percentages from zero baselines.
13
+
14
+ ### Fixed
15
+ - Fix Windows safe-mode Codex execution by screening Store PowerShell/aliases and probing a native shell under the same sandbox before model work; keep permissions intact.
16
+ - Launch Windows provider executables/npm entry points with literal argv; bound preflight and provider lifetime, recognize streamed infrastructure failures, retain failed-worktree/process evidence, and guard against unconfirmed termination before another worker starts.
17
+ - Separate provider liveness, supervisor heartbeat, output and successful-tool progress in status/dashboard; label backlog percentages and preserve explicit bare startup through capability routing. See [Windows runner validation](docs/WINDOWS-RUNNER-VALIDATION.md).
18
+ - Keep local loop locks and dashboard status out of implementation commits and clean-worktree checks.
19
+ - Stage implementation files safely when runtime history directories are already ignored, including literal filenames and tracked deletions, for serial commits and parallel candidate snapshots.
20
+
21
+ ### Migration and validation limits
22
+ - Existing routing configurations retain on-demand assessment and parent fallback. Use `yoke prd assess` to prepare task packages; configure `planning.agent`, `planning.model`, `planning.reasoningEffort` and `routing.maxTier` to separate planning from bounded execution. New setups require prepared assessments and block missing profiles.
23
+ - Update installed packages and restart the dashboard/runner to use the new code. Existing running processes are not upgraded or instrumented retroactively. Windows preflight preserves sandbox permissions and changes only the provider environment.
24
+ - Dashboard comparisons report recorded measurements, not reconstructed history or calibrated savings. Tokens per minute are consumption rates, not generation speed. Live routed Windows validation covered Codex on the documented machine; cross-provider performance and universal Windows compatibility are not established. See the linked dashboard, batch-planning and runner validation records.
4
25
 
5
26
  ## 1.9.0 — 2026-09-06
6
27
 
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  <div align="center">
2
2
 
3
- # 🐂 Yoke
3
+ <h1><img src="https://raw.githubusercontent.com/HECer/yoke/v1.10.0/docs/assets/yoke-logo.png" alt="Yoke" width="100" height="63"></h1>
4
4
 
5
- <!-- yoke:version:start -->1.9.0<!-- yoke:version:end -->
6
- <!-- yoke:tests:start -->1134<!-- yoke:tests:end -->
5
+ <!-- yoke:version:start -->1.10.0<!-- yoke:version:end -->
6
+ <!-- yoke:tests:start -->1173<!-- yoke:tests:end -->
7
7
  <!-- yoke:skills:start -->34<!-- 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-1134%20defined-blue.svg)
20
+ ![Tests](https://img.shields.io/badge/tests-1173%20defined-blue.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
 
@@ -27,7 +27,7 @@
27
27
 
28
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
29
 
30
- **New in 1.9.0:** [routing by task requirements](docs/CAPABILITY-ROUTING.md). Keep planning on the start model, select execution models and effort from saved task assessments, and use bounded repair and escalation with independent checks. The dashboard explains model selection; existing routing settings remain authoritative.
30
+ **New in 1.10.0:** [dashboard search, filters and period comparisons](docs/DASHBOARD-EVOLUTION.md), [batch task assessments with separate planning models](docs/CAPABILITY-ROUTING.md), and [Windows sandbox preflight and process supervision](docs/WINDOWS-RUNNER-VALIDATION.md). Existing routing settings remain authoritative. See the [changelog](CHANGELOG.md) for migration and validation limits.
31
31
 
32
32
  ### One dashboard, multiple projects
33
33
 
@@ -40,6 +40,8 @@ yoke dashboard --no-register
40
40
 
41
41
  Open the printed `http://127.0.0.1:...` URL. Each registered project has its own goals, tasks and evidence. The dashboard shows available worker state, per-task duration estimates, planned start offsets, input/output tokens, costs and unknown measurements. You can request a goal pause at a safe boundary.
42
42
 
43
+ The dashboard includes attention-first project search and filters, restorable view links, and usage comparisons against the preceding period. See [dashboard behavior and measurement limits](docs/DASHBOARD-EVOLUTION.md).
44
+
43
45
  Projects are registered explicitly; this version does not automatically discover every process or aggregate other computers. Start/resume and budget changes use the CLI. Missing history appears as unknown; time ranges are empirical estimates, not exact deadlines.
44
46
 
45
47
  ### Verified goals and efficient execution
@@ -895,7 +897,7 @@ release provenance.
895
897
  ## 🧪 Development
896
898
 
897
899
  ```bash
898
- npm test # vitest (1134 tests)
900
+ npm test # vitest (1173 tests)
899
901
  npm run build # tsc, no emit errors
900
902
  npm run yoke -- validate canon
901
903
  ```
@@ -34,6 +34,12 @@ belongs in a trusted `completion.command` journey suite.
34
34
  `sourceChange` is an optional Yoke-owned request ID. The change inbox uses it to append new
35
35
  stories idempotently; authors normally omit it.
36
36
 
37
+ `assessment` holds the planner's task class, difficulty, uncertainty, risk, scope, testability,
38
+ reason and approach. `assessmentFor` is a Yoke-generated SHA-256 binding to the task requirements,
39
+ upstream contracts and approved brief. Do not copy a binding onto changed requirements.
40
+ With `routing.assessmentPolicy: prepared`, use `yoke prd assess` after editing contracts;
41
+ dispatch blocks missing or stale assessments. See [capability routing](../../docs/CAPABILITY-ROUTING.md).
42
+
37
43
  Stories without `needs`, `area`, or `agent` retain serial behavior. A story is ready only when
38
44
  every ID in `needs` passes. The scheduler orders ready work by priority, avoids simultaneously
39
45
  active areas, and uses `agent` as an affinity hint.
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- const queryProcessIdentity = (command, args, options) => execFileSync(command, args, { stdio: 'pipe', ...options }).toString();
2
+ const queryProcessIdentity = (command, args, options) => execFileSync(command, args, { stdio: 'pipe', timeout: 5000, windowsHide: true, ...options }).toString();
3
3
  export function processIncarnation(pid, platform = process.platform, query = queryProcessIdentity) {
4
4
  try {
5
5
  if (platform === 'win32') {
@@ -4,35 +4,64 @@ import { killProcessTreeForCleanup } from '../loop/watchdog.js';
4
4
  import { createProviderProcessRecord, filesystemProviderProcessRecordAdapter, } from './process-record.js';
5
5
  import { createBoundedOutput, createTelemetryAccumulator } from './process-streams.js';
6
6
  import { processIncarnation } from './process-incarnation.js';
7
+ import { prepareWindowsInvocation, resolveWindowsCommand } from './windows-launch.js';
8
+ import { createSupervision, supervisionLimits, assertPreviousProvidersStopped } from './supervision.js';
7
9
  function cancellationReason(signal) {
8
10
  return typeof signal.reason === 'string' && signal.reason.length > 0
9
11
  ? signal.reason
10
12
  : 'provider process cancellation requested';
11
13
  }
12
14
  export function providerSpawnOptions(invocation, platform = process.platform) {
13
- const windowsCommandShim = !/[\\/]/u.test(invocation.command) || /\.(?:bat|cmd)$/iu.test(invocation.command);
15
+ const resolved = platform === 'win32' ? resolveWindowsCommand(invocation.command, invocation.args) : invocation;
14
16
  return {
15
- command: invocation.command,
16
- args: invocation.args,
17
+ command: resolved.command,
18
+ args: resolved.args,
17
19
  cwd: invocation.cwd,
18
- shell: platform === 'win32' && windowsCommandShim,
20
+ shell: false,
19
21
  detached: platform !== 'win32',
20
22
  };
21
23
  }
22
24
  export function startProviderProcess(agent, invocation, options = {}) {
23
- const spawnOptions = providerSpawnOptions(invocation);
25
+ let failure = () => { };
26
+ let progress = () => { };
27
+ const limits = supervisionLimits(invocation.cwd);
28
+ const supervision = createSupervision(invocation.cwd, reason => failure(reason), () => progress(), options.attempt);
29
+ let prepared;
30
+ try {
31
+ assertPreviousProvidersStopped(invocation.cwd);
32
+ prepared = process.platform === 'win32' ? prepareWindowsInvocation(invocation) : { command: invocation.command, args: invocation.args, env: process.env };
33
+ }
34
+ catch (error) {
35
+ const message = error.message;
36
+ supervision.stop(message);
37
+ return { pid: undefined, invocation, recordPath: '', cancel: () => false, completion: Promise.resolve({ kind: 'spawn-failed', error: message, invocation, pid: undefined, stdout: '', stderr: '', stdoutTruncated: false, stderrTruncated: false, telemetry: { usageAvailable: false } }) };
38
+ }
39
+ const spawnOptions = { ...prepared, cwd: invocation.cwd, shell: false, detached: process.platform !== 'win32' };
24
40
  const child = spawn(spawnOptions.command, [...spawnOptions.args], {
25
41
  cwd: spawnOptions.cwd,
26
42
  shell: spawnOptions.shell,
27
43
  stdio: ['pipe', 'pipe', 'pipe'],
28
44
  detached: spawnOptions.detached,
45
+ env: prepared.env,
46
+ windowsHide: true,
29
47
  });
30
48
  const targetDir = resolve(invocation.cwd);
31
49
  const pid = child.pid;
32
50
  const startedAt = pid === undefined ? `unverified:${new Date().toISOString()}` : processIncarnation(pid) ?? `unverified:${new Date().toISOString()}`;
51
+ supervision.start(pid, 'shell' in prepared ? prepared.shell : undefined, startedAt.startsWith('unverified:') ? undefined : startedAt);
33
52
  const record = createProviderProcessRecord(targetDir, pid ?? 0, options.workerId, startedAt);
34
53
  const recordAdapter = options.recordAdapter ?? filesystemProviderProcessRecordAdapter;
35
- const terminateProcessTree = options.terminateProcessTree ?? ((processPid) => killProcessTreeForCleanup(processPid));
54
+ const terminateProcessTree = options.terminateProcessTree ?? ((processPid) => {
55
+ try {
56
+ process.kill(processPid, 0);
57
+ }
58
+ catch {
59
+ return true;
60
+ }
61
+ if (startedAt.startsWith('unverified:') || processIncarnation(processPid) !== startedAt)
62
+ return false;
63
+ return killProcessTreeForCleanup(processPid);
64
+ });
36
65
  let recordPublished = false;
37
66
  const stdout = createBoundedOutput(options.outputLimitBytes ?? 1_048_576);
38
67
  const stderr = createBoundedOutput(options.outputLimitBytes ?? 1_048_576);
@@ -42,6 +71,9 @@ export function startProviderProcess(agent, invocation, options = {}) {
42
71
  let termination;
43
72
  let idleTimer;
44
73
  let forceTimer;
74
+ let totalTimer;
75
+ let progressTimer;
76
+ let completionTimer;
45
77
  let recordFailure;
46
78
  let terminationConfirmed = false;
47
79
  let settled = false;
@@ -58,6 +90,12 @@ export function startProviderProcess(agent, invocation, options = {}) {
58
90
  clearTimeout(idleTimer);
59
91
  if (forceTimer)
60
92
  clearTimeout(forceTimer);
93
+ if (totalTimer)
94
+ clearTimeout(totalTimer);
95
+ if (progressTimer)
96
+ clearTimeout(progressTimer);
97
+ if (completionTimer)
98
+ clearTimeout(completionTimer);
61
99
  idleTimer = undefined;
62
100
  forceTimer = undefined;
63
101
  };
@@ -66,6 +104,7 @@ export function startProviderProcess(agent, invocation, options = {}) {
66
104
  return;
67
105
  settled = true;
68
106
  clearTimers();
107
+ supervision.stop(termination?.reason ?? (result.kind === 'succeeded' ? 'provider-exited' : 'provider-failed'), !termination || terminationConfirmed);
69
108
  options.signal?.removeEventListener('abort', onAbort);
70
109
  if (!termination || terminationConfirmed)
71
110
  removeRecord();
@@ -81,6 +120,9 @@ export function startProviderProcess(agent, invocation, options = {}) {
81
120
  telemetry: telemetry.finish(),
82
121
  });
83
122
  const finalize = (exitCode) => {
123
+ if (settled)
124
+ return;
125
+ supervision.flush();
84
126
  // Windows can emit close before taskkill's process-tree state is observable.
85
127
  // Reconfirm here so successful termination does not leave a stale ownership record.
86
128
  if (termination && pid !== undefined && !terminationConfirmed) {
@@ -114,6 +156,21 @@ export function startProviderProcess(agent, invocation, options = {}) {
114
156
  forceTimer = setTimeout(() => {
115
157
  if (pid !== undefined && !settled)
116
158
  terminationConfirmed = terminateProcessTree(pid, true);
159
+ // Allow close/pipe draining to confirm termination before the bounded fallback.
160
+ if (!settled)
161
+ completionTimer = setTimeout(() => {
162
+ if (settled)
163
+ return;
164
+ // A killer's return value is not an observed process exit.
165
+ if (pid !== undefined) {
166
+ try {
167
+ process.kill(pid, 0);
168
+ terminationConfirmed = false;
169
+ }
170
+ catch { /* exited */ }
171
+ }
172
+ finalize(null);
173
+ }, 5000);
117
174
  }, terminationGraceMs);
118
175
  return true;
119
176
  };
@@ -140,8 +197,16 @@ export function startProviderProcess(agent, invocation, options = {}) {
140
197
  stderr.append(text);
141
198
  }
142
199
  options.onOutput?.({ stream, text });
200
+ supervision.output(stream, text);
143
201
  armIdleTimer();
144
202
  };
203
+ failure = reason => { terminate({ kind: 'cancelled', reason }); };
204
+ progress = () => {
205
+ if (progressTimer)
206
+ clearTimeout(progressTimer);
207
+ if ((options.progressTimeoutMs ?? limits.progressMs) > 0)
208
+ progressTimer = setTimeout(() => terminate({ kind: 'timed-out', reason: 'provider-progress-timeout' }), options.progressTimeoutMs ?? limits.progressMs);
209
+ };
145
210
  child.stdout?.on('data', chunk => { onOutput('stdout', chunk); });
146
211
  child.stderr?.on('data', chunk => { onOutput('stderr', chunk); });
147
212
  child.stdin?.on('error', () => { });
@@ -178,5 +243,8 @@ export function startProviderProcess(agent, invocation, options = {}) {
178
243
  else
179
244
  options.signal?.addEventListener('abort', onAbort, { once: true });
180
245
  armIdleTimer();
246
+ if ((options.totalTimeoutMs ?? limits.totalMs) > 0)
247
+ totalTimer = setTimeout(() => terminate({ kind: 'timed-out', reason: 'provider-total-timeout' }), options.totalTimeoutMs ?? limits.totalMs);
248
+ progress();
181
249
  return handle;
182
250
  }
@@ -0,0 +1,153 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdirSync, writeFileSync, renameSync, readFileSync, readdirSync, lstatSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { processIncarnation } from './process-incarnation.js';
5
+ import { loadConfig } from '../retrofit/config.js';
6
+ export function supervisionLimits(cwd) {
7
+ const loop = loadConfig(cwd)?.loop;
8
+ return { totalMs: (loop?.maxCallMinutes ?? 30) * 60_000, progressMs: (loop?.progressTimeoutMinutes ?? 20) * 60_000 };
9
+ }
10
+ export function assertPreviousProvidersStopped(cwd) {
11
+ if (readSupervision(cwd, '').some(record => record.state === 'termination-unconfirmed' && record.liveness !== 'exited')) {
12
+ throw Error('A previous provider termination is unconfirmed; inspect owned process records before starting another worker');
13
+ }
14
+ }
15
+ /** Only provider error/tool-result envelopes are failure evidence; stderr MCP chatter is not. */
16
+ export function inspectProviderEvent(line) {
17
+ let event;
18
+ try {
19
+ event = JSON.parse(line);
20
+ }
21
+ catch {
22
+ return {};
23
+ }
24
+ if (!event || typeof event !== 'object')
25
+ return {};
26
+ const item = event.item;
27
+ const failedCommand = event.type === 'item.completed' && item?.type === 'command_execution' && (item.status === 'failed' || (typeof item.exit_code === 'number' && item.exit_code !== 0));
28
+ const fatal = event.type === 'error' || event.type === 'turn.failed' || (event.type === 'result' && (event.is_error === true || event.status === 'error'));
29
+ if (failedCommand || fatal) {
30
+ const details = JSON.stringify(failedCommand ? item.aggregated_output : event.error ?? event.errors ?? event.result ?? event.message);
31
+ if (/CreateProcess(?:AsUserW|W)? failed|Failed to create unified exec process/iu.test(details ?? ''))
32
+ return { failure: 'worker-shell-spawn-failed' };
33
+ if (/AuthRequired|authentication failed|invalid api key|No access token|credentials (?:missing|not found)/iu.test(details ?? ''))
34
+ return { failure: 'provider-authentication-failed' };
35
+ if (fatal)
36
+ return { failure: 'provider-terminal-error' };
37
+ }
38
+ return { progress: (event.type === 'item.completed' && ((item?.type === 'command_execution' && item.exit_code === 0) || (item?.type === 'file_change' && item.status === 'completed')))
39
+ || (event.type === 'tool_result' && event.status === 'success')
40
+ || (event.type === 'user' && Array.isArray(event.message?.content) && event.message.content.some((part) => part.type === 'tool_result' && part.is_error === false)) };
41
+ }
42
+ export function inspectProviderDiagnostic(line) {
43
+ if (/\bERROR codex_core::tools::router: error=exec_command failed: CreateProcess\b/u.test(line) && /CreateProcess(?:AsUserW|W)? failed/u.test(line))
44
+ return 'worker-shell-spawn-failed';
45
+ // Unrelated rmcp/MCP authentication diagnostics are intentionally excluded.
46
+ if (/^(?:Error: |error: )(?:authentication failed|invalid api key|No access token)/iu.test(line))
47
+ return 'provider-authentication-failed';
48
+ return undefined;
49
+ }
50
+ export function createSupervision(cwd, onFailure, onProgress = () => { }, attempt = 1) {
51
+ const id = randomUUID(), dir = join(cwd, '.yoke', 'supervision'), path = join(dir, id + '.json');
52
+ const now = () => new Date().toISOString();
53
+ const state = { id, supervisorPid: process.pid, startedAt: now(), heartbeatAt: now(), state: 'preflight', retry: Math.max(0, attempt - 1) };
54
+ let pending = '', stderrPending = '', terminal = false, failure;
55
+ const save = () => {
56
+ state.heartbeatAt = now();
57
+ mkdirSync(dir, { recursive: true });
58
+ const temp = path + '.tmp';
59
+ writeFileSync(temp, JSON.stringify(state));
60
+ renameSync(temp, path);
61
+ };
62
+ save();
63
+ const timer = setInterval(() => { if (!terminal)
64
+ save(); }, 5000);
65
+ timer.unref();
66
+ return {
67
+ state,
68
+ flush() { if (pending)
69
+ this.output('stdout', '\n'); if (stderrPending)
70
+ this.output('stderr', '\n'); },
71
+ start(pid, shell, incarnation) { state.childPid = pid; state.childIncarnation = incarnation; state.shell = shell; state.state = 'running'; save(); },
72
+ output(stream, text) {
73
+ state.lastOutputAt = now();
74
+ const lines = ((stream === 'stdout' ? pending : stderrPending) + text).split(/\r?\n/u);
75
+ const tail = lines.pop() ?? '';
76
+ if (stream === 'stdout')
77
+ pending = tail.length > 262144 ? '' : tail;
78
+ else
79
+ stderrPending = tail.length > 262144 ? '' : tail;
80
+ for (const line of lines) {
81
+ if (line.length > 262144)
82
+ continue;
83
+ const result = stream === 'stdout' ? inspectProviderEvent(line) : { failure: inspectProviderDiagnostic(line) };
84
+ if (result.progress) {
85
+ state.lastProgressAt = now();
86
+ onProgress();
87
+ }
88
+ if (result.failure && !failure) {
89
+ failure = result.failure;
90
+ state.reason = failure;
91
+ state.state = 'stopping';
92
+ save();
93
+ onFailure(failure);
94
+ }
95
+ }
96
+ },
97
+ stop(reason, confirmed = true) { terminal = true; clearInterval(timer); state.reason = failure ?? reason; state.state = confirmed ? 'stopped' : 'termination-unconfirmed'; save(); },
98
+ get failure() { return failure; },
99
+ };
100
+ }
101
+ export function readSupervision(root, since) {
102
+ const roots = [root];
103
+ try {
104
+ for (const name of readdirSync(join(root, '.yoke', 'worktrees')).slice(0, 100)) {
105
+ const path = join(root, '.yoke', 'worktrees', name);
106
+ if (lstatSync(path).isDirectory() && !lstatSync(path).isSymbolicLink())
107
+ roots.push(path);
108
+ }
109
+ }
110
+ catch { /* no isolated workers */ }
111
+ const records = [];
112
+ for (const cwd of roots) {
113
+ const dir = join(cwd, '.yoke', 'supervision');
114
+ try {
115
+ if (lstatSync(dir).isSymbolicLink())
116
+ continue;
117
+ for (const name of readdirSync(dir).filter(n => n.endsWith('.json')).slice(-200)) {
118
+ const path = join(dir, name), stat = lstatSync(path);
119
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 8192)
120
+ continue;
121
+ try {
122
+ const record = JSON.parse(readFileSync(path, 'utf8'));
123
+ if (typeof record.startedAt === 'string' && record.startedAt >= since && typeof record.heartbeatAt === 'string')
124
+ records.push(record);
125
+ }
126
+ catch { /* incomplete/untrusted state */ }
127
+ }
128
+ }
129
+ catch { /* no process evidence */ }
130
+ }
131
+ return records.sort((a, b) => b.startedAt.localeCompare(a.startedAt)).slice(0, 20).map(record => ({ ...record, liveness: processLiveness(record) }));
132
+ }
133
+ const identities = new Map();
134
+ function processLiveness(record) {
135
+ if (!Number.isInteger(record.childPid) || record.childPid <= 0)
136
+ return 'identity-unverified';
137
+ try {
138
+ process.kill(record.childPid, 0);
139
+ }
140
+ catch (error) {
141
+ return error.code === 'ESRCH' ? 'exited' : 'identity-unverified';
142
+ }
143
+ if (!record.childIncarnation)
144
+ return 'identity-unverified';
145
+ let identity = identities.get(record.childPid);
146
+ if (!identity || Date.now() - identity.at > 5000) {
147
+ identity = { at: Date.now(), value: processIncarnation(record.childPid) };
148
+ identities.set(record.childPid, identity);
149
+ if (identities.size > 100)
150
+ identities.delete(identities.keys().next().value);
151
+ }
152
+ return identity.value === record.childIncarnation ? 'alive' : 'identity-unverified';
153
+ }
@@ -0,0 +1,80 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
+ import { delimiter, dirname, extname, isAbsolute, join, resolve } from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
+ import { createRequire } from 'node:module';
6
+ const file = (path) => { try {
7
+ return statSync(path).isFile();
8
+ }
9
+ catch {
10
+ return false;
11
+ } };
12
+ const pathValue = (env) => env[Object.keys(env).find(k => k.toLowerCase() === 'path') ?? 'PATH'] ?? '';
13
+ /** Resolve npm launchers without cmd.exe argument concatenation. */
14
+ export function resolveWindowsCommand(command, args, env = process.env) {
15
+ if (command === 'node' || command === process.execPath)
16
+ return { command: process.execPath, args: [...args] };
17
+ if (isAbsolute(command) && /\.(exe|com)$/iu.test(command))
18
+ return { command, args: [...args] };
19
+ const candidates = isAbsolute(command) || /[\\/]/u.test(command) ? [command]
20
+ : pathValue(env).split(';').filter(Boolean).flatMap(dir => extname(command) ? [join(dir, command)] : ['.exe', '.com', '.cmd', '.bat'].map(ext => join(dir, command + ext)));
21
+ const found = candidates.find(file);
22
+ if (!found)
23
+ throw Error(`Provider executable unavailable: ${command}`);
24
+ if (/\.(exe|com)$/iu.test(found))
25
+ return { command: found, args: [...args] };
26
+ const script = readFileSync(found, 'utf8');
27
+ const entry = script.match(/"%dp0%[\\/]([^"\r\n]+\.(?:m?js|cjs))"/iu)?.[1];
28
+ if (!entry)
29
+ throw Error('Unsupported Windows batch launcher; use a native executable or an npm-generated CLI launcher');
30
+ const target = resolve(dirname(found), ...entry.split(/[\\/]/u));
31
+ if (!file(target))
32
+ throw Error('Provider npm entry point is missing');
33
+ const localNode = join(dirname(found), 'node.exe');
34
+ return { command: file(localNode) ? localNode : process.execPath, args: [target, ...args] };
35
+ }
36
+ export function inaccessibleStoreShell(path) {
37
+ const bad = (p) => /[\\/]WindowsApps[\\/](?:Microsoft\.PowerShell[^\\/]*[\\/]|(?:pwsh|powershell)\.exe$)/iu.test(p);
38
+ if (bad(path))
39
+ return true;
40
+ try {
41
+ return bad(realpathSync(path));
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ export function windowsShellEnvironment(env = process.env) {
48
+ // Change only this provider's PATH. Never change machine/user configuration or permissions.
49
+ const paths = pathValue(env).split(';').filter(Boolean).filter(dir => !['pwsh.exe', 'powershell.exe'].some(name => inaccessibleStoreShell(join(dir, name))));
50
+ const nativePwsh = join(env.ProgramFiles ?? 'C:\\Program Files', 'PowerShell', '7', 'pwsh.exe');
51
+ const systemShell = join(env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
52
+ const shell = [...paths.map(p => join(p, 'pwsh.exe')), nativePwsh, ...paths.map(p => join(p, 'powershell.exe')), systemShell].find(p => file(p) && !inaccessibleStoreShell(p));
53
+ if (!shell)
54
+ throw Error('No native PowerShell available for sandbox preflight');
55
+ const next = { ...env };
56
+ for (const key of Object.keys(next))
57
+ if (key.toLowerCase() === 'path')
58
+ delete next[key];
59
+ next.PATH = [dirname(shell), ...paths].join(delimiter);
60
+ return { env: next, shell };
61
+ }
62
+ /** Model-free probe under the same Codex permission profile and isolated cwd. */
63
+ export function prepareWindowsInvocation(inv, env = process.env) {
64
+ if (inv.command !== 'codex' || !inv.args.includes('exec') || inv.args.includes('--dangerously-bypass-approvals-and-sandbox'))
65
+ return { ...resolveWindowsCommand(inv.command, inv.args, env), env };
66
+ const prepared = windowsShellEnvironment(env);
67
+ const readOnly = inv.args.includes('read-only');
68
+ const config = ['--config', `shell_environment_policy.set.PATH=${JSON.stringify(prepared.env.PATH)}`];
69
+ const probe = resolveWindowsCommand('codex', ['sandbox', '-P', readOnly ? ':read-only' : ':workspace', '-C', inv.cwd, ...config, '--', prepared.shell, '-NoProfile', '-NonInteractive', '-Command', 'Write-Output YOKE_SHELL_OK'], prepared.env);
70
+ const compiled = fileURLToPath(new URL('../loop/watchdog.js', import.meta.url));
71
+ const watchdog = existsSync(compiled) ? [compiled] : ['--import', pathToFileURL(createRequire(import.meta.url).resolve('tsx')).href, fileURLToPath(new URL('../loop/watchdog.ts', import.meta.url))];
72
+ // The inner watchdog owns the native process tree. Killing only an npm wrapper
73
+ // on spawnSync timeout could otherwise leave the sandbox helper behind.
74
+ const result = spawnSync(process.execPath, [...watchdog, '--idle-ms=30000', '--total-ms=30000', '--', probe.command, ...probe.args], { cwd: inv.cwd, env: prepared.env, shell: false, windowsHide: true, encoding: 'utf8', timeout: 90_000, maxBuffer: 64 * 1024 });
75
+ if (result.status !== 0 || !result.stdout?.includes('YOKE_SHELL_OK')) {
76
+ const signature = /CreateProcessAsUserW failed:\s*-?\d+/u.exec(result.stderr ?? '')?.[0];
77
+ throw Error(`Codex sandbox preflight failed (${signature ?? result.error?.code ?? result.status ?? 'no completion'}); no model started. Check native PowerShell and Codex sandbox setup.`);
78
+ }
79
+ return { ...resolveWindowsCommand(inv.command, [...inv.args, ...config], prepared.env), ...prepared };
80
+ }
@@ -1,4 +1,8 @@
1
1
  import { assessmentInstructions } from "../routing/assessment.js";
2
+ import { bindAssessments, preparedProblems } from '../prd/assess.js';
3
+ import { loadConfig } from '../retrofit/config.js';
4
+ import { resolvePlanner } from '../routing/planning.js';
5
+ import { readPlanningFile } from '../routing/contracts.js';
2
6
  import { randomUUID } from 'node:crypto';
3
7
  import { execFileSync } from 'node:child_process';
4
8
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
@@ -88,13 +92,14 @@ export function pendingChanges(targetDir) {
88
92
  .map(name => ChangeRequestSchema.parse(JSON.parse(readFileSync(join(dir, name), 'utf8'))))
89
93
  .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
90
94
  }
91
- export function buildChangePrompt(request, proposalPath, stories) {
95
+ export function buildChangePrompt(request, proposalPath, stories, brief = '') {
92
96
  return [
93
97
  'You are planning one newly requested product change for the Yoke autonomous loop.',
94
98
  `Change request ${request.id}: ${request.request}`,
95
99
  '',
96
100
  'Create an append-only proposal: add small new stories; never rewrite or delete existing stories.',
97
101
  assessmentInstructions,
102
+ ...(brief ? ['Approved planning brief:', brief] : []),
98
103
  `Existing story IDs: ${stories.map(story => story.id).join(', ') || '(none)'}`,
99
104
  'Every proposed story must have passes: false and 2-5 structured acceptance criteria.',
100
105
  'Every criterion must have a stable id, behavioral text, and one or more executable verify commands.',
@@ -128,6 +133,9 @@ function archiveRequest(targetDir, request) {
128
133
  rmSync(reviewFile(targetDir, request.id), { force: true });
129
134
  }
130
135
  export function runChangeApply(targetDir, opts) {
136
+ const config = loadConfig(targetDir);
137
+ const planner = resolvePlanner(config, opts.runner, opts.selection);
138
+ opts = { ...opts, runner: planner.agent, selection: planner.selection };
131
139
  let requests;
132
140
  try {
133
141
  requests = pendingChanges(targetDir);
@@ -170,15 +178,16 @@ export function runChangeApply(targetDir, opts) {
170
178
  if (!available(reviewer))
171
179
  return { ok: false, added: 0, summary: `reviewer CLI "${reviewer}" is unavailable`, changeId: request.id };
172
180
  const proposal = proposalFile(targetDir, request.id);
181
+ const brief = readPlanningFile(targetDir, '.yoke/plan.md', 80_000) ?? '';
173
182
  const plannerDir = changesDir(targetDir);
174
183
  mkdirSync(plannerDir, { recursive: true });
175
184
  rmSync(proposal, { force: true });
176
- const base = agentInvocation(opts.runner, buildChangePrompt(request, proposal, existing), plannerDir, opts.permissions ?? 'safe', opts.selection);
185
+ const base = agentInvocation(opts.runner, buildChangePrompt(request, proposal, existing, brief), plannerDir, opts.permissions ?? 'safe', opts.selection);
177
186
  const invocation = buildWatchdogInvocation(base, opts.timeoutMs ?? 0);
178
187
  const result = (opts.run ?? runAgent)(invocation);
179
188
  if (!result.success)
180
189
  return { ok: false, added: 0, summary: `planner failed: ${result.summary}`, changeId: request.id };
181
- if (readFileSync(prdPath, 'utf8') !== existingText) {
190
+ if (readFileSync(prdPath, 'utf8') !== existingText || (readPlanningFile(targetDir, '.yoke/plan.md', 80_000) ?? '') !== brief) {
182
191
  return {
183
192
  ok: false,
184
193
  added: 0,
@@ -221,12 +230,19 @@ export function runChangeApply(targetDir, opts) {
221
230
  }
222
231
  proposedIds.add(story.id);
223
232
  }
224
- const appended = proposed.map(story => ({ ...story, sourceChange: request.id }));
233
+ let appended = proposed.map(story => ({ ...story, sourceChange: request.id }));
225
234
  const dependencyIssues = validateDependencies([...existing, ...appended]);
226
235
  if (dependencyIssues.length > 0) {
227
236
  return { ok: false, added: 0, summary: `invalid combined dependency graph: ${dependencyIssues.join('; ')}`, changeId: request.id };
228
237
  }
229
238
  const reviewPath = reviewFile(targetDir, request.id);
239
+ const bound = bindAssessments([...existing, ...appended], brief);
240
+ appended = bound.slice(existing.length).map(s => ({ ...s, sourceChange: request.id }));
241
+ if (config?.routing?.assessmentPolicy === 'prepared') {
242
+ const issues = preparedProblems(bound, brief).filter(issue => appended.some(s => issue.startsWith(s.id + ':')));
243
+ if (issues.length)
244
+ return { ok: false, added: 0, summary: issues.join('; '), changeId: request.id };
245
+ }
230
246
  rmSync(reviewPath, { force: true });
231
247
  const reviewBase = agentInvocation(reviewer, buildChangeReviewPrompt(request, reviewPath, appended), plannerDir, opts.permissions ?? 'safe', reviewer === opts.runner ? opts.selection : undefined);
232
248
  const reviewInvocation = buildWatchdogInvocation(reviewBase, opts.timeoutMs ?? 0);
@@ -234,7 +250,7 @@ export function runChangeApply(targetDir, opts) {
234
250
  if (!reviewResult.success) {
235
251
  return { ok: false, added: 0, summary: `coverage review failed: ${reviewResult.summary}`, changeId: request.id };
236
252
  }
237
- if (readFileSync(prdPath, 'utf8') !== existingText) {
253
+ if (readFileSync(prdPath, 'utf8') !== existingText || (readPlanningFile(targetDir, '.yoke/plan.md', 80_000) ?? '') !== brief) {
238
254
  return {
239
255
  ok: false, added: 0,
240
256
  summary: 'PRD changed while the coverage reviewer was running; refusing to overwrite concurrent or out-of-contract edits',
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ import { runReview } from './review/command.js';
13
13
  import { scanDir } from './scan/design.js';
14
14
  import { runNew } from './new/command.js';
15
15
  import { runPrdDraft, runPrdCheck } from './prd/command.js';
16
+ import { runPrdAssess } from './prd/assess.js';
16
17
  import { runLoopCleanup } from './loop/cleanup.js';
17
18
  import { runFlowSmoke } from './smoke/command.js';
18
19
  import { maybeNotifyUpdate, currentYokeVersion } from './update/check.js';
@@ -550,6 +551,14 @@ export function main(argv) {
550
551
  case 'prd': {
551
552
  const sub = rest[0];
552
553
  const targetDir = rest.slice(1).find(a => !a.startsWith('-')) ?? '.';
554
+ if (sub === 'assess') {
555
+ const runner = rest.find(a => a.startsWith('--runner='))?.slice('--runner='.length);
556
+ if (runner && !['codex', 'claude', 'gemini'].includes(runner)) {
557
+ console.error('Invalid planning runner');
558
+ return 1;
559
+ }
560
+ return runPrdAssess(targetDir, { runner: runner, story: rest.find(a => a.startsWith('--story='))?.slice('--story='.length), reassess: rest.includes('--reassess') });
561
+ }
553
562
  if (sub === 'draft') {
554
563
  const idea = rest.find(a => a.startsWith('--idea='))?.slice('--idea='.length);
555
564
  if (!idea) {
@@ -577,7 +586,7 @@ export function main(argv) {
577
586
  }
578
587
  if (sub === 'check')
579
588
  return runPrdCheck(targetDir);
580
- console.log('usage: yoke prd <draft|check> [dir] [--idea="..."] [--runner=<claude|codex|gemini>] [--force] [--timeout=<minutes>]');
589
+ console.log('usage: yoke prd <draft|check|assess> [dir] [--idea="..."] [--runner=<claude|codex|gemini>] [--story=<id>] [--reassess] [--force] [--timeout=<minutes>]');
581
590
  return 1;
582
591
  }
583
592
  case 'context': {
@@ -642,7 +651,7 @@ export function main(argv) {
642
651
  return runUpgrade();
643
652
  default:
644
653
  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]');
645
- console.log('usage: yoke <setup [dir] | new <dir> [--idea="..."] | validate [canonDir] | retrofit [targetDir] [--agent=claude,codex,gemini|all] [--code-graph=graphify|serena] [--loop] | change <add|status> [dir] | prd <draft|check> [dir] | loop <on|off|status|decision|answer|resume|run|cleanup> | context <init|status> | review [dir] [--reviewer=<claude|codex|gemini>] [--base=<ref>] [--focus="..."] | design-scan [dir] [--max=N] [--report] | flow-smoke [dir] [--url=<baseUrl>] [--label=<name>] | upgrade>');
654
+ console.log('usage: yoke <setup [dir] | new <dir> [--idea="..."] | validate [canonDir] | retrofit [targetDir] [--agent=claude,codex,gemini|all] [--code-graph=graphify|serena] [--loop] | change <add|status> [dir] | prd <draft|check|assess> [dir] | loop <on|off|status|decision|answer|resume|run|cleanup> | context <init|status> | review [dir] [--reviewer=<claude|codex|gemini>] [--base=<ref>] [--focus="..."] | design-scan [dir] [--max=N] [--report] | flow-smoke [dir] [--url=<baseUrl>] [--label=<name>] | upgrade>');
646
655
  return cmd ? 1 : 0;
647
656
  }
648
657
  }