@hecer/yoke 1.2.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +66 -24
- package/README.md +186 -104
- package/TODOS.md +0 -3
- package/canon/loop/loop-spec.md +53 -24
- package/canon/loop/prd.schema.md +30 -6
- package/canon/manifest.yaml +1 -1
- package/canon/skills/authoring-prd/SKILL.md +30 -31
- package/dist/agents/contracts.js +50 -0
- package/dist/agents/process-incarnation.js +15 -0
- package/dist/agents/process-record.js +65 -0
- package/dist/agents/process-streams.js +40 -0
- package/dist/agents/process.js +177 -0
- package/dist/agents/providers.js +10 -7
- package/dist/agents/telemetry.js +62 -0
- package/dist/change/inbox.js +279 -0
- package/dist/cli.js +90 -4
- package/dist/loop/candidate-boundaries.js +43 -0
- package/dist/loop/candidate-cleanup.js +98 -0
- package/dist/loop/candidate-contracts.js +1 -0
- package/dist/loop/candidate-selection.js +84 -0
- package/dist/loop/candidates.js +228 -0
- package/dist/loop/claim-lease.js +131 -0
- package/dist/loop/claims.js +177 -40
- package/dist/loop/cleanup.js +117 -15
- package/dist/loop/decision.js +31 -0
- package/dist/loop/dispatcher.js +334 -0
- package/dist/loop/evidence.js +31 -0
- package/dist/loop/gates.js +10 -1
- package/dist/loop/loop.js +236 -33
- package/dist/loop/merge-queue.js +12 -6
- package/dist/loop/parallel-adapters.js +185 -0
- package/dist/loop/parallel-command.js +287 -0
- package/dist/loop/parallel.js +2 -4
- package/dist/loop/prd.js +63 -2
- package/dist/loop/reporter.js +86 -5
- package/dist/loop/run-command.js +227 -53
- package/dist/loop/runner.js +77 -34
- package/dist/loop/verify.js +11 -0
- package/dist/loop/watchdog.js +67 -8
- package/dist/loop/worker-cancellation.js +17 -0
- package/dist/loop/worker-cleanup.js +23 -0
- package/dist/loop/worker-contracts.js +1 -0
- package/dist/loop/worker.js +254 -0
- package/dist/prd/command.js +18 -5
- package/dist/quality/artifacts.js +59 -0
- package/dist/quality/candidate-comparison.js +130 -0
- package/dist/quality/command.js +316 -0
- package/dist/quality/loop.js +86 -0
- package/dist/quality/process-command.js +57 -0
- package/dist/quality/reference.js +187 -0
- package/dist/quality/repair.js +11 -0
- package/dist/quality/runner.js +66 -0
- package/dist/quality/types.js +60 -0
- package/dist/quality/verdict.js +142 -0
- package/dist/retrofit/config.js +13 -4
- package/dist/retrofit/gitignore.js +4 -0
- package/dist/review/command.js +27 -38
- package/dist/review/verdict.js +38 -7
- package/dist/routing/router.js +4 -1
- package/docs/MIGRATING-TO-1.4.md +70 -0
- package/docs/PUBLISHING.md +16 -2
- package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -0
- package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
- package/gemini-extension.json +1 -1
- package/package.json +6 -6
package/dist/agents/telemetry.js
CHANGED
|
@@ -1,4 +1,66 @@
|
|
|
1
1
|
const finite = (value) => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
2
|
+
function parseJson(value) {
|
|
3
|
+
try {
|
|
4
|
+
return { ok: true, value: JSON.parse(value) };
|
|
5
|
+
}
|
|
6
|
+
catch (error) {
|
|
7
|
+
if (error instanceof SyntaxError)
|
|
8
|
+
return { ok: false };
|
|
9
|
+
throw error;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function isRecord(value) {
|
|
13
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
function directMachineResult(value) {
|
|
16
|
+
return isRecord(value) && value.schemaVersion === 1 ? value : undefined;
|
|
17
|
+
}
|
|
18
|
+
export function parseProviderResult(agent, output) {
|
|
19
|
+
const whole = parseJson(output);
|
|
20
|
+
if (whole.ok) {
|
|
21
|
+
const direct = directMachineResult(whole.value);
|
|
22
|
+
if (direct !== undefined)
|
|
23
|
+
return direct;
|
|
24
|
+
}
|
|
25
|
+
const fragments = [];
|
|
26
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
27
|
+
const parsed = parseJson(line);
|
|
28
|
+
if (!parsed.ok || !isRecord(parsed.value))
|
|
29
|
+
continue;
|
|
30
|
+
const event = parsed.value;
|
|
31
|
+
switch (agent) {
|
|
32
|
+
case 'claude':
|
|
33
|
+
if (event.type === 'result' && typeof event.result === 'string')
|
|
34
|
+
fragments.push(event.result);
|
|
35
|
+
break;
|
|
36
|
+
case 'codex': {
|
|
37
|
+
const item = isRecord(event.item) ? event.item : undefined;
|
|
38
|
+
if (event.type === 'item.completed' && item?.type === 'agent_message' && typeof item.text === 'string')
|
|
39
|
+
fragments.push(item.text);
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
case 'gemini':
|
|
43
|
+
if (event.type === 'message' && event.role === 'assistant' && typeof event.content === 'string')
|
|
44
|
+
fragments.push(event.content);
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const joined = parseJson(fragments.join(''));
|
|
49
|
+
if (joined.ok) {
|
|
50
|
+
const direct = directMachineResult(joined.value);
|
|
51
|
+
if (direct !== undefined)
|
|
52
|
+
return direct;
|
|
53
|
+
}
|
|
54
|
+
for (const fragment of fragments.reverse()) {
|
|
55
|
+
const parsed = parseJson(fragment);
|
|
56
|
+
if (!parsed.ok)
|
|
57
|
+
continue;
|
|
58
|
+
const direct = directMachineResult(parsed.value);
|
|
59
|
+
if (direct !== undefined)
|
|
60
|
+
return direct;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
2
64
|
export function parseProviderTelemetry(agent, lines) {
|
|
3
65
|
let inputTokens;
|
|
4
66
|
let cachedInputTokens;
|
|
@@ -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) {
|
|
@@ -51,6 +52,48 @@ export function runDesignScan(targetDir, opts) {
|
|
|
51
52
|
console.log(`${label} — ✓`);
|
|
52
53
|
return 0;
|
|
53
54
|
}
|
|
55
|
+
export function parseQualityFlags(args) {
|
|
56
|
+
const quality = args.includes('--quality') ? true : args.includes('--no-quality') ? false : undefined;
|
|
57
|
+
const qualityUnbounded = args.includes('--quality-unbounded') || undefined;
|
|
58
|
+
const parsePositive = (flag) => {
|
|
59
|
+
const raw = args.find(arg => arg.startsWith(flag));
|
|
60
|
+
if (!raw)
|
|
61
|
+
return undefined;
|
|
62
|
+
const value = Number(raw.slice(flag.length));
|
|
63
|
+
return Number.isInteger(value) && value > 0 ? value : `Invalid ${flag.slice(0, -1)} value: ${raw}`;
|
|
64
|
+
};
|
|
65
|
+
const qualityRounds = parsePositive('--quality-rounds=');
|
|
66
|
+
if (typeof qualityRounds === 'string')
|
|
67
|
+
return { ok: false, error: qualityRounds };
|
|
68
|
+
const qualityMinutes = parsePositive('--quality-minutes=');
|
|
69
|
+
if (typeof qualityMinutes === 'string')
|
|
70
|
+
return { ok: false, error: qualityMinutes };
|
|
71
|
+
const candidates = parsePositive('--candidates=');
|
|
72
|
+
if (typeof candidates === 'string')
|
|
73
|
+
return { ok: false, error: candidates };
|
|
74
|
+
const policyArg = args.find(arg => arg.startsWith('--quality-policy='))?.slice('--quality-policy='.length);
|
|
75
|
+
if (policyArg && policyArg !== 'blocking' && policyArg !== 'advisory') {
|
|
76
|
+
return { ok: false, error: `Invalid --quality-policy value: ${policyArg} (expected blocking|advisory)` };
|
|
77
|
+
}
|
|
78
|
+
const qualityPolicy = policyArg === 'blocking' || policyArg === 'advisory' ? policyArg : undefined;
|
|
79
|
+
if (quality === true && args.includes('--no-quality'))
|
|
80
|
+
return { ok: false, error: 'Cannot use --quality with --no-quality.' };
|
|
81
|
+
if (qualityUnbounded && quality === false)
|
|
82
|
+
return { ok: false, error: 'Cannot use --quality-unbounded with --no-quality.' };
|
|
83
|
+
if (qualityUnbounded && (qualityRounds !== undefined || qualityMinutes !== undefined)) {
|
|
84
|
+
return { ok: false, error: 'Cannot use --quality-unbounded with --quality-rounds or --quality-minutes.' };
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
options: {
|
|
89
|
+
...(qualityUnbounded ? { quality: true, qualityUnbounded: true } : quality !== undefined ? { quality } : {}),
|
|
90
|
+
...(qualityRounds !== undefined ? { qualityRounds } : {}),
|
|
91
|
+
...(qualityMinutes !== undefined ? { qualityMinutes } : {}),
|
|
92
|
+
...(qualityPolicy ? { qualityPolicy } : {}),
|
|
93
|
+
...(candidates !== undefined ? { candidates } : {}),
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
54
97
|
export function main(argv) {
|
|
55
98
|
const [cmd, ...rest] = argv;
|
|
56
99
|
switch (cmd) {
|
|
@@ -115,6 +158,39 @@ export function main(argv) {
|
|
|
115
158
|
}
|
|
116
159
|
return runRetrofit(targetDir, { loop, agents, codeGraph });
|
|
117
160
|
}
|
|
161
|
+
case 'change': {
|
|
162
|
+
const sub = rest[0];
|
|
163
|
+
const targetDir = rest.slice(1).find(a => !a.startsWith('-')) ?? '.';
|
|
164
|
+
if (sub === 'add') {
|
|
165
|
+
const idea = rest.find(a => a.startsWith('--idea='))?.slice('--idea='.length);
|
|
166
|
+
if (!idea?.trim()) {
|
|
167
|
+
console.error('usage: yoke change add [dir] --idea="..."');
|
|
168
|
+
return 1;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const request = queueChange(targetDir, idea);
|
|
172
|
+
console.log(`Queued change ${request.id}. A running loop will turn it into new stories at the next story boundary.`);
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
console.error(`Could not queue change: ${error.message}`);
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (sub === 'status') {
|
|
181
|
+
try {
|
|
182
|
+
const requests = pendingChanges(targetDir);
|
|
183
|
+
console.log(requests.length === 0 ? 'No pending changes.' : requests.map(item => `${item.id} ${item.request}`).join('\n'));
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
console.error(`Could not read change inbox: ${error.message}`);
|
|
188
|
+
return 1;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
console.log('usage: yoke change <add|status> [dir] [--idea="..."]');
|
|
192
|
+
return 1;
|
|
193
|
+
}
|
|
118
194
|
case 'loop': {
|
|
119
195
|
const sub = rest[0];
|
|
120
196
|
const targetDir = rest.slice(1).find(a => !a.startsWith('-')) ?? '.';
|
|
@@ -183,7 +259,12 @@ export function main(argv) {
|
|
|
183
259
|
return 1;
|
|
184
260
|
}
|
|
185
261
|
const { version: _version, storyId: _storyId, requestId: _requestId, answered: _answered, ...resumeOptions } = resume;
|
|
186
|
-
|
|
262
|
+
const qualityFlags = parseQualityFlags(rest);
|
|
263
|
+
if (!qualityFlags.ok) {
|
|
264
|
+
console.error(qualityFlags.error);
|
|
265
|
+
return 1;
|
|
266
|
+
}
|
|
267
|
+
return runLoopCommand(targetDir, { ...resumeOptions, ...qualityFlags.options });
|
|
187
268
|
}
|
|
188
269
|
if (sub === 'answer') {
|
|
189
270
|
const choice = rest.find(a => a.startsWith('--choice='))?.slice('--choice='.length);
|
|
@@ -308,9 +389,14 @@ export function main(argv) {
|
|
|
308
389
|
console.error(`Invalid --decision-policy value: ${dpArg} (expected auto|critical)`);
|
|
309
390
|
return 1;
|
|
310
391
|
}
|
|
311
|
-
|
|
392
|
+
const qualityFlags = parseQualityFlags(rest);
|
|
393
|
+
if (!qualityFlags.ok) {
|
|
394
|
+
console.error(qualityFlags.error);
|
|
395
|
+
return 1;
|
|
396
|
+
}
|
|
397
|
+
return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, parallel, reviewer, review, allowSelfReview, timeoutMinutes, json, routing, onAmbiguity: oaArg, decisionPolicy: dpArg, permissions, ...qualityFlags.options });
|
|
312
398
|
}
|
|
313
|
-
console.log('usage: yoke loop <on|off|status|decision|answer|resume [--discard]|cleanup [--remove-worktrees] [--discard-stale-recovery]|run [--max=N] [--parallel=N] [--runner=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--review] [--allow-self-review] [--routing|--no-routing] [--isolate] [--unsafe] [--timeout=<minutes>] [--decision-policy=<auto|critical>] [--json]> [targetDir]');
|
|
399
|
+
console.log('usage: yoke loop <on|off|status|decision|answer|resume [--discard] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N]|cleanup [--remove-worktrees] [--discard-stale-recovery]|run [--max=N] [--parallel=N] [--runner=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--review] [--allow-self-review] [--routing|--no-routing] [--isolate] [--unsafe] [--timeout=<minutes>] [--decision-policy=<auto|critical>] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N] [--json]> [targetDir]');
|
|
314
400
|
return 1;
|
|
315
401
|
}
|
|
316
402
|
case 'new': {
|
|
@@ -430,7 +516,7 @@ export function main(argv) {
|
|
|
430
516
|
case 'upgrade':
|
|
431
517
|
return runUpgrade();
|
|
432
518
|
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>');
|
|
519
|
+
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
520
|
return cmd ? 1 : 0;
|
|
435
521
|
}
|
|
436
522
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { posix, win32 } from 'node:path';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
const CandidateEvidenceSchema = z.object({
|
|
4
|
+
digest: z.string().min(1),
|
|
5
|
+
artifacts: z.array(z.string()),
|
|
6
|
+
});
|
|
7
|
+
const CandidateIdSchema = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/);
|
|
8
|
+
export function candidateConfigProblem(input) {
|
|
9
|
+
if (!Number.isInteger(input.maxCandidates) || input.maxCandidates < 1 || input.maxCandidates > 5) {
|
|
10
|
+
return 'maxCandidates must be an integer from 1 to 5';
|
|
11
|
+
}
|
|
12
|
+
if (input.candidates.length === 0)
|
|
13
|
+
return 'at least one candidate is required';
|
|
14
|
+
if (input.candidates.some(candidate => !CandidateIdSchema.safeParse(candidate.id).success)) {
|
|
15
|
+
return 'candidate ids must be non-empty safe identifiers';
|
|
16
|
+
}
|
|
17
|
+
if (new Set(input.candidates.map(candidate => candidate.id)).size !== input.candidates.length) {
|
|
18
|
+
return 'candidate ids must be unique';
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
export function parseCandidateEvidence(value) {
|
|
23
|
+
const parsed = CandidateEvidenceSchema.parse(value);
|
|
24
|
+
return { digest: parsed.digest, artifacts: [...parsed.artifacts] };
|
|
25
|
+
}
|
|
26
|
+
export function canonicalWorktreePath(path, platform) {
|
|
27
|
+
if (platform === 'win32') {
|
|
28
|
+
if (!/^(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+)/.test(path))
|
|
29
|
+
return null;
|
|
30
|
+
const normalized = win32.normalize(path).replaceAll('/', '\\').toLowerCase();
|
|
31
|
+
return trimSeparators(normalized, win32.parse(normalized).root, '\\');
|
|
32
|
+
}
|
|
33
|
+
if (!posix.isAbsolute(path))
|
|
34
|
+
return null;
|
|
35
|
+
const normalized = posix.normalize(path);
|
|
36
|
+
return trimSeparators(normalized, posix.parse(normalized).root, '/');
|
|
37
|
+
}
|
|
38
|
+
function trimSeparators(path, root, separator) {
|
|
39
|
+
let key = path;
|
|
40
|
+
while (key.length > root.length && key.endsWith(separator))
|
|
41
|
+
key = key.slice(0, -1);
|
|
42
|
+
return key;
|
|
43
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const stageOrder = ['cancel', 'reap', 'remove'];
|
|
2
|
+
export class CandidateCleanup {
|
|
3
|
+
lifecycle;
|
|
4
|
+
onLifecycle;
|
|
5
|
+
#states = new Map();
|
|
6
|
+
constructor(lifecycle, onLifecycle) {
|
|
7
|
+
this.lifecycle = lifecycle;
|
|
8
|
+
this.onLifecycle = onLifecycle;
|
|
9
|
+
}
|
|
10
|
+
register(ownership) {
|
|
11
|
+
this.#states.set(ownership.candidateId, { ownership, materialized: false, completed: new Set(), failed: new Map(), pending: new Map() });
|
|
12
|
+
}
|
|
13
|
+
markMaterialized(candidateId) {
|
|
14
|
+
const state = this.#states.get(candidateId);
|
|
15
|
+
if (state)
|
|
16
|
+
state.materialized = true;
|
|
17
|
+
}
|
|
18
|
+
cancelNow(reason) {
|
|
19
|
+
for (const state of this.#states.values()) {
|
|
20
|
+
state.ownership.controller.abort(reason);
|
|
21
|
+
this.onLifecycle?.(state.ownership, 'cleaning', reason);
|
|
22
|
+
void this.#runStage(state, 'cancel', () => this.lifecycle.cancel(state.ownership, reason));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async cleanup(ownership, reason) {
|
|
26
|
+
const state = this.#states.get(ownership.candidateId);
|
|
27
|
+
if (!state)
|
|
28
|
+
return;
|
|
29
|
+
ownership.controller.abort(reason);
|
|
30
|
+
this.onLifecycle?.(ownership, 'cleaning', reason);
|
|
31
|
+
if (!await this.#runStage(state, 'cancel', () => this.lifecycle.cancel(ownership, reason)))
|
|
32
|
+
return;
|
|
33
|
+
if (!await this.#runStage(state, 'reap', () => this.lifecycle.reap(ownership)))
|
|
34
|
+
return;
|
|
35
|
+
if (await this.#runStage(state, 'remove', () => this.lifecycle.remove(ownership)))
|
|
36
|
+
this.onLifecycle?.(ownership, 'removed', reason);
|
|
37
|
+
}
|
|
38
|
+
async cleanupAll(reason, keepCandidateId) {
|
|
39
|
+
for (const state of this.#states.values()) {
|
|
40
|
+
if (state.ownership.candidateId !== keepCandidateId)
|
|
41
|
+
await this.cleanup(state.ownership, reason);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
hasFailures() {
|
|
45
|
+
return [...this.#states.values()].some(state => state.failed.size > 0);
|
|
46
|
+
}
|
|
47
|
+
failureSummary() {
|
|
48
|
+
return this.recovery().flatMap(item => item.failedStages.map(failure => `${item.ownership.candidateId}:${failure.stage}: ${failure.summary}`)).join('; ');
|
|
49
|
+
}
|
|
50
|
+
recovery() {
|
|
51
|
+
return [...this.#states.values()].filter(state => state.failed.size > 0).map(state => ({
|
|
52
|
+
ownership: {
|
|
53
|
+
candidateId: state.ownership.candidateId,
|
|
54
|
+
coordinatorId: state.ownership.coordinatorId,
|
|
55
|
+
ownerToken: state.ownership.ownerToken,
|
|
56
|
+
storyId: state.ownership.storyId,
|
|
57
|
+
provider: { ...state.ownership.provider },
|
|
58
|
+
worktree: { ...state.ownership.worktree },
|
|
59
|
+
},
|
|
60
|
+
materialized: state.materialized,
|
|
61
|
+
completedStages: stageOrder.filter(stage => state.completed.has(stage)),
|
|
62
|
+
failedStages: stageOrder.flatMap(stage => {
|
|
63
|
+
const summary = state.failed.get(stage);
|
|
64
|
+
return summary ? [{ stage, summary }] : [];
|
|
65
|
+
}),
|
|
66
|
+
pendingStages: stageOrder.filter(stage => !state.completed.has(stage) && !state.failed.has(stage)),
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
#runStage(state, stage, operation) {
|
|
70
|
+
if (state.completed.has(stage))
|
|
71
|
+
return Promise.resolve(true);
|
|
72
|
+
const pending = state.pending.get(stage);
|
|
73
|
+
if (pending)
|
|
74
|
+
return pending.then(() => state.completed.has(stage));
|
|
75
|
+
try {
|
|
76
|
+
const result = operation();
|
|
77
|
+
if (!(result instanceof Promise)) {
|
|
78
|
+
state.completed.add(stage);
|
|
79
|
+
state.failed.delete(stage);
|
|
80
|
+
return Promise.resolve(true);
|
|
81
|
+
}
|
|
82
|
+
const task = result.then(() => {
|
|
83
|
+
state.completed.add(stage);
|
|
84
|
+
state.failed.delete(stage);
|
|
85
|
+
}).catch(error => {
|
|
86
|
+
state.failed.set(stage, error instanceof Error ? error.message : String(error));
|
|
87
|
+
}).finally(() => {
|
|
88
|
+
state.pending.delete(stage);
|
|
89
|
+
});
|
|
90
|
+
state.pending.set(stage, task);
|
|
91
|
+
return task.then(() => state.completed.has(stage));
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
state.failed.set(stage, error instanceof Error ? error.message : String(error));
|
|
95
|
+
return Promise.resolve(false);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|