@hecer/yoke 1.2.0 → 1.3.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/dist/loop/loop.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { existsSync, unlinkSync, readFileSync } from 'node:fs';
2
2
  import { join, relative } from 'node:path';
3
- import { loadPrd, savePrd, selectNextStory, allPass, progress } from './prd.js';
3
+ import { isAcceptanceCriterion, loadPrd, savePrd, selectNextStory, allPass, progress, storyPathSegment } from './prd.js';
4
4
  import { stopTheLineGate, preDispatchGate } from './gates.js';
5
5
  import { appendDecision, contextDir } from '../context/context.js';
6
6
  import { noopReporter } from './reporter.js';
7
7
  import { consumeDecisionRequest } from './decision.js';
8
+ import { writeCriterionEvidence } from './evidence.js';
8
9
  function blockReason(base, targetDir, git) {
9
10
  let dirty = false;
10
11
  try {
@@ -58,26 +59,72 @@ function consumeAmbiguity(dir) {
58
59
  const compact = content.replace(/\s+/g, ' ').trim().slice(0, 500);
59
60
  return compact || 'agent reported ambiguous acceptance criteria without details';
60
61
  }
62
+ function runCompletionGate(opts, stories) {
63
+ if (!opts.completion)
64
+ return null;
65
+ const previous = process.env.YOKE_PHASE;
66
+ process.env.YOKE_PHASE = 'completion';
67
+ let verdict;
68
+ try {
69
+ verdict = opts.completion(opts.targetDir);
70
+ }
71
+ catch (error) {
72
+ const reason = `integrated completion gate failed: ${error.message}`;
73
+ (opts.reporter ?? noopReporter).blocked(reason);
74
+ return { status: 'blocked', iterations: 0, reason, finalProgress: progress(stories) };
75
+ }
76
+ finally {
77
+ if (previous === undefined)
78
+ delete process.env.YOKE_PHASE;
79
+ else
80
+ process.env.YOKE_PHASE = previous;
81
+ }
82
+ if (verdict.passed)
83
+ return null;
84
+ const reason = `integrated system did not verify: ${verdict.summary}`;
85
+ (opts.reporter ?? noopReporter).blocked(reason);
86
+ return { status: 'blocked', iterations: 0, reason, finalProgress: progress(stories) };
87
+ }
88
+ function runCriterionGates(opts, executionDir, story) {
89
+ const criteria = story.acceptance.filter(isAcceptanceCriterion);
90
+ if (criteria.length === 0) {
91
+ return opts.requireCriterionEvidence
92
+ ? { passed: false, summary: `story ${story.id} lacks executable criterion evidence` }
93
+ : { passed: true, summary: 'legacy acceptance criteria' };
94
+ }
95
+ if (!opts.verifyCriterion)
96
+ return { passed: false, summary: `story ${story.id} has criteria but no criterion verifier` };
97
+ const evidence = criteria.map(criterion => ({
98
+ criterion,
99
+ result: opts.verifyCriterion(executionDir, story, criterion),
100
+ }));
101
+ try {
102
+ writeCriterionEvidence(opts.targetDir, story, evidence);
103
+ }
104
+ catch (error) {
105
+ return { passed: false, summary: `could not persist criterion evidence: ${error.message}` };
106
+ }
107
+ const failed = evidence.find(item => !item.result.passed);
108
+ return failed
109
+ ? { passed: false, summary: `${failed.criterion.id}: ${failed.result.summary}` }
110
+ : { passed: true, summary: `${evidence.length} acceptance criteria verified` };
111
+ }
61
112
  export function runLoop(opts) {
62
113
  let iterations = 0;
63
114
  const reporter = opts.reporter ?? noopReporter;
64
- const initial = loadPrd(opts.prdPath);
65
- if (initial.length === 0) {
66
- reporter.blocked('PRD has no stories');
67
- return { status: 'blocked', iterations: 0, reason: 'PRD has no stories', finalProgress: { passed: 0, total: 0 } };
68
- }
69
115
  for (;;) {
70
- const stories = loadPrd(opts.prdPath);
71
- if (allPass(stories)) {
72
- reporter.complete(progress(stories));
73
- return { status: 'complete', iterations, finalProgress: progress(stories) };
116
+ let stories = loadPrd(opts.prdPath);
117
+ if (stories.length === 0) {
118
+ reporter.blocked('PRD has no stories');
119
+ return { status: 'blocked', iterations, reason: 'PRD has no stories', finalProgress: { passed: 0, total: 0 } };
74
120
  }
75
- if (iterations >= opts.maxIterations) {
121
+ const capReached = iterations >= opts.maxIterations;
122
+ if (capReached && !allPass(stories)) {
76
123
  reporter.capReached(progress(stories));
77
124
  return { status: 'cap-reached', iterations, finalProgress: progress(stories) };
78
125
  }
79
- // Story boundary: honour a pause signal before selecting the next story.
80
- // complete/cap-reached above still win — pausing an already-finished loop is meaningless.
126
+ // Intake is work at a story boundary. Cap, pause, and clean-tree safety must
127
+ // win before a planner is allowed to edit and commit the PRD.
81
128
  const pauseFile = pauseFilePath(opts.targetDir);
82
129
  if (existsSync(pauseFile)) {
83
130
  try {
@@ -89,22 +136,71 @@ export function runLoop(opts) {
89
136
  }
90
137
  const pre = preDispatchGate(opts.targetDir, opts.git);
91
138
  if (!pre.ok) {
92
- reporter.blocked(pre.reason ?? 'pre-dispatch gate failed');
93
- return { status: 'blocked', iterations, reason: pre.reason, finalProgress: progress(stories) };
139
+ const reason = opts.intake ? `change intake blocked by dirty worktree: ${pre.reason ?? 'pre-dispatch gate failed'}` : pre.reason;
140
+ reporter.blocked(reason ?? 'pre-dispatch gate failed');
141
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
142
+ }
143
+ // A bounded run that finished its final story may report completion, but it
144
+ // must not plan additional queued work after the requested cap.
145
+ if (capReached) {
146
+ const completionFailure = runCompletionGate(opts, stories);
147
+ if (completionFailure)
148
+ return { ...completionFailure, iterations };
149
+ reporter.complete(progress(stories));
150
+ return { status: 'complete', iterations, finalProgress: progress(stories) };
151
+ }
152
+ if (opts.intake) {
153
+ let intake;
154
+ try {
155
+ intake = opts.intake();
156
+ }
157
+ catch (error) {
158
+ const stories = loadPrd(opts.prdPath);
159
+ const reason = `change intake failed: ${error.message}`;
160
+ reporter.blocked(reason);
161
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
162
+ }
163
+ if (!intake.ok) {
164
+ const stories = loadPrd(opts.prdPath);
165
+ const reason = `change intake failed: ${intake.summary}`;
166
+ reporter.blocked(reason);
167
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
168
+ }
169
+ if (intake.added > 0) {
170
+ const afterIntake = preDispatchGate(opts.targetDir, opts.git);
171
+ if (!afterIntake.ok) {
172
+ const stories = loadPrd(opts.prdPath);
173
+ const reason = `change intake left a dirty worktree: ${afterIntake.reason ?? 'pre-dispatch gate failed'}`;
174
+ reporter.blocked(reason);
175
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
176
+ }
177
+ }
178
+ }
179
+ stories = loadPrd(opts.prdPath);
180
+ if (stories.length === 0) {
181
+ reporter.blocked('PRD has no stories');
182
+ return { status: 'blocked', iterations, reason: 'PRD has no stories', finalProgress: { passed: 0, total: 0 } };
183
+ }
184
+ if (allPass(stories)) {
185
+ const completionFailure = runCompletionGate(opts, stories);
186
+ if (completionFailure)
187
+ return { ...completionFailure, iterations };
188
+ reporter.complete(progress(stories));
189
+ return { status: 'complete', iterations, finalProgress: progress(stories) };
94
190
  }
95
191
  const story = selectNextStory(stories);
96
192
  if (!story) {
97
193
  reporter.complete(progress(stories));
98
194
  return { status: 'complete', iterations, finalProgress: progress(stories) };
99
195
  }
100
- const stl = stopTheLineGate(story);
196
+ const stl = stopTheLineGate(story, opts.requireCriterionEvidence);
101
197
  if (!stl.ok) {
102
198
  reporter.blocked(stl.reason ?? 'stop-the-line gate failed');
103
199
  return { status: 'blocked', iterations, reason: stl.reason, finalProgress: progress(stories) };
104
200
  }
105
201
  reporter.storyStart({ id: story.id, title: story.title }, iterations + 1, progress(stories));
106
202
  if (opts.isolate) {
107
- const wt = join(opts.targetDir, '.yoke', 'worktrees', story.id);
203
+ const wt = join(opts.targetDir, '.yoke', 'worktrees', storyPathSegment(story.id));
108
204
  const wtPrd = join(wt, relative(opts.targetDir, opts.prdPath));
109
205
  let landed = null;
110
206
  try {
@@ -133,6 +229,13 @@ export function runLoop(opts) {
133
229
  reporter.blocked(reason);
134
230
  return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
135
231
  }
232
+ const criteriaVerdict = runCriterionGates(opts, wt, story);
233
+ if (!criteriaVerdict.passed) {
234
+ result.routing?.recordOutcome(false);
235
+ const reason = blockReason(`story ${story.id} lacks acceptance evidence: ${criteriaVerdict.summary}`, opts.targetDir, opts.git);
236
+ reporter.blocked(reason);
237
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
238
+ }
136
239
  // Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
137
240
  // exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
138
241
  reporter.phase('verifying');
@@ -234,6 +337,13 @@ export function runLoop(opts) {
234
337
  reporter.blocked(reason);
235
338
  return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
236
339
  }
340
+ const criteriaVerdict = runCriterionGates(opts, opts.targetDir, story);
341
+ if (!criteriaVerdict.passed) {
342
+ result.routing?.recordOutcome(false);
343
+ const reason = blockReason(`story ${story.id} lacks acceptance evidence: ${criteriaVerdict.summary}`, opts.targetDir, opts.git);
344
+ reporter.blocked(reason);
345
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
346
+ }
237
347
  // Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
238
348
  // exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
239
349
  reporter.phase('verifying');
package/dist/loop/prd.js CHANGED
@@ -1,19 +1,77 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { parse, stringify } from 'yaml';
3
3
  import { z } from 'zod';
4
+ export const AcceptanceCriterionSchema = z.object({
5
+ id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
6
+ text: z.string().min(1),
7
+ verify: z.array(z.string().min(1)).min(1),
8
+ });
9
+ export function criterionCommandProblem(criterion) {
10
+ const normalizedId = criterion.id.toLowerCase().replace(/[^a-z0-9]/g, '');
11
+ for (const raw of criterion.verify) {
12
+ const command = raw.trim();
13
+ if (/(?:[&;|<>`\r\n]|\$\()/.test(command)) {
14
+ return `criterion ${criterion.id} must use one single test command without shell control operators`;
15
+ }
16
+ const isTestCommand = /^(?:(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+(?:test|check|verify)(?::[A-Za-z0-9._-]+)?|npx\s+(?:vitest|jest|playwright|cypress)|(?:vitest|jest|pytest|phpunit)|python\s+-m\s+pytest|cargo\s+test|go\s+test|dotnet\s+test|mvn(?:w)?\s+test|gradle(?:w)?\s+test|bundle\s+exec\s+(?:rspec|rails\s+test)|bin\/rails\s+test|vendor\/bin\/phpunit|mix\s+test|swift\s+test|xcodebuild\s+test)(?:\s|$)/i.test(command);
17
+ if (!isTestCommand)
18
+ return `criterion ${criterion.id} must use an approved test command`;
19
+ const normalizedCommand = command.toLowerCase().replace(/[^a-z0-9]/g, '');
20
+ if (!normalizedCommand.includes(normalizedId)) {
21
+ return `criterion ${criterion.id} must target its criterion id in every verify command`;
22
+ }
23
+ }
24
+ return null;
25
+ }
4
26
  export const StorySchema = z.object({
27
+ // Existing projects may already use human-readable IDs. Keep loading them;
28
+ // every filesystem use goes through storyPathSegment instead.
5
29
  id: z.string().min(1),
6
30
  title: z.string().min(1),
7
31
  priority: z.number(),
8
- acceptance: z.array(z.string().min(1)),
32
+ // String criteria remain readable for existing projects. New strict projects
33
+ // use structured criteria so Yoke can execute proof for each outcome instead
34
+ // of trusting an unrelated green test suite.
35
+ acceptance: z.array(z.union([z.string().min(1), AcceptanceCriterionSchema])),
9
36
  passes: z.boolean(),
10
37
  needs: z.array(z.string().min(1)).optional(),
11
38
  area: z.string().min(1).optional(),
12
39
  agent: z.enum(['claude', 'codex', 'gemini']).optional(),
40
+ /** Inbox request that created this story. Used for idempotent append-only intake. */
41
+ sourceChange: z.string().min(1).optional(),
42
+ }).superRefine((story, ctx) => {
43
+ const structured = story.acceptance.filter(isAcceptanceCriterion);
44
+ const ids = structured.map(criterion => criterion.id);
45
+ if (structured.length > 0 && (structured.length < 2 || structured.length > 5)) {
46
+ ctx.addIssue({
47
+ code: 'custom',
48
+ path: ['acceptance'],
49
+ message: 'structured stories must have 2-5 structured acceptance criteria',
50
+ });
51
+ }
52
+ if (new Set(ids).size !== ids.length) {
53
+ ctx.addIssue({
54
+ code: 'custom',
55
+ path: ['acceptance'],
56
+ message: 'structured criterion ids must be unique within a story',
57
+ });
58
+ }
13
59
  });
60
+ export function storyPathSegment(id) {
61
+ return `story-${Buffer.from(id, 'utf8').toString('base64url')}`;
62
+ }
63
+ export function isAcceptanceCriterion(value) {
64
+ return typeof value !== 'string';
65
+ }
66
+ export function acceptanceText(value) {
67
+ return typeof value === 'string' ? value : value.text;
68
+ }
14
69
  const PrdSchema = z.array(StorySchema);
70
+ export function parsePrd(file) {
71
+ return PrdSchema.parse(parse(readFileSync(file, 'utf8')));
72
+ }
15
73
  export function loadPrd(file) {
16
- const stories = PrdSchema.parse(parse(readFileSync(file, 'utf8')));
74
+ const stories = parsePrd(file);
17
75
  const issues = validateDependencies(stories);
18
76
  if (issues.length)
19
77
  throw new Error(`Invalid PRD dependency graph:\n${issues.join('\n')}`);
@@ -3,9 +3,9 @@ import { existsSync } from 'node:fs';
3
3
  import { loadConfig, saveConfig, defaultConfig, resolveVerifyCommand } from '../retrofit/config.js';
4
4
  import { loadPrd, progress } from './prd.js';
5
5
  import { runLoop } from './loop.js';
6
- import { realGitOps } from './git.js';
6
+ import { commitPaths, realGitOps } from './git.js';
7
7
  import { makeRunner, makeReviewRunner, isAgentAvailable } from './runner.js';
8
- import { commandVerifier, retryingVerifier } from './verify.js';
8
+ import { commandVerifier, commandsVerifier, retryingVerifier } from './verify.js';
9
9
  import { readStatus, makeReporter, fmtDuration } from './reporter.js';
10
10
  import { acquireLock, releaseLock } from './lock.js';
11
11
  import { maybeAutoUpgrade } from '../update/upgrade.js';
@@ -14,6 +14,7 @@ import { runAudit } from '../audit/command.js';
14
14
  import { detectHostAgent, resolveRunnerAgent } from '../agents/host.js';
15
15
  import { clearDecisionResume, decisionProcessingExists, decisionRequestId, formatPendingDecision, readPendingDecision, writeDecisionResume, } from './decision.js';
16
16
  import { makeAdaptiveRunner } from '../routing/router.js';
17
+ import { runChangeApply } from '../change/inbox.js';
17
18
  export const DEFAULT_IDLE_MINUTES = 20;
18
19
  const STALE_MINUTES = 20; // a running status older than this likely means the loop died
19
20
  export function relativeTime(fromIso, now) {
@@ -114,6 +115,9 @@ export function runLoopCommand(targetDir, opts) {
114
115
  if (!perf && config.perf?.command) {
115
116
  perf = retryingVerifier(commandVerifier(config.perf.command), config.perf.retries ?? 1);
116
117
  }
118
+ const completion = config.completion?.command
119
+ ? retryingVerifier(commandVerifier(config.completion.command), config.completion.retries ?? 1)
120
+ : undefined;
117
121
  // Opt-in self-update, loop START only — this run keeps executing the version
118
122
  // it started with; a fetched upgrade applies from the next invocation.
119
123
  maybeAutoUpgrade(config.update?.auto);
@@ -148,6 +152,19 @@ export function runLoopCommand(targetDir, opts) {
148
152
  console.error('Adaptive routing was requested, but no worker profiles are configured. Run yoke setup . --routing or add routing.workers to .yoke/config.yaml.');
149
153
  return 2;
150
154
  }
155
+ const intake = opts.intake ?? (() => runChangeApply(targetDir, {
156
+ runner: runnerAgent,
157
+ reviewer: opts.reviewer ?? runnerAgent,
158
+ timeoutMs: idleMs,
159
+ isAvailable: available,
160
+ permissions,
161
+ selection: {
162
+ model: config.runner?.model,
163
+ reasoningEffort: config.runner?.reasoningEffort,
164
+ bare: config.runner?.bare,
165
+ },
166
+ commit: (_path, request) => commitPaths(targetDir, ['.yoke/prd.yaml'], `yoke: plan change ${request.id}`, commitIdentity),
167
+ }));
151
168
  let runner = opts.runner;
152
169
  if (!runner) {
153
170
  if (!available(runnerAgent)) {
@@ -230,6 +247,10 @@ export function runLoopCommand(targetDir, opts) {
230
247
  git,
231
248
  commitIdentity,
232
249
  verify,
250
+ verifyCriterion: (dir, _story, criterion) => commandsVerifier(criterion.verify)(dir),
251
+ requireCriterionEvidence: config.verify?.requireCriteria ?? false,
252
+ completion,
253
+ intake,
233
254
  perf,
234
255
  audit,
235
256
  maxIterations,
@@ -1,3 +1,4 @@
1
+ import { isAcceptanceCriterion } from './prd.js';
1
2
  import { execFileSync, execSync } from 'node:child_process';
2
3
  import { existsSync, mkdirSync, rmSync } from 'node:fs';
3
4
  import { join } from 'node:path';
@@ -9,8 +10,15 @@ import { formatReviewContract, readReviewVerdict, reviewVerdictPath } from '../r
9
10
  export function contextBlockFor(targetDir) {
10
11
  return formatForPrompt(loadContext(contextDir(targetDir)));
11
12
  }
13
+ function formatAcceptance(story) {
14
+ return story.acceptance.map(criterion => {
15
+ if (!isAcceptanceCriterion(criterion))
16
+ return `- ${criterion}`;
17
+ return `- [${criterion.id}] ${criterion.text}\n Proof: ${criterion.verify.join(' && ')}`;
18
+ }).join('\n');
19
+ }
12
20
  export function buildClaudePrompt(story, context, onAmbiguity = 'resolve', perfCommand) {
13
- const criteria = story.acceptance.map(a => `- ${a}`).join('\n');
21
+ const criteria = formatAcceptance(story);
14
22
  const lines = [
15
23
  'You are an autonomous coding agent running inside the Yoke loop.',
16
24
  'Implement ONLY this story and nothing else. Follow test-driven development.',
@@ -33,7 +41,7 @@ export function buildClaudePrompt(story, context, onAmbiguity = 'resolve', perfC
33
41
  return lines.join('\n');
34
42
  }
35
43
  export function buildReviewPrompt(story, context, verdictPath) {
36
- const criteria = story.acceptance.map(a => `- ${a}`).join('\n');
44
+ const criteria = formatAcceptance(story);
37
45
  const lines = [
38
46
  'You are an independent reviewer inside the Yoke loop. You did NOT implement this change.',
39
47
  'Review the current uncommitted working-tree changes against the story below.',
@@ -16,6 +16,17 @@ export function commandVerifier(command) {
16
16
  }
17
17
  };
18
18
  }
19
+ /** Execute the proof commands attached to one acceptance criterion. */
20
+ export function commandsVerifier(commands) {
21
+ return (targetDir) => {
22
+ for (const command of commands) {
23
+ const result = commandVerifier(command)(targetDir);
24
+ if (!result.passed)
25
+ return result;
26
+ }
27
+ return { passed: true, summary: `${commands.length} criterion command${commands.length === 1 ? '' : 's'} passed` };
28
+ };
29
+ }
19
30
  // Re-run a failing verifier up to `retries` times; the first pass wins. Lets a
20
31
  // transient flake (e.g. a load-induced async timeout) self-heal while a real
21
32
  // failure still fails (it stays red across every attempt).
@@ -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
  }
@@ -53,7 +53,14 @@ export const YokeConfigSchema = z.object({
53
53
  suppressionsVersion: z.literal(1).optional(),
54
54
  suppressions: z.array(z.object({ ruleId: z.string().min(1), file: z.string().min(1).optional(), reason: z.string(), expires: z.string().optional() })).optional(),
55
55
  }).optional(),
56
- verify: z.object({ command: z.string().min(1), retries: z.number().int().nonnegative().optional() }).optional(),
56
+ verify: z.object({
57
+ command: z.string().min(1).optional(),
58
+ retries: z.number().int().nonnegative().optional(),
59
+ requireCriteria: z.boolean().optional(),
60
+ }).optional(),
61
+ // Runs only when no open stories remain. This proves the current integrated
62
+ // system, without introducing release objects or a persistent stale graph.
63
+ completion: z.object({ command: z.string().min(1), retries: z.number().int().nonnegative().optional() }).optional(),
57
64
  // Optional performance budget gate: a benchmark command that must exit 0 for a
58
65
  // story to land (runs after verify). Benchmarks are noisy → retried like verify.
59
66
  perf: z.object({ command: z.string().min(1), retries: z.number().int().nonnegative().optional() }).optional(),
@@ -63,7 +70,7 @@ export const YokeConfigSchema = z.object({
63
70
  update: z.object({ auto: z.boolean() }).optional(),
64
71
  });
65
72
  export function defaultConfig(canonVersion) {
66
- return { canonVersion, agents: [], loop: { enabled: false } };
73
+ return { canonVersion, agents: [], loop: { enabled: false }, verify: { requireCriteria: true } };
67
74
  }
68
75
  export function configPath(targetDir) {
69
76
  return join(targetDir, '.yoke', 'config.yaml');
@@ -19,6 +19,7 @@ export const YOKE_IGNORE_LINES = [
19
19
  '.yoke/decision-*.yaml.*.tmp',
20
20
  '.yoke/story-durations.json',
21
21
  '.yoke/proof/',
22
+ '.yoke/changes/',
22
23
  ];
23
24
  const HEADER = '# Yoke runtime artifacts (managed by yoke retrofit)';
24
25
  // Idempotently ensure each Yoke runtime path is gitignored. Appends only the
@@ -1,4 +1,5 @@
1
1
  import { buildWatchdogInvocation, makeRunner, runCapturedAgent, runnerInvocation, } from '../loop/runner.js';
2
+ import { isAcceptanceCriterion } from '../loop/prd.js';
2
3
  import { historyForWorkers, projectHash, recordRoutingObservation, storyHash } from './registry.js';
3
4
  const costRank = { low: 0, medium: 1, high: 2 };
4
5
  export function rankWorkers(workers, strategy, maxCandidates) {
@@ -40,7 +41,9 @@ export function buildRoutingPrompt(ctx, workers, strategy) {
40
41
  '',
41
42
  `Story ${ctx.story.id}: ${ctx.story.title}`,
42
43
  'Acceptance criteria:',
43
- ...ctx.story.acceptance.map(item => `- ${item}`),
44
+ ...ctx.story.acceptance.map(item => isAcceptanceCriterion(item)
45
+ ? `- [${item.id}] ${item.text} (proof: ${item.verify.join(' && ')})`
46
+ : `- ${item}`),
44
47
  '',
45
48
  'Allowed candidates:',
46
49
  '- SELF: strong parent; highest confidence; highest expected cost',
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yoke",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Cross-agent coding harness: curated skill canon, mechanical safety gates, autonomous loop with proof artifacts. CLI: npm i -g @hecer/yoke",
5
5
  "contextFileName": "GEMINI-EXTENSION.md"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hecer/yoke",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "One harness, three agents, zero trust in \"done\" — cross-agent coding harness for Claude Code, Codex CLI, and Gemini CLI: one skill canon, mechanical safety gates, an autonomous loop with screenshot/video proofs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,11 +16,11 @@
16
16
  "hooks",
17
17
  "bench/README.md",
18
18
  "bench/RESULTS.md",
19
- "bench/result-schema.mjs",
20
- "bench/run.mjs",
21
- "bench/run-large.mjs",
22
- "bench/run-matrix.mjs",
23
- "bench/analyze-routing-study.mjs",
19
+ "bench/result-schema.mjs",
20
+ "bench/run.mjs",
21
+ "bench/run-large.mjs",
22
+ "bench/run-matrix.mjs",
23
+ "bench/analyze-routing-study.mjs",
24
24
  "bench/fixtures",
25
25
  "bench/results",
26
26
  "docs",