@hecer/yoke 1.2.1 → 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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +44 -24
- package/README.md +126 -96
- package/canon/loop/loop-spec.md +47 -32
- 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/change/inbox.js +279 -0
- package/dist/cli.js +35 -1
- package/dist/loop/evidence.js +31 -0
- package/dist/loop/gates.js +10 -1
- package/dist/loop/loop.js +127 -17
- package/dist/loop/prd.js +60 -2
- package/dist/loop/run-command.js +23 -2
- package/dist/loop/runner.js +10 -2
- package/dist/loop/verify.js +11 -0
- package/dist/prd/command.js +18 -5
- package/dist/retrofit/config.js +9 -2
- package/dist/retrofit/gitignore.js +1 -0
- package/dist/routing/router.js +4 -1
- package/gemini-extension.json +1 -1
- package/package.json +6 -6
|
@@ -1,38 +1,27 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: authoring-prd
|
|
3
|
-
description: Use when turning a product idea into a loop-ready
|
|
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.
|
|
9
|
-
|
|
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.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
-
|
|
45
|
-
|
|
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
|
-
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/loop/gates.js
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
|
|
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) {
|