@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.
@@ -1,38 +1,27 @@
1
1
  ---
2
2
  name: authoring-prd
3
- description: Use when turning a product idea into a loop-ready .yoke/prd.yaml — slice the idea into small, independently shippable stories with testable behavioral acceptance criteria; greenfield STORY-1 scaffolds the project and wires verify.command.
3
+ description: Use when turning a product idea or change into a loop-ready continuous backlog with small stories and executable behavioral evidence.
4
4
  ---
5
5
 
6
6
  # Authoring a PRD
7
7
 
8
- The Yoke loop is only as good as its stories. Bad stories ("build the app") stall it;
9
- good stories (small, testable, ordered) let it run overnight.
8
+ The Yoke loop is only as good as its stories. Keep the backlog continuous: new requests become
9
+ new stories; they do not require a release object.
10
10
 
11
11
  ## Story rules
12
12
 
13
- 1. **One iteration per story.** If you can't imagine an agent finishing it in one sitting,
14
- split it. Prefer 5-12 stories over 3 epics.
15
- 2. **Independently shippable.** After any story, the project builds and tests pass.
16
- 3. **Acceptance = observable behavior**, never implementation:
17
- - Good: "GET /health returns 200", "the CLI prints the sum of its arguments"
18
- - Bad: "create a HealthController class", "use express"
19
- 2-5 criteria per story. Each must be checkable by a test or a command.
20
- 4. **Dense priorities from 1**; lower runs first. Order by dependency, then by risk.
21
- 5. **Greenfield: STORY-1 scaffolds.** Project skeleton + runnable test suite + a criterion
22
- that the verify command (`verify.command` in `.yoke/config.yaml`) exits 0. Every later
23
- story stands on a green pipeline.
24
- 6. **Performance requirements are acceptance criteria — with numbers.** "Should be fast" is
25
- a vibe the loop cannot gate; "imports 1M rows in < 2s (asserted by the bench test)" is a
26
- criterion. If the whole project has a budget, wire `perf.command` in `.yoke/config.yaml`
27
- (see the `performance` skill) instead of repeating it per story.
28
- 7. **Ask everything now.** Clarifying questions belong in this planning round — a loop run
29
- is unattended. A criterion that still contains `TBD` or another placeholder is not
30
- loop-ready, and `yoke prd check` rejects it. During implementation,
31
- `loop.decisionPolicy: auto` resolves routine ambiguity; `critical` pauses only for
32
- high-impact choices and resumes after `yoke loop answer` records the answer.
33
- 8. **Model real dependencies.** Add `needs` only for hard prerequisites, `area` for files or
34
- subsystems that must not be edited concurrently, and `agent` only as an affinity hint.
35
- Dependency IDs must exist; self-dependencies and cycles are invalid.
13
+ 1. One iteration per story. Prefer 5–12 small stories over a few epics.
14
+ 2. Each story leaves the project buildable and testable.
15
+ 3. Acceptance describes observable behavior, never implementation. Give each of 2–5 criteria
16
+ a stable `id`, behavioral `text`, and `verify` list with one or more real commands proving
17
+ that exact outcome.
18
+ 4. Use dense priorities from 1; order by dependency, then risk.
19
+ 5. Greenfield `STORY-1` creates the skeleton, runnable suite, and `verify.command`.
20
+ 6. Express performance with numbers and executable benchmarks, not words such as “fast”.
21
+ 7. Resolve planning questions before unattended execution. `yoke prd check` rejects unresolved
22
+ placeholders; critical irreversible choices use the structured decision channel.
23
+ 8. Use `needs` only for hard prerequisites, `area` for collision domains, and `agent` only as
24
+ a Claude/Codex/Gemini affinity hint.
36
25
 
37
26
  ## Format (`.yoke/prd.yaml`)
38
27
 
@@ -41,8 +30,12 @@ good stories (small, testable, ordered) let it run overnight.
41
30
  title: scaffold a TypeScript CLI with vitest
42
31
  priority: 1
43
32
  acceptance:
44
- - "npm test exits 0 with at least one passing test"
45
- - "verify.command is set in .yoke/config.yaml"
33
+ - id: cli-help-runs
34
+ text: the CLI help command exits 0 and prints usage
35
+ verify: [npm run test:cli-help-runs]
36
+ - id: test-runner-starts
37
+ text: the project test runner starts and reports at least one passing test
38
+ verify: [npm run test:test-runner-starts]
46
39
  passes: false
47
40
  - id: STORY-2
48
41
  title: add the sum command
@@ -51,9 +44,15 @@ good stories (small, testable, ordered) let it run overnight.
51
44
  area: cli
52
45
  agent: codex
53
46
  acceptance:
54
- - "cli sum 1 2 prints 3"
55
- - "non-numeric input exits 1 with an error message"
47
+ - id: sum-valid
48
+ text: cli sum 1 2 prints 3
49
+ verify: [npm run test:sum-valid]
50
+ - id: sum-invalid
51
+ text: non-numeric input exits 1 with an error message
52
+ verify: [npm run test:sum-invalid]
56
53
  passes: false
57
54
  ```
58
55
 
59
- `passes` is owned by the loop — always start `false`. Validate with `yoke prd check`.
56
+ Every story has 2–5 structured criteria. Each `verify` entry is one approved test command whose
57
+ normalized text contains its criterion ID; never use shell operators or a broad unrelated suite.
58
+ `passes` is owned by the loop and always starts false. Validate with `yoke prd check`.
@@ -0,0 +1,279 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
5
+ import { z } from 'zod';
6
+ import { criterionCommandProblem, isAcceptanceCriterion, loadPrd, parsePrd, savePrd, validateDependencies } from '../loop/prd.js';
7
+ import { agentInvocation, buildWatchdogInvocation, isAgentAvailable, runAgent, } from '../loop/runner.js';
8
+ import { commitPaths } from '../loop/git.js';
9
+ const ChangeRequestSchema = z.object({
10
+ version: z.literal(1),
11
+ id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
12
+ createdAt: z.string().datetime(),
13
+ request: z.string().min(1),
14
+ });
15
+ const ChangeReviewSchema = z.object({
16
+ version: z.literal(1),
17
+ changeId: z.string().min(1),
18
+ approved: z.boolean(),
19
+ summary: z.string().min(1),
20
+ uncovered: z.array(z.string().min(1)),
21
+ }).superRefine((review, ctx) => {
22
+ if (review.approved && review.uncovered.length > 0) {
23
+ ctx.addIssue({ code: 'custom', path: ['approved'], message: 'approved review cannot list uncovered requirements' });
24
+ }
25
+ });
26
+ const NewStoryId = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
27
+ function changesDir(targetDir) { return join(targetDir, '.yoke', 'changes'); }
28
+ function pendingDir(targetDir) { return join(changesDir(targetDir), 'pending'); }
29
+ function appliedDir(targetDir) { return join(changesDir(targetDir), 'applied'); }
30
+ function requestFile(targetDir, id) { return join(pendingDir(targetDir), `${id}.json`); }
31
+ export function proposalFile(targetDir, id) { return resolve(changesDir(targetDir), `proposal-${id}.yaml`); }
32
+ export function reviewFile(targetDir, id) { return resolve(changesDir(targetDir), `review-${id}.json`); }
33
+ function ensureLocalIgnore(targetDir) {
34
+ let exclude;
35
+ try {
36
+ const gitPath = execFileSync('git', ['rev-parse', '--git-path', 'info/exclude'], { cwd: targetDir, stdio: 'pipe' }).toString().trim();
37
+ exclude = isAbsolute(gitPath) ? gitPath : resolve(targetDir, gitPath);
38
+ }
39
+ catch {
40
+ return;
41
+ }
42
+ mkdirSync(dirname(exclude), { recursive: true });
43
+ const current = existsSync(exclude) ? readFileSync(exclude, 'utf8') : '';
44
+ if (current.split(/\r?\n/).some(line => line.trim() === '.yoke/changes/'))
45
+ return;
46
+ const prefix = current.length === 0 || current.endsWith('\n') ? '' : '\n';
47
+ writeFileSync(exclude, `${current}${prefix}.yoke/changes/\n`);
48
+ }
49
+ function prdIsCommitted(targetDir) {
50
+ try {
51
+ execFileSync('git', ['ls-files', '--error-unmatch', '--', '.yoke/prd.yaml'], { cwd: targetDir, stdio: 'pipe' });
52
+ return execFileSync('git', ['status', '--porcelain', '--untracked-files=no', '--', '.yoke/prd.yaml'], {
53
+ cwd: targetDir,
54
+ stdio: 'pipe',
55
+ }).toString().trim() === '';
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ export function queueChange(targetDir, request, opts = {}) {
62
+ const text = request.trim();
63
+ if (!text)
64
+ throw new Error('change request must not be empty');
65
+ const change = ChangeRequestSchema.parse({
66
+ version: 1,
67
+ id: opts.id ?? randomUUID(),
68
+ createdAt: (opts.now ?? (() => new Date()))().toISOString(),
69
+ request: text,
70
+ });
71
+ ensureLocalIgnore(targetDir);
72
+ mkdirSync(pendingDir(targetDir), { recursive: true });
73
+ const path = requestFile(targetDir, change.id);
74
+ if (existsSync(path))
75
+ throw new Error(`change ${change.id} already exists`);
76
+ const temporary = `${path}.${process.pid}.tmp`;
77
+ writeFileSync(temporary, `${JSON.stringify(change, null, 2)}\n`, { flag: 'wx' });
78
+ renameSync(temporary, path);
79
+ return change;
80
+ }
81
+ export function pendingChanges(targetDir) {
82
+ const dir = pendingDir(targetDir);
83
+ if (!existsSync(dir))
84
+ return [];
85
+ return readdirSync(dir)
86
+ .filter(name => name.endsWith('.json'))
87
+ .map(name => ChangeRequestSchema.parse(JSON.parse(readFileSync(join(dir, name), 'utf8'))))
88
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
89
+ }
90
+ export function buildChangePrompt(request, proposalPath, stories) {
91
+ return [
92
+ 'You are planning one newly requested product change for the Yoke autonomous loop.',
93
+ `Change request ${request.id}: ${request.request}`,
94
+ '',
95
+ 'Create an append-only proposal: add small new stories; never rewrite or delete existing stories.',
96
+ `Existing story IDs: ${stories.map(story => story.id).join(', ') || '(none)'}`,
97
+ 'Every proposed story must have passes: false and 2-5 structured acceptance criteria.',
98
+ 'Every criterion must have a stable id, behavioral text, and one or more executable verify commands.',
99
+ 'Each criterion id must appear in every verify command; each entry must be one approved test command without shell control operators.',
100
+ 'Use only claude, codex, or gemini if an optional agent affinity is useful.',
101
+ '',
102
+ `Write ONLY a YAML array of the NEW stories to this exact file: ${proposalPath}`,
103
+ 'Do not edit .yoke/prd.yaml or any source file. Do not commit.',
104
+ ].join('\n');
105
+ }
106
+ export function buildChangeReviewPrompt(request, reviewPath, proposed) {
107
+ return [
108
+ 'Independently review whether a proposed append-only plan fully covers one product change.',
109
+ `Change request ${request.id}: ${request.request}`,
110
+ '',
111
+ 'Check every distinct requested outcome, edge case, and state transition against the proposed stories and their criteria.',
112
+ 'Approve only when every distinct requested outcome has a behavioral criterion and a criterion-specific verify command.',
113
+ 'Do not assume a broad test suite proves an outcome. List every omission in uncovered.',
114
+ '',
115
+ `Proposed stories: ${JSON.stringify(proposed, null, 2)}`,
116
+ '',
117
+ `Write ONLY JSON to this exact file: ${reviewPath}`,
118
+ '{"version":1,"changeId":"<exact change id>","approved":true|false,"summary":"<reason>","uncovered":["<missing outcome>"]}',
119
+ 'Do not edit .yoke/prd.yaml, the proposal, or any source file. Do not commit.',
120
+ ].join('\n');
121
+ }
122
+ function archiveRequest(targetDir, request) {
123
+ mkdirSync(appliedDir(targetDir), { recursive: true });
124
+ renameSync(requestFile(targetDir, request.id), join(appliedDir(targetDir), `${request.id}.json`));
125
+ rmSync(proposalFile(targetDir, request.id), { force: true });
126
+ rmSync(reviewFile(targetDir, request.id), { force: true });
127
+ }
128
+ export function runChangeApply(targetDir, opts) {
129
+ let requests;
130
+ try {
131
+ requests = pendingChanges(targetDir);
132
+ }
133
+ catch (error) {
134
+ return { ok: false, added: 0, summary: `invalid pending request: ${error.message}` };
135
+ }
136
+ const request = requests[0];
137
+ if (!request)
138
+ return { ok: true, added: 0, summary: 'inbox empty' };
139
+ const prdPath = join(targetDir, '.yoke', 'prd.yaml');
140
+ const existingText = readFileSync(prdPath, 'utf8');
141
+ const existing = loadPrd(prdPath);
142
+ if (existing.some(story => story.sourceChange === request.id)) {
143
+ if (!prdIsCommitted(targetDir)) {
144
+ return {
145
+ ok: false,
146
+ added: 0,
147
+ summary: `PRD contains change ${request.id} only as uncommitted recovery state; keeping the request pending`,
148
+ changeId: request.id,
149
+ };
150
+ }
151
+ try {
152
+ ;
153
+ (opts.archive ?? archiveRequest)(targetDir, request);
154
+ }
155
+ catch (error) {
156
+ return {
157
+ ok: false, added: 0,
158
+ summary: `committed change ${request.id} could not be archived; keeping it pending for recovery: ${error.message}`,
159
+ changeId: request.id,
160
+ };
161
+ }
162
+ return { ok: true, added: 0, summary: `recovered applied change ${request.id}`, changeId: request.id };
163
+ }
164
+ const available = opts.isAvailable ?? isAgentAvailable;
165
+ if (!available(opts.runner))
166
+ return { ok: false, added: 0, summary: `planner CLI "${opts.runner}" is unavailable`, changeId: request.id };
167
+ const reviewer = opts.reviewer ?? opts.runner;
168
+ if (!available(reviewer))
169
+ return { ok: false, added: 0, summary: `reviewer CLI "${reviewer}" is unavailable`, changeId: request.id };
170
+ const proposal = proposalFile(targetDir, request.id);
171
+ const plannerDir = changesDir(targetDir);
172
+ mkdirSync(plannerDir, { recursive: true });
173
+ rmSync(proposal, { force: true });
174
+ const base = agentInvocation(opts.runner, buildChangePrompt(request, proposal, existing), plannerDir, opts.permissions ?? 'safe', opts.selection);
175
+ const invocation = buildWatchdogInvocation(base, opts.timeoutMs ?? 0);
176
+ const result = (opts.run ?? runAgent)(invocation);
177
+ if (!result.success)
178
+ return { ok: false, added: 0, summary: `planner failed: ${result.summary}`, changeId: request.id };
179
+ if (readFileSync(prdPath, 'utf8') !== existingText) {
180
+ return {
181
+ ok: false,
182
+ added: 0,
183
+ summary: 'PRD changed while the planner was running; refusing to overwrite concurrent or out-of-contract edits',
184
+ changeId: request.id,
185
+ };
186
+ }
187
+ if (!existsSync(proposal))
188
+ return { ok: false, added: 0, summary: `planner did not write ${basename(proposal)}`, changeId: request.id };
189
+ let proposed;
190
+ try {
191
+ proposed = parsePrd(proposal);
192
+ }
193
+ catch (error) {
194
+ return { ok: false, added: 0, summary: `invalid story proposal: ${error.message}`, changeId: request.id };
195
+ }
196
+ if (proposed.length === 0)
197
+ return { ok: false, added: 0, summary: 'planner proposed no stories', changeId: request.id };
198
+ const existingIds = new Set(existing.map(story => story.id));
199
+ const proposedIds = new Set();
200
+ for (const story of proposed) {
201
+ if (!NewStoryId.test(story.id)) {
202
+ return { ok: false, added: 0, summary: `proposed story id must be one safe path segment: ${story.id}`, changeId: request.id };
203
+ }
204
+ if (story.passes)
205
+ return { ok: false, added: 0, summary: `proposed story ${story.id} is already marked passed`, changeId: request.id };
206
+ if (existingIds.has(story.id) || proposedIds.has(story.id)) {
207
+ return { ok: false, added: 0, summary: `duplicate proposed story id: ${story.id}`, changeId: request.id };
208
+ }
209
+ if (story.acceptance.length < 2 || story.acceptance.length > 5) {
210
+ return { ok: false, added: 0, summary: `proposed story ${story.id} must have 2-5 acceptance criteria`, changeId: request.id };
211
+ }
212
+ if (story.acceptance.some(criterion => !isAcceptanceCriterion(criterion))) {
213
+ return { ok: false, added: 0, summary: `proposed story ${story.id} lacks executable criterion evidence`, changeId: request.id };
214
+ }
215
+ for (const criterion of story.acceptance.filter(isAcceptanceCriterion)) {
216
+ const problem = criterionCommandProblem(criterion);
217
+ if (problem)
218
+ return { ok: false, added: 0, summary: problem, changeId: request.id };
219
+ }
220
+ proposedIds.add(story.id);
221
+ }
222
+ const appended = proposed.map(story => ({ ...story, sourceChange: request.id }));
223
+ const dependencyIssues = validateDependencies([...existing, ...appended]);
224
+ if (dependencyIssues.length > 0) {
225
+ return { ok: false, added: 0, summary: `invalid combined dependency graph: ${dependencyIssues.join('; ')}`, changeId: request.id };
226
+ }
227
+ const reviewPath = reviewFile(targetDir, request.id);
228
+ rmSync(reviewPath, { force: true });
229
+ const reviewBase = agentInvocation(reviewer, buildChangeReviewPrompt(request, reviewPath, appended), plannerDir, opts.permissions ?? 'safe', reviewer === opts.runner ? opts.selection : undefined);
230
+ const reviewInvocation = buildWatchdogInvocation(reviewBase, opts.timeoutMs ?? 0);
231
+ const reviewResult = (opts.review ?? runAgent)(reviewInvocation);
232
+ if (!reviewResult.success) {
233
+ return { ok: false, added: 0, summary: `coverage review failed: ${reviewResult.summary}`, changeId: request.id };
234
+ }
235
+ if (readFileSync(prdPath, 'utf8') !== existingText) {
236
+ return {
237
+ ok: false, added: 0,
238
+ summary: 'PRD changed while the coverage reviewer was running; refusing to overwrite concurrent or out-of-contract edits',
239
+ changeId: request.id,
240
+ };
241
+ }
242
+ if (!existsSync(reviewPath)) {
243
+ return { ok: false, added: 0, summary: `coverage reviewer did not write ${basename(reviewPath)}`, changeId: request.id };
244
+ }
245
+ let coverageReview;
246
+ try {
247
+ coverageReview = ChangeReviewSchema.parse(JSON.parse(readFileSync(reviewPath, 'utf8')));
248
+ }
249
+ catch (error) {
250
+ return { ok: false, added: 0, summary: `invalid coverage review: ${error.message}`, changeId: request.id };
251
+ }
252
+ if (coverageReview.changeId !== request.id) {
253
+ return { ok: false, added: 0, summary: `coverage review targets ${coverageReview.changeId}, expected ${request.id}`, changeId: request.id };
254
+ }
255
+ if (!coverageReview.approved || coverageReview.uncovered.length > 0) {
256
+ const missing = coverageReview.uncovered.length > 0 ? `: ${coverageReview.uncovered.join('; ')}` : '';
257
+ return { ok: false, added: 0, summary: `coverage review rejected the proposal: ${coverageReview.summary}${missing}`, changeId: request.id };
258
+ }
259
+ try {
260
+ savePrd(prdPath, [...existing, ...appended]);
261
+ (opts.commit ?? ((path, change) => commitPaths(targetDir, [path], `yoke: plan change ${change.id}`)))(prdPath, request);
262
+ }
263
+ catch (error) {
264
+ writeFileSync(prdPath, existingText);
265
+ return { ok: false, added: 0, summary: `could not persist change: ${error.message}`, changeId: request.id };
266
+ }
267
+ try {
268
+ ;
269
+ (opts.archive ?? archiveRequest)(targetDir, request);
270
+ }
271
+ catch (error) {
272
+ return {
273
+ ok: false, added: appended.length,
274
+ summary: `planned change ${request.id} was committed but could not be archived; keeping it pending for recovery: ${error.message}`,
275
+ changeId: request.id,
276
+ };
277
+ }
278
+ return { ok: true, added: appended.length, summary: `added ${appended.length} stories from ${request.id}`, changeId: request.id };
279
+ }
package/dist/cli.js CHANGED
@@ -15,6 +15,7 @@ import { maybeNotifyUpdate, currentYokeVersion } from './update/check.js';
15
15
  import { runUpgrade } from './update/upgrade.js';
16
16
  import { printAudit, runAudit } from './audit/command.js';
17
17
  import { runSetup } from './setup/command.js';
18
+ import { pendingChanges, queueChange } from './change/inbox.js';
18
19
  import { answerPendingDecision, answeredDecisionResumeIsValid, clearDecisionResume, decisionProcessingExists, decisionResumeMatchesCurrent, finalizeCommittedDecisionResume, formatPendingDecision, readDecisionResume, readPendingDecision, writeDecisionResume, } from './loop/decision.js';
19
20
  export { runRetrofit } from './retrofit/command.js';
20
21
  export function runValidate(canonDir) {
@@ -115,6 +116,39 @@ export function main(argv) {
115
116
  }
116
117
  return runRetrofit(targetDir, { loop, agents, codeGraph });
117
118
  }
119
+ case 'change': {
120
+ const sub = rest[0];
121
+ const targetDir = rest.slice(1).find(a => !a.startsWith('-')) ?? '.';
122
+ if (sub === 'add') {
123
+ const idea = rest.find(a => a.startsWith('--idea='))?.slice('--idea='.length);
124
+ if (!idea?.trim()) {
125
+ console.error('usage: yoke change add [dir] --idea="..."');
126
+ return 1;
127
+ }
128
+ try {
129
+ const request = queueChange(targetDir, idea);
130
+ console.log(`Queued change ${request.id}. A running loop will turn it into new stories at the next story boundary.`);
131
+ return 0;
132
+ }
133
+ catch (error) {
134
+ console.error(`Could not queue change: ${error.message}`);
135
+ return 1;
136
+ }
137
+ }
138
+ if (sub === 'status') {
139
+ try {
140
+ const requests = pendingChanges(targetDir);
141
+ console.log(requests.length === 0 ? 'No pending changes.' : requests.map(item => `${item.id} ${item.request}`).join('\n'));
142
+ return 0;
143
+ }
144
+ catch (error) {
145
+ console.error(`Could not read change inbox: ${error.message}`);
146
+ return 1;
147
+ }
148
+ }
149
+ console.log('usage: yoke change <add|status> [dir] [--idea="..."]');
150
+ return 1;
151
+ }
118
152
  case 'loop': {
119
153
  const sub = rest[0];
120
154
  const targetDir = rest.slice(1).find(a => !a.startsWith('-')) ?? '.';
@@ -430,7 +464,7 @@ export function main(argv) {
430
464
  case 'upgrade':
431
465
  return runUpgrade();
432
466
  default:
433
- console.log('usage: yoke <setup [dir] | new <dir> [--idea="..."] | validate [canonDir] | retrofit [targetDir] [--agent=claude,codex,gemini|all] [--code-graph=graphify|serena] [--loop] | prd <draft|check> [dir] | loop <on|off|status|decision|answer|resume|run|cleanup> | context <init|status> | review [dir] [--reviewer=<claude|codex|gemini>] [--base=<ref>] [--focus="..."] | design-scan [dir] [--max=N] [--report] | flow-smoke [dir] [--url=<baseUrl>] [--label=<name>] | upgrade>');
467
+ console.log('usage: yoke <setup [dir] | new <dir> [--idea="..."] | validate [canonDir] | retrofit [targetDir] [--agent=claude,codex,gemini|all] [--code-graph=graphify|serena] [--loop] | change <add|status> [dir] | prd <draft|check> [dir] | loop <on|off|status|decision|answer|resume|run|cleanup> | context <init|status> | review [dir] [--reviewer=<claude|codex|gemini>] [--base=<ref>] [--focus="..."] | design-scan [dir] [--max=N] [--report] | flow-smoke [dir] [--url=<baseUrl>] [--label=<name>] | upgrade>');
434
468
  return cmd ? 1 : 0;
435
469
  }
436
470
  }
@@ -0,0 +1,31 @@
1
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { storyPathSegment } from './prd.js';
4
+ export function writeCriterionEvidence(targetDir, story, evidence) {
5
+ const storySegment = storyPathSegment(story.id);
6
+ const dir = join(targetDir, '.yoke', 'proof', storySegment);
7
+ mkdirSync(dir, { recursive: true });
8
+ const path = join(dir, 'evidence.json');
9
+ const temporary = `${path}.${process.pid}.tmp`;
10
+ const contents = JSON.stringify({
11
+ version: 1,
12
+ storyId: story.id,
13
+ generatedAt: new Date().toISOString(),
14
+ criteria: evidence.map(({ criterion, result }) => ({
15
+ id: criterion.id,
16
+ text: criterion.text,
17
+ verify: criterion.verify,
18
+ passed: result.passed,
19
+ summary: result.summary,
20
+ })),
21
+ }, null, 2) + '\n';
22
+ try {
23
+ writeFileSync(temporary, contents, { flag: 'wx' });
24
+ renameSync(temporary, path);
25
+ }
26
+ catch (error) {
27
+ rmSync(temporary, { force: true });
28
+ throw error;
29
+ }
30
+ return path;
31
+ }
@@ -1,7 +1,16 @@
1
- export function stopTheLineGate(story) {
1
+ import { criterionCommandProblem, isAcceptanceCriterion } from './prd.js';
2
+ export function stopTheLineGate(story, requireCriterionEvidence = false) {
2
3
  if (story.acceptance.length === 0) {
3
4
  return { ok: false, reason: `story ${story.id} has no acceptance criteria (Stop-the-Line)` };
4
5
  }
6
+ if (requireCriterionEvidence && story.acceptance.some(criterion => !isAcceptanceCriterion(criterion))) {
7
+ return { ok: false, reason: `story ${story.id} lacks executable criterion evidence (Stop-the-Line)` };
8
+ }
9
+ for (const criterion of story.acceptance.filter(isAcceptanceCriterion)) {
10
+ const problem = criterionCommandProblem(criterion);
11
+ if (problem)
12
+ return { ok: false, reason: `${problem} (Stop-the-Line)` };
13
+ }
5
14
  return { ok: true };
6
15
  }
7
16
  export function preDispatchGate(targetDir, git) {