@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.
Files changed (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +66 -24
  4. package/README.md +186 -104
  5. package/TODOS.md +0 -3
  6. package/canon/loop/loop-spec.md +53 -24
  7. package/canon/loop/prd.schema.md +30 -6
  8. package/canon/manifest.yaml +1 -1
  9. package/canon/skills/authoring-prd/SKILL.md +30 -31
  10. package/dist/agents/contracts.js +50 -0
  11. package/dist/agents/process-incarnation.js +15 -0
  12. package/dist/agents/process-record.js +65 -0
  13. package/dist/agents/process-streams.js +40 -0
  14. package/dist/agents/process.js +177 -0
  15. package/dist/agents/providers.js +10 -7
  16. package/dist/agents/telemetry.js +62 -0
  17. package/dist/change/inbox.js +279 -0
  18. package/dist/cli.js +90 -4
  19. package/dist/loop/candidate-boundaries.js +43 -0
  20. package/dist/loop/candidate-cleanup.js +98 -0
  21. package/dist/loop/candidate-contracts.js +1 -0
  22. package/dist/loop/candidate-selection.js +84 -0
  23. package/dist/loop/candidates.js +228 -0
  24. package/dist/loop/claim-lease.js +131 -0
  25. package/dist/loop/claims.js +177 -40
  26. package/dist/loop/cleanup.js +117 -15
  27. package/dist/loop/decision.js +31 -0
  28. package/dist/loop/dispatcher.js +334 -0
  29. package/dist/loop/evidence.js +31 -0
  30. package/dist/loop/gates.js +10 -1
  31. package/dist/loop/loop.js +236 -33
  32. package/dist/loop/merge-queue.js +12 -6
  33. package/dist/loop/parallel-adapters.js +185 -0
  34. package/dist/loop/parallel-command.js +287 -0
  35. package/dist/loop/parallel.js +2 -4
  36. package/dist/loop/prd.js +63 -2
  37. package/dist/loop/reporter.js +86 -5
  38. package/dist/loop/run-command.js +227 -53
  39. package/dist/loop/runner.js +77 -34
  40. package/dist/loop/verify.js +11 -0
  41. package/dist/loop/watchdog.js +67 -8
  42. package/dist/loop/worker-cancellation.js +17 -0
  43. package/dist/loop/worker-cleanup.js +23 -0
  44. package/dist/loop/worker-contracts.js +1 -0
  45. package/dist/loop/worker.js +254 -0
  46. package/dist/prd/command.js +18 -5
  47. package/dist/quality/artifacts.js +59 -0
  48. package/dist/quality/candidate-comparison.js +130 -0
  49. package/dist/quality/command.js +316 -0
  50. package/dist/quality/loop.js +86 -0
  51. package/dist/quality/process-command.js +57 -0
  52. package/dist/quality/reference.js +187 -0
  53. package/dist/quality/repair.js +11 -0
  54. package/dist/quality/runner.js +66 -0
  55. package/dist/quality/types.js +60 -0
  56. package/dist/quality/verdict.js +142 -0
  57. package/dist/retrofit/config.js +13 -4
  58. package/dist/retrofit/gitignore.js +4 -0
  59. package/dist/review/command.js +27 -38
  60. package/dist/review/verdict.js +38 -7
  61. package/dist/routing/router.js +4 -1
  62. package/docs/MIGRATING-TO-1.4.md +70 -0
  63. package/docs/PUBLISHING.md +16 -2
  64. package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -0
  65. package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
  66. package/gemini-extension.json +1 -1
  67. package/package.json +6 -6
@@ -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 : (process.platform === 'win32' ? taskkillTree : undefined);
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) => { clear(); removePidFile(); resolve(killedForIdle ? 124 : (code ?? 0)); });
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
+ }
@@ -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
- # - "the verify command exits 0"
18
- # - "a placeholder test exists and passes"
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 => /\b(?:TBD|TODO|TO BE DECIDED|DECIDE LATER)\b|\?\?\?/i.test(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
+ }