@hecer/yoke 1.8.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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +34 -1
- package/README.md +8 -6
- package/canon/loop/prd.schema.md +6 -0
- package/canon/skills/authoring-prd/SKILL.md +7 -0
- package/dist/agents/process-incarnation.js +1 -1
- package/dist/agents/process.js +74 -6
- package/dist/agents/supervision.js +153 -0
- package/dist/agents/windows-launch.js +80 -0
- package/dist/change/inbox.js +23 -5
- package/dist/cli.js +18 -2
- package/dist/dashboard/page.js +102 -8
- package/dist/dashboard/panels.js +84 -8
- package/dist/goals/command.js +29 -4
- package/dist/loop/git.js +12 -4
- package/dist/loop/loop.js +48 -3
- package/dist/loop/parallel-adapters.js +2 -3
- package/dist/loop/parallel-command.js +14 -7
- package/dist/loop/prd.js +4 -0
- package/dist/loop/reporter.js +8 -1
- package/dist/loop/run-command.js +25 -5
- package/dist/loop/runner.js +27 -31
- package/dist/loop/watchdog.js +87 -11
- package/dist/loop/worker.js +30 -1
- package/dist/prd/assess.js +145 -0
- package/dist/prd/command.js +59 -21
- package/dist/quality/command.js +11 -5
- package/dist/retrofit/config.js +15 -1
- package/dist/retrofit/gitignore.js +1 -1
- package/dist/routing/assessment.js +66 -0
- package/dist/routing/capability.js +91 -0
- package/dist/routing/contracts.js +60 -0
- package/dist/routing/planning.js +12 -0
- package/dist/routing/router.js +85 -2
- package/dist/setup/command.js +23 -9
- package/docs/BATCH-PLANNING-VALIDATION.md +67 -0
- package/docs/CAPABILITY-ROUTING.md +82 -0
- package/docs/DASHBOARD-EVOLUTION.md +33 -0
- package/docs/PRODUCT-DIRECTION-2026-09-05.md +28 -0
- package/docs/WINDOWS-RUNNER-VALIDATION.md +104 -0
- package/docs/assets/yoke-logo.png +0 -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.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",
|
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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.
|
|
25
|
+
|
|
26
|
+
## 1.9.0 — 2026-09-06
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- Add capability-based routing with persisted task assessments, explicit model/effort tiers, role eligibility and conservative use of independent task-class outcomes.
|
|
30
|
+
- Keep planning on the start model; reuse assessments across attempts/worktrees and invalidate them when task requirements change.
|
|
31
|
+
- Add bounded repair and tier escalation after mechanical gate failures, retaining the patch and forwarding failure evidence. Infrastructure failures do not trigger capability escalation.
|
|
32
|
+
- Apply task-based profiles to reviews, quality critics/repairs and goal execution; display implementation selection reasons and next escalation in the dashboard.
|
|
33
|
+
- Add setup options `--routing-strategy=capability` and `--routing-preset` for explicit migration. Preserve existing strategies and custom profiles by default.
|
|
34
|
+
|
|
35
|
+
### Validation limits
|
|
36
|
+
- Initial profile tiers are configurable hypotheses, not authenticated model benchmarks, price estimates or calibrated success probabilities. See [capability routing](docs/CAPABILITY-ROUTING.md) for defaults and bounds.
|
|
4
37
|
|
|
5
38
|
## 1.8.0 — 2026-09-06
|
|
6
39
|
|
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
6
|
-
<!-- yoke:tests:start -->
|
|
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)
|
|
18
18
|

|
|
19
19
|

|
|
20
|
-

|
|
21
21
|

|
|
22
22
|

|
|
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.
|
|
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 (
|
|
900
|
+
npm test # vitest (1173 tests)
|
|
899
901
|
npm run build # tsc, no emit errors
|
|
900
902
|
npm run yoke -- validate canon
|
|
901
903
|
```
|
package/canon/loop/prd.schema.md
CHANGED
|
@@ -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.
|
|
@@ -22,6 +22,13 @@ new stories; they do not require a release object.
|
|
|
22
22
|
placeholders; critical irreversible choices use the structured decision channel.
|
|
23
23
|
8. Use `needs` only for hard prerequisites, `area` for collision domains, and `agent` only as
|
|
24
24
|
a Claude/Codex/Gemini affinity hint.
|
|
25
|
+
9. Keep planning on the start model. Add an `assessment` to each story: `taskClass`
|
|
26
|
+
(`mechanical`, `implementation`, `debugging`, `architecture`), `difficulty`, `uncertainty`,
|
|
27
|
+
`risk`, `scope`, `testability` (each `low`, `medium`, `high`), a concise `reason`, and
|
|
28
|
+
an actionable `approach` including checks. High testability means executable checks
|
|
29
|
+
reliably detect mistakes. Small security-sensitive changes can still be high-risk.
|
|
30
|
+
These are planning judgments, never invented success probabilities; Yoke selects the
|
|
31
|
+
execution model from configured profiles and independent outcomes.
|
|
25
32
|
|
|
26
33
|
## Format (`.yoke/prd.yaml`)
|
|
27
34
|
|
|
@@ -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') {
|
package/dist/agents/process.js
CHANGED
|
@@ -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
|
|
15
|
+
const resolved = platform === 'win32' ? resolveWindowsCommand(invocation.command, invocation.args) : invocation;
|
|
14
16
|
return {
|
|
15
|
-
command:
|
|
16
|
-
args:
|
|
17
|
+
command: resolved.command,
|
|
18
|
+
args: resolved.args,
|
|
17
19
|
cwd: invocation.cwd,
|
|
18
|
-
shell:
|
|
20
|
+
shell: false,
|
|
19
21
|
detached: platform !== 'win32',
|
|
20
22
|
};
|
|
21
23
|
}
|
|
22
24
|
export function startProviderProcess(agent, invocation, options = {}) {
|
|
23
|
-
|
|
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) =>
|
|
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
|
+
}
|
package/dist/change/inbox.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
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';
|
|
1
6
|
import { randomUUID } from 'node:crypto';
|
|
2
7
|
import { execFileSync } from 'node:child_process';
|
|
3
8
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
@@ -87,12 +92,14 @@ export function pendingChanges(targetDir) {
|
|
|
87
92
|
.map(name => ChangeRequestSchema.parse(JSON.parse(readFileSync(join(dir, name), 'utf8'))))
|
|
88
93
|
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
89
94
|
}
|
|
90
|
-
export function buildChangePrompt(request, proposalPath, stories) {
|
|
95
|
+
export function buildChangePrompt(request, proposalPath, stories, brief = '') {
|
|
91
96
|
return [
|
|
92
97
|
'You are planning one newly requested product change for the Yoke autonomous loop.',
|
|
93
98
|
`Change request ${request.id}: ${request.request}`,
|
|
94
99
|
'',
|
|
95
100
|
'Create an append-only proposal: add small new stories; never rewrite or delete existing stories.',
|
|
101
|
+
assessmentInstructions,
|
|
102
|
+
...(brief ? ['Approved planning brief:', brief] : []),
|
|
96
103
|
`Existing story IDs: ${stories.map(story => story.id).join(', ') || '(none)'}`,
|
|
97
104
|
'Every proposed story must have passes: false and 2-5 structured acceptance criteria.',
|
|
98
105
|
'Every criterion must have a stable id, behavioral text, and one or more executable verify commands.',
|
|
@@ -126,6 +133,9 @@ function archiveRequest(targetDir, request) {
|
|
|
126
133
|
rmSync(reviewFile(targetDir, request.id), { force: true });
|
|
127
134
|
}
|
|
128
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 };
|
|
129
139
|
let requests;
|
|
130
140
|
try {
|
|
131
141
|
requests = pendingChanges(targetDir);
|
|
@@ -168,15 +178,16 @@ export function runChangeApply(targetDir, opts) {
|
|
|
168
178
|
if (!available(reviewer))
|
|
169
179
|
return { ok: false, added: 0, summary: `reviewer CLI "${reviewer}" is unavailable`, changeId: request.id };
|
|
170
180
|
const proposal = proposalFile(targetDir, request.id);
|
|
181
|
+
const brief = readPlanningFile(targetDir, '.yoke/plan.md', 80_000) ?? '';
|
|
171
182
|
const plannerDir = changesDir(targetDir);
|
|
172
183
|
mkdirSync(plannerDir, { recursive: true });
|
|
173
184
|
rmSync(proposal, { force: true });
|
|
174
|
-
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);
|
|
175
186
|
const invocation = buildWatchdogInvocation(base, opts.timeoutMs ?? 0);
|
|
176
187
|
const result = (opts.run ?? runAgent)(invocation);
|
|
177
188
|
if (!result.success)
|
|
178
189
|
return { ok: false, added: 0, summary: `planner failed: ${result.summary}`, changeId: request.id };
|
|
179
|
-
if (readFileSync(prdPath, 'utf8') !== existingText) {
|
|
190
|
+
if (readFileSync(prdPath, 'utf8') !== existingText || (readPlanningFile(targetDir, '.yoke/plan.md', 80_000) ?? '') !== brief) {
|
|
180
191
|
return {
|
|
181
192
|
ok: false,
|
|
182
193
|
added: 0,
|
|
@@ -219,12 +230,19 @@ export function runChangeApply(targetDir, opts) {
|
|
|
219
230
|
}
|
|
220
231
|
proposedIds.add(story.id);
|
|
221
232
|
}
|
|
222
|
-
|
|
233
|
+
let appended = proposed.map(story => ({ ...story, sourceChange: request.id }));
|
|
223
234
|
const dependencyIssues = validateDependencies([...existing, ...appended]);
|
|
224
235
|
if (dependencyIssues.length > 0) {
|
|
225
236
|
return { ok: false, added: 0, summary: `invalid combined dependency graph: ${dependencyIssues.join('; ')}`, changeId: request.id };
|
|
226
237
|
}
|
|
227
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
|
+
}
|
|
228
246
|
rmSync(reviewPath, { force: true });
|
|
229
247
|
const reviewBase = agentInvocation(reviewer, buildChangeReviewPrompt(request, reviewPath, appended), plannerDir, opts.permissions ?? 'safe', reviewer === opts.runner ? opts.selection : undefined);
|
|
230
248
|
const reviewInvocation = buildWatchdogInvocation(reviewBase, opts.timeoutMs ?? 0);
|
|
@@ -232,7 +250,7 @@ export function runChangeApply(targetDir, opts) {
|
|
|
232
250
|
if (!reviewResult.success) {
|
|
233
251
|
return { ok: false, added: 0, summary: `coverage review failed: ${reviewResult.summary}`, changeId: request.id };
|
|
234
252
|
}
|
|
235
|
-
if (readFileSync(prdPath, 'utf8') !== existingText) {
|
|
253
|
+
if (readFileSync(prdPath, 'utf8') !== existingText || (readPlanningFile(targetDir, '.yoke/plan.md', 80_000) ?? '') !== brief) {
|
|
236
254
|
return {
|
|
237
255
|
ok: false, added: 0,
|
|
238
256
|
summary: 'PRD changed while the coverage reviewer was running; refusing to overwrite concurrent or out-of-contract edits',
|