@hecer/yoke 1.2.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +66 -24
  4. package/README.md +186 -104
  5. package/TODOS.md +0 -3
  6. package/canon/loop/loop-spec.md +53 -24
  7. package/canon/loop/prd.schema.md +30 -6
  8. package/canon/manifest.yaml +1 -1
  9. package/canon/skills/authoring-prd/SKILL.md +30 -31
  10. package/dist/agents/contracts.js +50 -0
  11. package/dist/agents/process-incarnation.js +15 -0
  12. package/dist/agents/process-record.js +65 -0
  13. package/dist/agents/process-streams.js +40 -0
  14. package/dist/agents/process.js +177 -0
  15. package/dist/agents/providers.js +10 -7
  16. package/dist/agents/telemetry.js +62 -0
  17. package/dist/change/inbox.js +279 -0
  18. package/dist/cli.js +90 -4
  19. package/dist/loop/candidate-boundaries.js +43 -0
  20. package/dist/loop/candidate-cleanup.js +98 -0
  21. package/dist/loop/candidate-contracts.js +1 -0
  22. package/dist/loop/candidate-selection.js +84 -0
  23. package/dist/loop/candidates.js +228 -0
  24. package/dist/loop/claim-lease.js +131 -0
  25. package/dist/loop/claims.js +177 -40
  26. package/dist/loop/cleanup.js +117 -15
  27. package/dist/loop/decision.js +31 -0
  28. package/dist/loop/dispatcher.js +334 -0
  29. package/dist/loop/evidence.js +31 -0
  30. package/dist/loop/gates.js +10 -1
  31. package/dist/loop/loop.js +236 -33
  32. package/dist/loop/merge-queue.js +12 -6
  33. package/dist/loop/parallel-adapters.js +185 -0
  34. package/dist/loop/parallel-command.js +287 -0
  35. package/dist/loop/parallel.js +2 -4
  36. package/dist/loop/prd.js +63 -2
  37. package/dist/loop/reporter.js +86 -5
  38. package/dist/loop/run-command.js +227 -53
  39. package/dist/loop/runner.js +77 -34
  40. package/dist/loop/verify.js +11 -0
  41. package/dist/loop/watchdog.js +67 -8
  42. package/dist/loop/worker-cancellation.js +17 -0
  43. package/dist/loop/worker-cleanup.js +23 -0
  44. package/dist/loop/worker-contracts.js +1 -0
  45. package/dist/loop/worker.js +254 -0
  46. package/dist/prd/command.js +18 -5
  47. package/dist/quality/artifacts.js +59 -0
  48. package/dist/quality/candidate-comparison.js +130 -0
  49. package/dist/quality/command.js +316 -0
  50. package/dist/quality/loop.js +86 -0
  51. package/dist/quality/process-command.js +57 -0
  52. package/dist/quality/reference.js +187 -0
  53. package/dist/quality/repair.js +11 -0
  54. package/dist/quality/runner.js +66 -0
  55. package/dist/quality/types.js +60 -0
  56. package/dist/quality/verdict.js +142 -0
  57. package/dist/retrofit/config.js +13 -4
  58. package/dist/retrofit/gitignore.js +4 -0
  59. package/dist/review/command.js +27 -38
  60. package/dist/review/verdict.js +38 -7
  61. package/dist/routing/router.js +4 -1
  62. package/docs/MIGRATING-TO-1.4.md +70 -0
  63. package/docs/PUBLISHING.md +16 -2
  64. package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -0
  65. package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
  66. package/gemini-extension.json +1 -1
  67. package/package.json +6 -6
@@ -1,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
- return runLoopCommand(targetDir, resumeOptions);
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
- return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, parallel, reviewer, review, allowSelfReview, timeoutMinutes, json, routing, onAmbiguity: oaArg, decisionPolicy: dpArg, permissions });
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 {};