@hecer/yoke 1.2.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +66 -24
- package/README.md +186 -104
- package/TODOS.md +0 -3
- package/canon/loop/loop-spec.md +53 -24
- package/canon/loop/prd.schema.md +30 -6
- package/canon/manifest.yaml +1 -1
- package/canon/skills/authoring-prd/SKILL.md +30 -31
- package/dist/agents/contracts.js +50 -0
- package/dist/agents/process-incarnation.js +15 -0
- package/dist/agents/process-record.js +65 -0
- package/dist/agents/process-streams.js +40 -0
- package/dist/agents/process.js +177 -0
- package/dist/agents/providers.js +10 -7
- package/dist/agents/telemetry.js +62 -0
- package/dist/change/inbox.js +279 -0
- package/dist/cli.js +90 -4
- package/dist/loop/candidate-boundaries.js +43 -0
- package/dist/loop/candidate-cleanup.js +98 -0
- package/dist/loop/candidate-contracts.js +1 -0
- package/dist/loop/candidate-selection.js +84 -0
- package/dist/loop/candidates.js +228 -0
- package/dist/loop/claim-lease.js +131 -0
- package/dist/loop/claims.js +177 -40
- package/dist/loop/cleanup.js +117 -15
- package/dist/loop/decision.js +31 -0
- package/dist/loop/dispatcher.js +334 -0
- package/dist/loop/evidence.js +31 -0
- package/dist/loop/gates.js +10 -1
- package/dist/loop/loop.js +236 -33
- package/dist/loop/merge-queue.js +12 -6
- package/dist/loop/parallel-adapters.js +185 -0
- package/dist/loop/parallel-command.js +287 -0
- package/dist/loop/parallel.js +2 -4
- package/dist/loop/prd.js +63 -2
- package/dist/loop/reporter.js +86 -5
- package/dist/loop/run-command.js +227 -53
- package/dist/loop/runner.js +77 -34
- package/dist/loop/verify.js +11 -0
- package/dist/loop/watchdog.js +67 -8
- package/dist/loop/worker-cancellation.js +17 -0
- package/dist/loop/worker-cleanup.js +23 -0
- package/dist/loop/worker-contracts.js +1 -0
- package/dist/loop/worker.js +254 -0
- package/dist/prd/command.js +18 -5
- package/dist/quality/artifacts.js +59 -0
- package/dist/quality/candidate-comparison.js +130 -0
- package/dist/quality/command.js +316 -0
- package/dist/quality/loop.js +86 -0
- package/dist/quality/process-command.js +57 -0
- package/dist/quality/reference.js +187 -0
- package/dist/quality/repair.js +11 -0
- package/dist/quality/runner.js +66 -0
- package/dist/quality/types.js +60 -0
- package/dist/quality/verdict.js +142 -0
- package/dist/retrofit/config.js +13 -4
- package/dist/retrofit/gitignore.js +4 -0
- package/dist/review/command.js +27 -38
- package/dist/review/verdict.js +38 -7
- package/dist/routing/router.js +4 -1
- package/docs/MIGRATING-TO-1.4.md +70 -0
- package/docs/PUBLISHING.md +16 -2
- package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -0
- package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
- package/gemini-extension.json +1 -1
- package/package.json +6 -6
package/dist/loop/watchdog.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { constants } from 'node:os';
|
|
2
3
|
import { writeFileSync, rmSync } from 'node:fs';
|
|
3
4
|
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { processIncarnation } from '../agents/process-incarnation.js';
|
|
4
6
|
// Kill one recorded process tree, platform-appropriately. Exported for
|
|
5
7
|
// `yoke loop cleanup` (scoped reaping of recorded runner pids).
|
|
6
|
-
export function killProcessTree(pid) {
|
|
8
|
+
export function killProcessTree(pid, force = true) {
|
|
7
9
|
if (process.platform === 'win32') {
|
|
8
10
|
try {
|
|
9
11
|
spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
|
|
@@ -12,11 +14,51 @@ export function killProcessTree(pid) {
|
|
|
12
14
|
}
|
|
13
15
|
else {
|
|
14
16
|
try {
|
|
15
|
-
process.kill(pid, 'SIGKILL');
|
|
17
|
+
process.kill(pid, force ? 'SIGKILL' : 'SIGTERM');
|
|
16
18
|
}
|
|
17
19
|
catch { /* already gone */ }
|
|
18
20
|
}
|
|
19
21
|
}
|
|
22
|
+
function waitForCleanupRetry() {
|
|
23
|
+
spawnSync(process.execPath, ['-e', 'Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25)'], { stdio: 'ignore' });
|
|
24
|
+
}
|
|
25
|
+
export function killProcessForCleanup(pid, platform = process.platform, runTaskkill = (command, args) => spawnSync(command, args, { stdio: 'ignore' }).status, sendSignal = (target, signal) => { process.kill(target, signal); }, isProcessAlive = (target) => {
|
|
26
|
+
try {
|
|
27
|
+
process.kill(target, 0);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}) {
|
|
34
|
+
if (platform === 'win32')
|
|
35
|
+
return runTaskkill('taskkill', ['/PID', String(pid), '/T', '/F']) === 0;
|
|
36
|
+
try {
|
|
37
|
+
sendSignal(pid, 'SIGKILL');
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
return error.code === 'ESRCH';
|
|
41
|
+
}
|
|
42
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
43
|
+
if (!isProcessAlive(pid))
|
|
44
|
+
return true;
|
|
45
|
+
waitForCleanupRetry();
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
export function killProcessTreeForCleanup(pid, platform = process.platform, runTaskkill = (command, args) => spawnSync(command, args, { stdio: 'ignore' }).status, sendSignal = (target, signal) => { process.kill(target, signal); }) {
|
|
50
|
+
if (platform === 'win32')
|
|
51
|
+
return runTaskkill('taskkill', ['/PID', String(pid), '/T', '/F']) === 0;
|
|
52
|
+
try {
|
|
53
|
+
// Provider processes run detached on POSIX, so their PID is also the
|
|
54
|
+
// process-group leader. Signal the group to reap descendants as well.
|
|
55
|
+
sendSignal(-pid, 'SIGKILL');
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
return error.code === 'ESRCH';
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
20
62
|
// win32 default: kill the whole tree. Console apps have no reliable soft-close
|
|
21
63
|
// on Windows, so both phases force (/F) — the grace pass is a retry, not an
|
|
22
64
|
// escalation. Best-effort by design: taskkill is a standard Windows component.
|
|
@@ -30,7 +72,7 @@ export function runWatchdog(opts) {
|
|
|
30
72
|
const spawnFn = opts.spawnFn ?? spawn;
|
|
31
73
|
const out = opts.out ?? ((d) => process.stdout.write(d));
|
|
32
74
|
const err = opts.err ?? ((d) => process.stderr.write(d));
|
|
33
|
-
const child = spawnFn(opts.command, opts.args, { shell: process.platform === 'win32' });
|
|
75
|
+
const child = spawnFn(opts.command, opts.args, { shell: process.platform === 'win32', detached: process.platform !== 'win32' });
|
|
34
76
|
if (opts.stdin && child.stdin) {
|
|
35
77
|
try {
|
|
36
78
|
opts.stdin.pipe(child.stdin);
|
|
@@ -39,7 +81,7 @@ export function runWatchdog(opts) {
|
|
|
39
81
|
}
|
|
40
82
|
if (opts.pidFile && child.pid !== undefined) {
|
|
41
83
|
try {
|
|
42
|
-
writeFileSync(opts.pidFile, JSON.stringify({ watchdogPid: process.pid, childPid: child.pid, startedAt: new Date().toISOString() }));
|
|
84
|
+
writeFileSync(opts.pidFile, JSON.stringify({ watchdogPid: process.pid, watchdogIncarnation: processIncarnation(process.pid), childPid: child.pid, childIncarnation: processIncarnation(child.pid), startedAt: new Date().toISOString() }));
|
|
43
85
|
}
|
|
44
86
|
catch { /* best-effort — cleanup falls back to worktree/lock handling */ }
|
|
45
87
|
}
|
|
@@ -54,12 +96,15 @@ export function runWatchdog(opts) {
|
|
|
54
96
|
const graceMs = opts.graceMs ?? 5000;
|
|
55
97
|
// Explicitly-passed killTree wins (including an explicit undefined, which pins
|
|
56
98
|
// the per-process signal path — tests use this to be platform-independent).
|
|
57
|
-
const killTree = 'killTree' in opts ? opts.killTree : (
|
|
99
|
+
const killTree = 'killTree' in opts ? opts.killTree : (pid, _force) => killProcessTreeForCleanup(pid);
|
|
58
100
|
// Terminate the child — via the tree-killer when we have one and a pid,
|
|
59
101
|
// otherwise per-process signals (POSIX default; SIGKILL is uncatchable).
|
|
102
|
+
let terminationRequested = false;
|
|
103
|
+
let terminationConfirmed = false;
|
|
60
104
|
const terminate = (child, force) => {
|
|
105
|
+
terminationRequested = true;
|
|
61
106
|
if (killTree && child.pid !== undefined) {
|
|
62
|
-
killTree(child.pid, force);
|
|
107
|
+
terminationConfirmed = killTree(child.pid, force) === true || terminationConfirmed;
|
|
63
108
|
return;
|
|
64
109
|
}
|
|
65
110
|
try {
|
|
@@ -111,7 +156,21 @@ export function runWatchdog(opts) {
|
|
|
111
156
|
child.stdout.on('data', (d) => { out(d); arm(); });
|
|
112
157
|
child.stderr.on('data', (d) => { err(d); arm(); });
|
|
113
158
|
child.on('error', () => { clear(); removePidFile(); resolve(127); });
|
|
114
|
-
child.on('close', (code) => {
|
|
159
|
+
child.on('close', (code, signal) => {
|
|
160
|
+
clear();
|
|
161
|
+
if (!terminationRequested || terminationConfirmed)
|
|
162
|
+
removePidFile();
|
|
163
|
+
if (killedForIdle) {
|
|
164
|
+
resolve(124);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (code !== null) {
|
|
168
|
+
resolve(code);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const signalNumber = signal ? constants.signals[signal] : undefined;
|
|
172
|
+
resolve(signalNumber === undefined ? 1 : 128 + signalNumber);
|
|
173
|
+
});
|
|
115
174
|
arm();
|
|
116
175
|
});
|
|
117
176
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function bindStoryWorkerCancellation(cancellation, handle) {
|
|
2
|
+
const signal = cancellation?.signal;
|
|
3
|
+
if (!signal)
|
|
4
|
+
return () => { };
|
|
5
|
+
const cancel = () => {
|
|
6
|
+
const reason = typeof signal.reason === 'string' && signal.reason.length > 0
|
|
7
|
+
? signal.reason
|
|
8
|
+
: 'worker cancellation requested';
|
|
9
|
+
handle.cancel(reason);
|
|
10
|
+
};
|
|
11
|
+
if (signal.aborted) {
|
|
12
|
+
cancel();
|
|
13
|
+
return () => { };
|
|
14
|
+
}
|
|
15
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
16
|
+
return () => signal.removeEventListener('abort', cancel);
|
|
17
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function createWorkerCleanup(steps) {
|
|
2
|
+
const completedByWorker = new WeakMap();
|
|
3
|
+
const orderedSteps = [steps.cleanupProcess ?? (() => undefined), steps.removeWorktree, steps.releaseClaim];
|
|
4
|
+
return input => {
|
|
5
|
+
const completed = completedByWorker.get(input) ?? new Set();
|
|
6
|
+
completedByWorker.set(input, completed);
|
|
7
|
+
for (const [index, step] of orderedSteps.entries()) {
|
|
8
|
+
if (completed.has(index))
|
|
9
|
+
continue;
|
|
10
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
11
|
+
try {
|
|
12
|
+
step(input);
|
|
13
|
+
completed.add(index);
|
|
14
|
+
break;
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (attempt === 1)
|
|
18
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { isAcceptanceCriterion } from './prd.js';
|
|
2
|
+
import { runQualityRepairLoop } from '../quality/loop.js';
|
|
3
|
+
function emptyEvidence() { return { criteria: [] }; }
|
|
4
|
+
function errorMessage(error) {
|
|
5
|
+
return error instanceof Error ? error.message : String(error);
|
|
6
|
+
}
|
|
7
|
+
function assertNever(value) {
|
|
8
|
+
throw new Error(`Unexpected worker result: ${String(value)}`);
|
|
9
|
+
}
|
|
10
|
+
function cancellationReason(cancellation) {
|
|
11
|
+
if (cancellation?.signal?.aborted) {
|
|
12
|
+
return typeof cancellation.signal.reason === 'string' && cancellation.signal.reason.length > 0
|
|
13
|
+
? cancellation.signal.reason
|
|
14
|
+
: 'worker cancellation requested';
|
|
15
|
+
}
|
|
16
|
+
return cancellation?.handle?.isCancelled() ? 'worker cancellation requested' : null;
|
|
17
|
+
}
|
|
18
|
+
function runGate(gate, worktree, storyId) {
|
|
19
|
+
const previous = process.env.YOKE_STORY;
|
|
20
|
+
process.env.YOKE_STORY = storyId;
|
|
21
|
+
try {
|
|
22
|
+
return gate(worktree);
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
if (previous === undefined)
|
|
26
|
+
delete process.env.YOKE_STORY;
|
|
27
|
+
else
|
|
28
|
+
process.env.YOKE_STORY = previous;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function reviewOutcome(result) {
|
|
32
|
+
if (result.reviewOutcome)
|
|
33
|
+
return result.reviewOutcome;
|
|
34
|
+
if (result.success)
|
|
35
|
+
return { kind: 'approved', verdict: { approved: true, summary: result.summary, findings: [] } };
|
|
36
|
+
return { kind: 'malformed', summary: result.summary };
|
|
37
|
+
}
|
|
38
|
+
function runMechanicalGates(input, context, evidence) {
|
|
39
|
+
const criteria = context.story.acceptance.filter(isAcceptanceCriterion);
|
|
40
|
+
if (criteria.length === 0) {
|
|
41
|
+
if (input.requireCriterionEvidence)
|
|
42
|
+
return { kind: 'failed', stage: 'criterion', summary: `story ${context.story.id} lacks executable criterion evidence` };
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
if (!input.verifyCriterion)
|
|
46
|
+
return { kind: 'failed', stage: 'criterion', summary: `story ${context.story.id} has criteria but no criterion verifier` };
|
|
47
|
+
for (const criterion of criteria) {
|
|
48
|
+
const cancelled = cancellationReason(input.cancellation);
|
|
49
|
+
if (cancelled)
|
|
50
|
+
return { kind: 'cancelled', summary: cancelled };
|
|
51
|
+
const result = input.verifyCriterion(context.targetDir, context.story, criterion);
|
|
52
|
+
const criteriaEvidence = [...evidence.criteria, { id: criterion.id, passed: result.passed, summary: result.summary }];
|
|
53
|
+
evidence.criteria = criteriaEvidence;
|
|
54
|
+
input.callbacks?.onGate?.('criterion', result);
|
|
55
|
+
if (!result.passed)
|
|
56
|
+
return { kind: 'failed', stage: 'criterion', summary: result.summary };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const cancelled = cancellationReason(input.cancellation);
|
|
60
|
+
if (cancelled)
|
|
61
|
+
return { kind: 'cancelled', summary: cancelled };
|
|
62
|
+
input.reporter?.phase('verifying');
|
|
63
|
+
const verify = runGate(input.verify, context.targetDir, context.story.id);
|
|
64
|
+
evidence.verify = verify;
|
|
65
|
+
input.callbacks?.onGate?.('verify', verify);
|
|
66
|
+
if (!verify.passed)
|
|
67
|
+
return { kind: 'failed', stage: 'verify', summary: verify.summary };
|
|
68
|
+
const afterVerifyCancellation = cancellationReason(input.cancellation);
|
|
69
|
+
if (afterVerifyCancellation)
|
|
70
|
+
return { kind: 'cancelled', summary: afterVerifyCancellation };
|
|
71
|
+
if (input.perf) {
|
|
72
|
+
input.reporter?.phase('perf');
|
|
73
|
+
const perf = runGate(input.perf, context.targetDir, context.story.id);
|
|
74
|
+
evidence.perf = perf;
|
|
75
|
+
input.callbacks?.onGate?.('perf', perf);
|
|
76
|
+
if (!perf.passed)
|
|
77
|
+
return { kind: 'failed', stage: 'perf', summary: perf.summary };
|
|
78
|
+
const afterPerfCancellation = cancellationReason(input.cancellation);
|
|
79
|
+
if (afterPerfCancellation)
|
|
80
|
+
return { kind: 'cancelled', summary: afterPerfCancellation };
|
|
81
|
+
}
|
|
82
|
+
if (input.audit) {
|
|
83
|
+
input.reporter?.phase('audit');
|
|
84
|
+
const audit = runGate(input.audit, context.targetDir, context.story.id);
|
|
85
|
+
evidence.audit = audit;
|
|
86
|
+
input.callbacks?.onGate?.('audit', audit);
|
|
87
|
+
if (!audit.passed)
|
|
88
|
+
return { kind: 'failed', stage: 'audit', summary: audit.summary };
|
|
89
|
+
const afterAuditCancellation = cancellationReason(input.cancellation);
|
|
90
|
+
if (afterAuditCancellation)
|
|
91
|
+
return { kind: 'cancelled', summary: afterAuditCancellation };
|
|
92
|
+
}
|
|
93
|
+
return { kind: 'passed' };
|
|
94
|
+
}
|
|
95
|
+
function finalResult(input, result) {
|
|
96
|
+
input.callbacks?.onResult?.(result);
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
function baseResult(input, evidence, summary) {
|
|
100
|
+
return {
|
|
101
|
+
storyId: input.story.id,
|
|
102
|
+
worktree: input.worktree,
|
|
103
|
+
baseCommit: input.baseCommit,
|
|
104
|
+
provider: input.provider,
|
|
105
|
+
summary,
|
|
106
|
+
evidence,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function resultFromQuality(input, evidence, summary, outcome) {
|
|
110
|
+
switch (outcome.kind) {
|
|
111
|
+
case 'approved':
|
|
112
|
+
return {
|
|
113
|
+
...baseResult(input, evidence, summary),
|
|
114
|
+
kind: 'candidate',
|
|
115
|
+
routing: { outcome: 'pending-integration' },
|
|
116
|
+
};
|
|
117
|
+
case 'paused':
|
|
118
|
+
return { ...baseResult(input, evidence, summary), kind: 'paused' };
|
|
119
|
+
case 'cancelled':
|
|
120
|
+
return { ...baseResult(input, evidence, outcome.summary), kind: 'cancelled' };
|
|
121
|
+
case 'blocked':
|
|
122
|
+
switch (outcome.reason) {
|
|
123
|
+
case 'quality-inconsistent':
|
|
124
|
+
return { ...baseResult(input, evidence, outcome.summary ?? outcome.reason), kind: 'quality-failure', reason: 'inconsistent' };
|
|
125
|
+
case 'quality-infrastructure':
|
|
126
|
+
return { ...baseResult(input, evidence, outcome.summary ?? outcome.reason), kind: 'quality-failure', reason: 'infrastructure' };
|
|
127
|
+
case 'review-malformed':
|
|
128
|
+
return { ...baseResult(input, evidence, outcome.summary ?? outcome.reason), kind: 'review-failure', reason: 'malformed' };
|
|
129
|
+
case 'review-infrastructure':
|
|
130
|
+
return { ...baseResult(input, evidence, outcome.summary ?? outcome.reason), kind: 'review-failure', reason: 'infrastructure' };
|
|
131
|
+
case 'gate-failed':
|
|
132
|
+
return { ...baseResult(input, evidence, outcome.summary), kind: 'mechanical-failure', stage: outcome.stage };
|
|
133
|
+
case 'no-actionable-finding':
|
|
134
|
+
case 'round-budget-exhausted':
|
|
135
|
+
case 'time-budget-exhausted':
|
|
136
|
+
case 'repair-failed':
|
|
137
|
+
return { ...baseResult(input, evidence, outcome.summary ?? outcome.reason), kind: 'mechanical-failure', stage: 'quality' };
|
|
138
|
+
default:
|
|
139
|
+
return assertNever(outcome);
|
|
140
|
+
}
|
|
141
|
+
default:
|
|
142
|
+
return assertNever(outcome);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
export async function runStoryWorker(input) {
|
|
146
|
+
const evidence = emptyEvidence();
|
|
147
|
+
const cancelled = cancellationReason(input.cancellation);
|
|
148
|
+
if (cancelled)
|
|
149
|
+
return finalResult(input, { ...baseResult(input, evidence, cancelled), kind: 'cancelled' });
|
|
150
|
+
const workerStory = structuredClone(input.story);
|
|
151
|
+
const context = { targetDir: input.worktree, story: workerStory };
|
|
152
|
+
const qualityEnabled = input.qualityEnabled?.(workerStory) ?? input.qualityStage !== undefined;
|
|
153
|
+
const preflight = qualityEnabled ? input.qualityPreflight?.(context) : undefined;
|
|
154
|
+
if (preflight?.kind === 'blocked') {
|
|
155
|
+
return finalResult(input, { ...baseResult(input, evidence, preflight.summary), kind: 'mechanical-failure', stage: 'quality' });
|
|
156
|
+
}
|
|
157
|
+
let implementation;
|
|
158
|
+
try {
|
|
159
|
+
implementation = await input.runner(context);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
return finalResult(input, {
|
|
163
|
+
...baseResult(input, evidence, `worker implementation failed: ${errorMessage(error)}`),
|
|
164
|
+
kind: 'mechanical-failure',
|
|
165
|
+
stage: 'implementation',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (implementation.tokens)
|
|
169
|
+
input.reporter?.addTokens(implementation.tokens);
|
|
170
|
+
const afterImplementationCancellation = cancellationReason(input.cancellation);
|
|
171
|
+
if (afterImplementationCancellation) {
|
|
172
|
+
return finalResult(input, { ...baseResult(input, evidence, afterImplementationCancellation), kind: 'cancelled' });
|
|
173
|
+
}
|
|
174
|
+
const decision = input.beforeGates?.(context);
|
|
175
|
+
if (decision) {
|
|
176
|
+
return finalResult(input, { ...baseResult(input, evidence, decision), kind: 'paused', reason: 'decision' });
|
|
177
|
+
}
|
|
178
|
+
const gates = runMechanicalGates(input, context, evidence);
|
|
179
|
+
if (gates.kind === 'cancelled') {
|
|
180
|
+
return finalResult(input, { ...baseResult(input, evidence, gates.summary), kind: 'cancelled' });
|
|
181
|
+
}
|
|
182
|
+
if (gates.kind === 'failed') {
|
|
183
|
+
implementation.routing?.recordOutcome(false);
|
|
184
|
+
return finalResult(input, { ...baseResult(input, evidence, gates.summary), kind: 'mechanical-failure', stage: gates.stage });
|
|
185
|
+
}
|
|
186
|
+
const summary = implementation.success
|
|
187
|
+
? implementation.summary
|
|
188
|
+
: `${implementation.summary} (runner exited non-zero but verify is green)`;
|
|
189
|
+
if (!qualityEnabled && !input.review) {
|
|
190
|
+
return finalResult(input, {
|
|
191
|
+
...baseResult(input, evidence, summary),
|
|
192
|
+
kind: 'candidate',
|
|
193
|
+
routing: { outcome: 'pending-integration', ...(implementation.routing ? { recordOutcome: implementation.routing.recordOutcome } : {}) },
|
|
194
|
+
...(implementation.tokens ? { tokens: implementation.tokens } : {}),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const beforeQualityCancellation = cancellationReason(input.cancellation);
|
|
198
|
+
if (beforeQualityCancellation) {
|
|
199
|
+
return finalResult(input, { ...baseResult(input, evidence, beforeQualityCancellation), kind: 'cancelled' });
|
|
200
|
+
}
|
|
201
|
+
const qualityStage = qualityEnabled ? input.qualityStage : undefined;
|
|
202
|
+
const review = input.review;
|
|
203
|
+
const quality = runQualityRepairLoop({
|
|
204
|
+
...(qualityStage
|
|
205
|
+
? { quality: (round) => {
|
|
206
|
+
input.reporter?.phase('comparing');
|
|
207
|
+
return qualityStage(context, round);
|
|
208
|
+
} }
|
|
209
|
+
: {}),
|
|
210
|
+
...(review
|
|
211
|
+
? { review: () => {
|
|
212
|
+
input.reporter?.phase('reviewing');
|
|
213
|
+
return reviewOutcome(review(context));
|
|
214
|
+
} }
|
|
215
|
+
: {}),
|
|
216
|
+
repair: request => {
|
|
217
|
+
if (!input.repair)
|
|
218
|
+
return { kind: 'blocked', summary: 'repair callback is not configured' };
|
|
219
|
+
input.reporter?.phase('repairing');
|
|
220
|
+
const repair = input.repair(context, request);
|
|
221
|
+
return repair.success ? { kind: 'repaired' } : { kind: 'blocked', summary: repair.summary };
|
|
222
|
+
},
|
|
223
|
+
rerunGates: () => {
|
|
224
|
+
const rerun = runMechanicalGates(input, context, evidence);
|
|
225
|
+
if (rerun.kind === 'passed')
|
|
226
|
+
return { kind: 'passed' };
|
|
227
|
+
if (rerun.kind === 'cancelled')
|
|
228
|
+
return rerun;
|
|
229
|
+
return { kind: 'failed', stage: rerun.stage, summary: rerun.summary };
|
|
230
|
+
},
|
|
231
|
+
...(input.repairLimits ? { limits: input.repairLimits } : {}),
|
|
232
|
+
pause: input.pause,
|
|
233
|
+
onStatus: status => {
|
|
234
|
+
const metadata = input.qualityMetadata?.(context);
|
|
235
|
+
input.reporter?.quality({ ...status, ...(metadata ?? { policy: 'blocking' }) });
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
evidence.quality = quality;
|
|
239
|
+
const afterQualityCancellation = cancellationReason(input.cancellation);
|
|
240
|
+
if (afterQualityCancellation) {
|
|
241
|
+
return finalResult(input, { ...baseResult(input, evidence, afterQualityCancellation), kind: 'cancelled' });
|
|
242
|
+
}
|
|
243
|
+
const result = resultFromQuality(input, evidence, summary, quality);
|
|
244
|
+
if (result.kind !== 'candidate' && result.kind !== 'paused')
|
|
245
|
+
implementation.routing?.recordOutcome(false);
|
|
246
|
+
if (result.kind === 'candidate') {
|
|
247
|
+
return finalResult(input, {
|
|
248
|
+
...result,
|
|
249
|
+
routing: { outcome: 'pending-integration', ...(implementation.routing ? { recordOutcome: implementation.routing.recordOutcome } : {}) },
|
|
250
|
+
...(implementation.tokens ? { tokens: implementation.tokens } : {}),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return finalResult(input, result);
|
|
254
|
+
}
|
package/dist/prd/command.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { loadConfig } from '../retrofit/config.js';
|
|
4
|
-
import { loadPrd, progress } from '../loop/prd.js';
|
|
4
|
+
import { acceptanceText, criterionCommandProblem, isAcceptanceCriterion, loadPrd, progress } from '../loop/prd.js';
|
|
5
5
|
import { agentInvocation, buildWatchdogInvocation, runAgent, isAgentAvailable, } from '../loop/runner.js';
|
|
6
6
|
import { resolveIdleMs } from '../loop/run-command.js';
|
|
7
7
|
import { detectHostAgent, resolveRunnerAgent } from '../agents/host.js';
|
|
@@ -14,8 +14,12 @@ export const PRD_TEMPLATE = `# Yoke PRD — the loop picks the lowest-priority o
|
|
|
14
14
|
# area: foundation # optional collision domain for parallel runs
|
|
15
15
|
# agent: codex # optional claude|codex|gemini affinity
|
|
16
16
|
# acceptance:
|
|
17
|
-
# -
|
|
18
|
-
#
|
|
17
|
+
# - id: suite-runs
|
|
18
|
+
# text: "the project test suite can run"
|
|
19
|
+
# verify: ["npm run test:suite-runs"]
|
|
20
|
+
# - id: scaffold-starts
|
|
21
|
+
# text: "the scaffolded application starts"
|
|
22
|
+
# verify: ["npm run test:scaffold-starts"]
|
|
19
23
|
# passes: false
|
|
20
24
|
[]
|
|
21
25
|
`;
|
|
@@ -30,7 +34,7 @@ export function buildPrdDraftPrompt(idea, planningBrief) {
|
|
|
30
34
|
if (planningBrief?.trim()) {
|
|
31
35
|
lines.push('', '## Approved planning brief (treat these decisions as settled)', planningBrief.trim(), '', 'Do not reopen settled choices or invent alternatives that contradict this brief.');
|
|
32
36
|
}
|
|
33
|
-
lines.push('', 'Break the idea into 5-12 small, independently shippable stories; each must fit one loop iteration.', 'Each story needs:', '- id: STORY-1, STORY-2, ... (unique)', '- title: one imperative sentence', '- priority: dense integers from 1 (lower = built first)', '- needs: optional list of story IDs that must pass first; the graph must be acyclic', '- area: optional collision domain for safe parallel scheduling', '- agent: optional claude|codex|gemini affinity', '- acceptance: 2-5 testable, behavioral criteria (observable outcomes, never implementation steps)', '- passes: false', '', 'If the project has no source code yet, STORY-1 must scaffold the project skeleton with a runnable', 'test suite, and its acceptance must include that the verify command (verify.command in', '.yoke/config.yaml) exits 0.', '', 'Write ONLY the file .yoke/prd.yaml as a YAML array of stories in exactly that shape.', 'Do not modify any other file. Do not commit.');
|
|
37
|
+
lines.push('', 'Break the idea into 5-12 small, independently shippable stories; each must fit one loop iteration.', 'Each story needs:', '- id: STORY-1, STORY-2, ... (unique)', '- title: one imperative sentence', '- priority: dense integers from 1 (lower = built first)', '- needs: optional list of story IDs that must pass first; the graph must be acyclic', '- area: optional collision domain for safe parallel scheduling', '- agent: optional claude|codex|gemini affinity', '- acceptance: 2-5 testable, behavioral criteria (observable outcomes, never implementation steps)', ' Each criterion is an object with a stable id, behavioral text, and verify: [one or more approved test commands].', ' Every criterion id must appear in every verify command; use one test command without shell control operators.', '- passes: false', '', 'If the project has no source code yet, STORY-1 must scaffold the project skeleton with a runnable', 'test suite, and its acceptance must include that the verify command (verify.command in', '.yoke/config.yaml) exits 0.', '', 'Write ONLY the file .yoke/prd.yaml as a YAML array of stories in exactly that shape.', 'Do not modify any other file. Do not commit.');
|
|
34
38
|
return lines.join('\n');
|
|
35
39
|
}
|
|
36
40
|
export function prdFile(targetDir) {
|
|
@@ -113,6 +117,7 @@ export function runPrdCheck(targetDir) {
|
|
|
113
117
|
return 1;
|
|
114
118
|
}
|
|
115
119
|
const errors = [];
|
|
120
|
+
const requireCriteria = loadConfig(targetDir)?.verify?.requireCriteria ?? false;
|
|
116
121
|
if (stories.length === 0)
|
|
117
122
|
errors.push('PRD has no stories');
|
|
118
123
|
const seen = new Set();
|
|
@@ -123,7 +128,15 @@ export function runPrdCheck(targetDir) {
|
|
|
123
128
|
// the schema allows [], but the loop's stop-the-line gate blocks it — fail fast here
|
|
124
129
|
if (s.acceptance.length === 0)
|
|
125
130
|
errors.push(`story ${s.id} has no acceptance criteria`);
|
|
126
|
-
if (s.acceptance.some(criterion =>
|
|
131
|
+
if (requireCriteria && s.acceptance.some(criterion => !isAcceptanceCriterion(criterion))) {
|
|
132
|
+
errors.push(`story ${s.id} lacks executable criterion evidence`);
|
|
133
|
+
}
|
|
134
|
+
for (const criterion of s.acceptance.filter(isAcceptanceCriterion)) {
|
|
135
|
+
const problem = criterionCommandProblem(criterion);
|
|
136
|
+
if (problem)
|
|
137
|
+
errors.push(problem);
|
|
138
|
+
}
|
|
139
|
+
if (s.acceptance.some(criterion => /\b(?:TBD|TODO|TO BE DECIDED|DECIDE LATER)\b|\?\?\?/i.test(acceptanceText(criterion)))) {
|
|
127
140
|
errors.push(`story ${s.id} has unresolved planning decisions in acceptance criteria`);
|
|
128
141
|
}
|
|
129
142
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { hasShellControlOperator, parseQualityCommand } from './process-command.js';
|
|
4
|
+
export function collectCandidateArtifacts(input, adapters) {
|
|
5
|
+
const captured = captureCandidateArtifacts(input, adapters);
|
|
6
|
+
if (captured.kind === 'rejected')
|
|
7
|
+
return captured;
|
|
8
|
+
return { kind: 'collected', artifacts: captured.artifacts.map(value => value.artifact), digests: captured.digests };
|
|
9
|
+
}
|
|
10
|
+
export function captureCandidateArtifacts(input, adapters) {
|
|
11
|
+
if ('command' in input.candidate) {
|
|
12
|
+
if (hasShellControlOperator(input.candidate.command))
|
|
13
|
+
return { kind: 'rejected', reason: 'shell-control' };
|
|
14
|
+
if (!parseQualityCommand(input.candidate.command))
|
|
15
|
+
return { kind: 'rejected', reason: 'invalid-command' };
|
|
16
|
+
const bytes = input.candidate.kind === 'command-output'
|
|
17
|
+
? adapters.commandOutput(input.candidate.command)
|
|
18
|
+
: adapters.benchmark(input.candidate.command);
|
|
19
|
+
const artifact = { kind: input.candidate.kind, source: input.candidate.command, bytes: bytes.byteLength, digest: digest(bytes) };
|
|
20
|
+
return { kind: 'captured', artifacts: [{ artifact, bytes }], digests: [artifact.digest] };
|
|
21
|
+
}
|
|
22
|
+
const projectRoot = resolve(input.projectDir);
|
|
23
|
+
const proofRoot = join(projectRoot, '.yoke', 'proof');
|
|
24
|
+
const kind = input.candidate.kind === 'screenshots' ? 'screenshot' : 'file';
|
|
25
|
+
const artifacts = [];
|
|
26
|
+
for (const source of input.candidate.paths) {
|
|
27
|
+
if (containsTraversal(source))
|
|
28
|
+
return { kind: 'rejected', reason: 'path-traversal' };
|
|
29
|
+
if (isAbsolute(source))
|
|
30
|
+
return { kind: 'rejected', reason: 'outside-project' };
|
|
31
|
+
const path = resolve(projectRoot, source);
|
|
32
|
+
if (!isInside(projectRoot, path))
|
|
33
|
+
return { kind: 'rejected', reason: 'outside-project' };
|
|
34
|
+
if (kind === 'screenshot' && !isInside(proofRoot, path))
|
|
35
|
+
return { kind: 'rejected', reason: 'outside-proof' };
|
|
36
|
+
const canonicalRoot = adapters.realpath?.(projectRoot) ?? projectRoot;
|
|
37
|
+
const canonicalPath = adapters.realpath?.(path) ?? path;
|
|
38
|
+
if (!isInside(canonicalRoot, canonicalPath))
|
|
39
|
+
return { kind: 'rejected', reason: 'outside-project' };
|
|
40
|
+
if (kind === 'screenshot' && !isInside(adapters.realpath?.(proofRoot) ?? proofRoot, canonicalPath))
|
|
41
|
+
return { kind: 'rejected', reason: 'outside-proof' };
|
|
42
|
+
const bytes = adapters.readFile(canonicalPath);
|
|
43
|
+
if (bytes === null)
|
|
44
|
+
return { kind: 'rejected', reason: 'missing' };
|
|
45
|
+
artifacts.push({ artifact: { kind, path: canonicalPath, bytes: bytes.byteLength, digest: digest(bytes) }, bytes });
|
|
46
|
+
}
|
|
47
|
+
artifacts.sort((left, right) => left.artifact.digest.localeCompare(right.artifact.digest) || (left.artifact.path ?? '').localeCompare(right.artifact.path ?? ''));
|
|
48
|
+
return { kind: 'captured', artifacts, digests: artifacts.map(artifact => artifact.artifact.digest) };
|
|
49
|
+
}
|
|
50
|
+
function digest(bytes) {
|
|
51
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
52
|
+
}
|
|
53
|
+
function containsTraversal(path) {
|
|
54
|
+
return path.split(/[\\/]+/).includes('..');
|
|
55
|
+
}
|
|
56
|
+
function isInside(root, path) {
|
|
57
|
+
const relativePath = relative(root, path);
|
|
58
|
+
return relativePath === '' || (!relativePath.startsWith(`..${sep}`) && relativePath !== '..' && !isAbsolute(relativePath));
|
|
59
|
+
}
|