@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
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { existsSync, rmSync } from 'node:fs';
|
|
2
|
+
import { createDispatcher } from './dispatcher.js';
|
|
3
|
+
import { consumeDecisionRequest } from './decision.js';
|
|
4
|
+
import { pauseFilePath } from './loop.js';
|
|
5
|
+
import { makeParallelAdapters } from './parallel-adapters.js';
|
|
6
|
+
import { loadPrd, progress } from './prd.js';
|
|
7
|
+
import { makeAsyncRunner } from './runner.js';
|
|
8
|
+
import { runStoryWorker } from './worker.js';
|
|
9
|
+
export async function runParallelLoopCommand(input) {
|
|
10
|
+
const adapters = makeParallelAdapters(input.targetDir, input.identity, input.git);
|
|
11
|
+
if (!adapters.git.isClean(input.targetDir)) {
|
|
12
|
+
input.reporter.blocked('target working tree is not clean');
|
|
13
|
+
return 1;
|
|
14
|
+
}
|
|
15
|
+
let pauseConsumed = false;
|
|
16
|
+
const pause = () => {
|
|
17
|
+
if (pauseConsumed)
|
|
18
|
+
return true;
|
|
19
|
+
const path = pauseFilePath(input.targetDir);
|
|
20
|
+
if (!existsSync(path))
|
|
21
|
+
return false;
|
|
22
|
+
rmSync(path, { force: true });
|
|
23
|
+
pauseConsumed = true;
|
|
24
|
+
return true;
|
|
25
|
+
};
|
|
26
|
+
const dispatcher = createDispatcher({
|
|
27
|
+
targetDir: input.targetDir,
|
|
28
|
+
prdPath: input.prdPath,
|
|
29
|
+
stories: loadPrd(input.prdPath),
|
|
30
|
+
maxConcurrency: input.maxConcurrency,
|
|
31
|
+
maxIterations: input.maxIterations,
|
|
32
|
+
providers: input.providers,
|
|
33
|
+
affinityProviders: input.affinityProviders,
|
|
34
|
+
worktrees: adapters.worktrees,
|
|
35
|
+
git: adapters.git,
|
|
36
|
+
...(input.candidateCount && input.candidateCount > 1 ? {
|
|
37
|
+
candidateCount: input.candidateCount,
|
|
38
|
+
candidateCoordinator: workerInput => candidateCoordinatorInput(input, adapters, workerInput, pause),
|
|
39
|
+
} : {}),
|
|
40
|
+
pause,
|
|
41
|
+
onProgress: status => input.reporter.parallel?.(status),
|
|
42
|
+
gates: {
|
|
43
|
+
verify: input.verify,
|
|
44
|
+
verifyCriterion: input.verifyCriterion,
|
|
45
|
+
requireCriterionEvidence: input.requireCriterionEvidence,
|
|
46
|
+
perf: input.perf,
|
|
47
|
+
audit: input.audit,
|
|
48
|
+
qualityReview: (path, story, worker) => integrationGate(input, path, story, integratorReporter(input.reporter, worker)),
|
|
49
|
+
integrationPhase: (worker, phase) => reportIntegrator(input.reporter, worker, phase),
|
|
50
|
+
},
|
|
51
|
+
worker: workerInput => {
|
|
52
|
+
const runner = input.runner ?? asyncRunner(input, workerInput.provider, workerInput.cancellation.signal, workerInput.ownerToken);
|
|
53
|
+
const result = runStoryWorker({
|
|
54
|
+
story: workerInput.story,
|
|
55
|
+
worktree: workerInput.worktree.path,
|
|
56
|
+
baseCommit: workerInput.worktree.baseCommit,
|
|
57
|
+
provider: workerInput.provider,
|
|
58
|
+
runner,
|
|
59
|
+
verify: input.verify,
|
|
60
|
+
verifyCriterion: input.verifyCriterion,
|
|
61
|
+
requireCriterionEvidence: input.requireCriterionEvidence,
|
|
62
|
+
perf: input.perf,
|
|
63
|
+
audit: input.audit,
|
|
64
|
+
qualityPreflight: input.quality?.qualityPreflight,
|
|
65
|
+
qualityStage: input.quality?.qualityStage,
|
|
66
|
+
repair: input.quality?.repair,
|
|
67
|
+
repairLimits: input.quality?.repairLimits,
|
|
68
|
+
qualityMetadata: input.quality?.qualityMetadata,
|
|
69
|
+
qualityEnabled: input.quality?.qualityEnabled,
|
|
70
|
+
review: input.review,
|
|
71
|
+
cancellation: workerInput.cancellation,
|
|
72
|
+
beforeGates: context => {
|
|
73
|
+
const decision = consumeDecisionRequest(context.targetDir, input.targetDir, context.story.id);
|
|
74
|
+
if (!decision)
|
|
75
|
+
return null;
|
|
76
|
+
input.onCriticalDecision?.(decision);
|
|
77
|
+
return decision.question;
|
|
78
|
+
},
|
|
79
|
+
pause,
|
|
80
|
+
reporter: workerReporter(input.reporter, workerInput),
|
|
81
|
+
});
|
|
82
|
+
return Promise.resolve(result);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const result = await dispatcher.run();
|
|
86
|
+
const finalProgress = progress(loadPrd(input.prdPath));
|
|
87
|
+
if (result.status === 'complete' && input.completion) {
|
|
88
|
+
const gate = input.completion(input.targetDir);
|
|
89
|
+
if (!gate.passed) {
|
|
90
|
+
input.reporter.blocked(gate.summary);
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (result.status === 'complete')
|
|
95
|
+
input.reporter.complete(finalProgress);
|
|
96
|
+
else if (result.status === 'paused')
|
|
97
|
+
input.reporter.paused(finalProgress);
|
|
98
|
+
else if (result.status === 'cap-reached')
|
|
99
|
+
input.reporter.capReached(finalProgress);
|
|
100
|
+
else
|
|
101
|
+
input.reporter.blocked(result.reason ?? `parallel dispatcher ${result.status}`);
|
|
102
|
+
return result.status === 'complete' ? 0 : result.status === 'paused' ? 3 : 1;
|
|
103
|
+
}
|
|
104
|
+
function candidateCoordinatorInput(input, adapters, worker, pause) {
|
|
105
|
+
const quality = input.quality;
|
|
106
|
+
const candidateCount = input.candidateCount;
|
|
107
|
+
if (!quality || !candidateCount)
|
|
108
|
+
throw new Error('candidate dispatch requires quality hooks and a candidate count');
|
|
109
|
+
const candidateIds = Array.from({ length: candidateCount }, (_, index) => `candidate-${index + 1}`);
|
|
110
|
+
const comparison = quality.candidateComparison(worker.story, candidateIds);
|
|
111
|
+
const reporter = workerReporter(input.reporter, worker);
|
|
112
|
+
return {
|
|
113
|
+
story: worker.story,
|
|
114
|
+
baseCommit: worker.worktree.baseCommit,
|
|
115
|
+
maxCandidates: candidateCount,
|
|
116
|
+
candidates: candidateDefinitions(input, worker, candidateCount, pause),
|
|
117
|
+
coordinatorId: `${worker.dispatcherId}:${worker.story.id}:${worker.ownerToken}`,
|
|
118
|
+
ownerToken: candidateId => `${worker.ownerToken}-${candidateId}`,
|
|
119
|
+
lifecycle: adapters.candidates(worker),
|
|
120
|
+
evidence: comparison.evidence,
|
|
121
|
+
stageEvidence: comparison.stageEvidence,
|
|
122
|
+
judgeProvenance: comparison.judgeProvenance,
|
|
123
|
+
onSelecting: () => reporter.phase('selecting-candidate'),
|
|
124
|
+
onLifecycle: (candidate, state, reason) => candidateReporter(input.reporter, worker, candidate, state, reason),
|
|
125
|
+
judge: request => comparison.judge(request),
|
|
126
|
+
recordResult: comparison.recordResult,
|
|
127
|
+
recordElimination: comparison.recordElimination,
|
|
128
|
+
recordSelection: comparison.recordSelection,
|
|
129
|
+
signal: worker.cancellation.signal,
|
|
130
|
+
pause,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function candidateDefinitions(input, worker, candidateCount, pause) {
|
|
134
|
+
const quality = input.quality;
|
|
135
|
+
return Array.from({ length: candidateCount }, (_, index) => {
|
|
136
|
+
const candidateId = `candidate-${index + 1}`;
|
|
137
|
+
const qualityRoundBase = (index + 1) * 10_000;
|
|
138
|
+
const runner = input.runner ?? asyncRunner(input, worker.provider, undefined, `${worker.ownerToken}-${candidateId}`);
|
|
139
|
+
return {
|
|
140
|
+
id: candidateId,
|
|
141
|
+
worker: {
|
|
142
|
+
provider: worker.provider,
|
|
143
|
+
runner,
|
|
144
|
+
verify: input.verify,
|
|
145
|
+
verifyCriterion: input.verifyCriterion,
|
|
146
|
+
requireCriterionEvidence: input.requireCriterionEvidence,
|
|
147
|
+
perf: input.perf,
|
|
148
|
+
audit: input.audit,
|
|
149
|
+
...(quality ? {
|
|
150
|
+
qualityPreflight: quality.qualityPreflight,
|
|
151
|
+
qualityStage: (context, round, attempt) => quality.qualityStage(context, qualityRoundBase + round, attempt),
|
|
152
|
+
repair: quality.repair,
|
|
153
|
+
repairLimits: quality.repairLimits,
|
|
154
|
+
qualityMetadata: quality.qualityMetadata,
|
|
155
|
+
qualityEnabled: quality.qualityEnabled,
|
|
156
|
+
} : {}),
|
|
157
|
+
review: input.review,
|
|
158
|
+
beforeGates: context => {
|
|
159
|
+
if (pause())
|
|
160
|
+
return 'candidate coordination paused';
|
|
161
|
+
const decision = consumeDecisionRequest(context.targetDir, input.targetDir, context.story.id);
|
|
162
|
+
if (!decision)
|
|
163
|
+
return null;
|
|
164
|
+
input.onCriticalDecision?.(decision);
|
|
165
|
+
return decision.question;
|
|
166
|
+
},
|
|
167
|
+
pause,
|
|
168
|
+
reporter: workerReporter(input.reporter, worker),
|
|
169
|
+
reporterFor: (candidate) => workerReporter(input.reporter, worker, candidate),
|
|
170
|
+
},
|
|
171
|
+
runnerFor: cancellation => input.runner ?? asyncRunner(input, worker.provider, cancellation.signal, `${worker.ownerToken}-${candidateId}`),
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
function workerReporter(reporter, worker, candidate) {
|
|
176
|
+
const attribution = {
|
|
177
|
+
story: worker.story.id,
|
|
178
|
+
storyTitle: worker.story.title,
|
|
179
|
+
provider: worker.provider.provider,
|
|
180
|
+
...(worker.provider.model ? { model: worker.provider.model } : {}),
|
|
181
|
+
...(candidate ? { candidateId: candidate.candidateId, worktree: candidate.worktree.path } : {}),
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
phase: phase => {
|
|
185
|
+
if (reporter.parallelWorker)
|
|
186
|
+
reporter.parallelWorker({ ...attribution, phase });
|
|
187
|
+
else
|
|
188
|
+
reporter.phase(phase);
|
|
189
|
+
},
|
|
190
|
+
quality: quality => {
|
|
191
|
+
if (reporter.parallelWorker)
|
|
192
|
+
reporter.parallelWorker({ ...attribution, quality });
|
|
193
|
+
else
|
|
194
|
+
reporter.quality(quality);
|
|
195
|
+
},
|
|
196
|
+
addTokens: usage => reporter.addTokens(usage),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function candidateReporter(reporter, worker, candidate, state, reason) {
|
|
200
|
+
if (!reporter.parallelWorker)
|
|
201
|
+
return;
|
|
202
|
+
reporter.parallelWorker({
|
|
203
|
+
story: worker.story.id,
|
|
204
|
+
storyTitle: worker.story.title,
|
|
205
|
+
provider: candidate.provider.provider,
|
|
206
|
+
...(candidate.provider.model ? { model: candidate.provider.model } : {}),
|
|
207
|
+
candidateId: candidate.candidateId,
|
|
208
|
+
worktree: candidate.worktree.path,
|
|
209
|
+
lifecycle: state,
|
|
210
|
+
...(reason ? { reason } : {}),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
function integratorReporter(reporter, worker) {
|
|
214
|
+
const attribution = {
|
|
215
|
+
story: worker.story.id,
|
|
216
|
+
storyTitle: worker.story.title,
|
|
217
|
+
provider: worker.provider.provider,
|
|
218
|
+
...(worker.provider.model ? { model: worker.provider.model } : {}),
|
|
219
|
+
};
|
|
220
|
+
return {
|
|
221
|
+
phase: phase => reportIntegrator(reporter, worker, phase),
|
|
222
|
+
quality: quality => {
|
|
223
|
+
if (reporter.parallelIntegrator)
|
|
224
|
+
reporter.parallelIntegrator({ ...attribution, quality });
|
|
225
|
+
else
|
|
226
|
+
reporter.quality(quality);
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function reportIntegrator(reporter, worker, phase) {
|
|
231
|
+
if (reporter.parallelIntegrator) {
|
|
232
|
+
reporter.parallelIntegrator(phase
|
|
233
|
+
? { story: worker.story.id, storyTitle: worker.story.title, provider: worker.provider.provider, ...(worker.provider.model ? { model: worker.provider.model } : {}), phase }
|
|
234
|
+
: null);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (phase)
|
|
238
|
+
reporter.phase(phase);
|
|
239
|
+
}
|
|
240
|
+
function asyncRunner(input, provider, signal, workerId) {
|
|
241
|
+
const { model: _globalModel, reasoningEffort: _globalReasoningEffort, ...selection } = input.selection;
|
|
242
|
+
const runner = makeAsyncRunner(provider.provider, {
|
|
243
|
+
onAmbiguity: input.onAmbiguity,
|
|
244
|
+
permissions: input.permissions,
|
|
245
|
+
selection: {
|
|
246
|
+
...selection,
|
|
247
|
+
...(provider.model ? { model: provider.model } : {}),
|
|
248
|
+
...(provider.reasoningEffort ? { reasoningEffort: provider.reasoningEffort } : {}),
|
|
249
|
+
},
|
|
250
|
+
process: { idleTimeoutMs: input.idleMs, signal, workerId },
|
|
251
|
+
});
|
|
252
|
+
return async (context) => providerProcessResultToAgentResult(provider.provider, context.story.id, await runner(context).completion);
|
|
253
|
+
}
|
|
254
|
+
export function providerProcessResultToAgentResult(agent, storyId, result) {
|
|
255
|
+
const tokens = result.telemetry.tokens;
|
|
256
|
+
const telemetry = tokens ? { tokens } : {};
|
|
257
|
+
switch (result.kind) {
|
|
258
|
+
case 'succeeded': return { success: true, summary: `${agent} implemented ${storyId}`, ...telemetry };
|
|
259
|
+
case 'cancelled': return { success: false, summary: result.reason, ...telemetry };
|
|
260
|
+
case 'timed-out': return { success: false, summary: result.reason, ...telemetry };
|
|
261
|
+
case 'spawn-failed': return { success: false, summary: result.error, ...telemetry };
|
|
262
|
+
case 'failed': return { success: false, summary: `${agent} exited ${result.exitCode ?? 'without a code'}`, ...telemetry };
|
|
263
|
+
default: return assertNever(result);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function reviewGate(review, path, story) {
|
|
267
|
+
if (!review)
|
|
268
|
+
return { passed: true, summary: 'integrated gates passed' };
|
|
269
|
+
const result = review({ targetDir: path, story });
|
|
270
|
+
return { passed: result.success && result.reviewOutcome?.kind !== 'rejected', summary: result.summary };
|
|
271
|
+
}
|
|
272
|
+
function assertNever(value) { throw new Error(`unexpected provider result: ${String(value)}`); }
|
|
273
|
+
function integrationGate(input, path, story, reporter) {
|
|
274
|
+
const context = { targetDir: path, story };
|
|
275
|
+
const qualityEnabled = input.quality?.qualityEnabled(story) ?? false;
|
|
276
|
+
if (qualityEnabled)
|
|
277
|
+
reporter.phase('comparing');
|
|
278
|
+
const preflight = qualityEnabled ? input.quality?.qualityPreflight(context) : undefined;
|
|
279
|
+
if (preflight?.kind === 'blocked')
|
|
280
|
+
return { passed: false, summary: preflight.summary };
|
|
281
|
+
const quality = qualityEnabled ? input.quality?.qualityStage(context, 10_000, 'integration') : undefined;
|
|
282
|
+
if (quality && quality.kind !== 'pass' && quality.kind !== 'skipped')
|
|
283
|
+
return { passed: false, summary: quality.summary };
|
|
284
|
+
if (input.review)
|
|
285
|
+
reporter.phase('reviewing');
|
|
286
|
+
return reviewGate(input.review, path, story);
|
|
287
|
+
}
|
package/dist/loop/parallel.js
CHANGED
|
@@ -12,10 +12,8 @@ export async function runParallelLoop(stories, opts) {
|
|
|
12
12
|
activeAreas.add(story.area);
|
|
13
13
|
const affinity = story.agent ?? (opts.agents?.length ? opts.agents[agentIndex++ % opts.agents.length] : undefined);
|
|
14
14
|
const promise = opts.worker(story, affinity).then(result => {
|
|
15
|
-
if (result.success)
|
|
16
|
-
story.passes = true;
|
|
15
|
+
if (result.success)
|
|
17
16
|
completed.push(story.id);
|
|
18
|
-
}
|
|
19
17
|
else
|
|
20
18
|
failed.push(story.id);
|
|
21
19
|
}).catch(() => { failed.push(story.id); }).finally(() => {
|
|
@@ -27,7 +25,7 @@ export async function runParallelLoop(stories, opts) {
|
|
|
27
25
|
};
|
|
28
26
|
while (iterations < opts.maxIterations && !opts.paused?.()) {
|
|
29
27
|
const slots = Math.max(0, opts.maxConcurrency - active.size);
|
|
30
|
-
const ready = readyStories(stories, { activeAreas }).filter(s => !active.has(s.id) && !failed.includes(s.id)).slice(0, slots);
|
|
28
|
+
const ready = readyStories(stories, { activeAreas }).filter(s => !active.has(s.id) && !completed.includes(s.id) && !failed.includes(s.id)).slice(0, slots);
|
|
31
29
|
for (const story of ready)
|
|
32
30
|
launch(story);
|
|
33
31
|
if (active.size === 0)
|
package/dist/loop/prd.js
CHANGED
|
@@ -1,19 +1,80 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
3
|
import { parse, stringify } from 'yaml';
|
|
3
4
|
import { z } from 'zod';
|
|
5
|
+
import { StoryQualityDeclarationSchema } from '../quality/types.js';
|
|
6
|
+
export const AcceptanceCriterionSchema = z.object({
|
|
7
|
+
id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
|
|
8
|
+
text: z.string().min(1),
|
|
9
|
+
verify: z.array(z.string().min(1)).min(1),
|
|
10
|
+
});
|
|
11
|
+
export function criterionCommandProblem(criterion) {
|
|
12
|
+
const normalizedId = criterion.id.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
13
|
+
for (const raw of criterion.verify) {
|
|
14
|
+
const command = raw.trim();
|
|
15
|
+
if (/(?:[&;|<>`\r\n]|\$\()/.test(command)) {
|
|
16
|
+
return `criterion ${criterion.id} must use one single test command without shell control operators`;
|
|
17
|
+
}
|
|
18
|
+
const isTestCommand = /^(?:(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+(?:test|check|verify)(?::[A-Za-z0-9._-]+)?|npx\s+(?:vitest|jest|playwright|cypress)|(?:vitest|jest|pytest|phpunit)|python\s+-m\s+pytest|cargo\s+test|go\s+test|dotnet\s+test|mvn(?:w)?\s+test|gradle(?:w)?\s+test|bundle\s+exec\s+(?:rspec|rails\s+test)|bin\/rails\s+test|vendor\/bin\/phpunit|mix\s+test|swift\s+test|xcodebuild\s+test)(?:\s|$)/i.test(command);
|
|
19
|
+
if (!isTestCommand)
|
|
20
|
+
return `criterion ${criterion.id} must use an approved test command`;
|
|
21
|
+
const normalizedCommand = command.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
22
|
+
if (!normalizedCommand.includes(normalizedId)) {
|
|
23
|
+
return `criterion ${criterion.id} must target its criterion id in every verify command`;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
4
28
|
export const StorySchema = z.object({
|
|
29
|
+
// Existing projects may already use human-readable IDs. Keep loading them;
|
|
30
|
+
// every filesystem use goes through storyPathSegment instead.
|
|
5
31
|
id: z.string().min(1),
|
|
6
32
|
title: z.string().min(1),
|
|
7
33
|
priority: z.number(),
|
|
8
|
-
|
|
34
|
+
// String criteria remain readable for existing projects. New strict projects
|
|
35
|
+
// use structured criteria so Yoke can execute proof for each outcome instead
|
|
36
|
+
// of trusting an unrelated green test suite.
|
|
37
|
+
acceptance: z.array(z.union([z.string().min(1), AcceptanceCriterionSchema])),
|
|
9
38
|
passes: z.boolean(),
|
|
10
39
|
needs: z.array(z.string().min(1)).optional(),
|
|
11
40
|
area: z.string().min(1).optional(),
|
|
12
41
|
agent: z.enum(['claude', 'codex', 'gemini']).optional(),
|
|
42
|
+
/** Inbox request that created this story. Used for idempotent append-only intake. */
|
|
43
|
+
sourceChange: z.string().min(1).optional(),
|
|
44
|
+
quality: StoryQualityDeclarationSchema.optional(),
|
|
45
|
+
}).superRefine((story, ctx) => {
|
|
46
|
+
const structured = story.acceptance.filter(isAcceptanceCriterion);
|
|
47
|
+
const ids = structured.map(criterion => criterion.id);
|
|
48
|
+
if (structured.length > 0 && (structured.length < 2 || structured.length > 5)) {
|
|
49
|
+
ctx.addIssue({
|
|
50
|
+
code: 'custom',
|
|
51
|
+
path: ['acceptance'],
|
|
52
|
+
message: 'structured stories must have 2-5 structured acceptance criteria',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (new Set(ids).size !== ids.length) {
|
|
56
|
+
ctx.addIssue({
|
|
57
|
+
code: 'custom',
|
|
58
|
+
path: ['acceptance'],
|
|
59
|
+
message: 'structured criterion ids must be unique within a story',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
13
62
|
});
|
|
63
|
+
export function storyPathSegment(id) {
|
|
64
|
+
return `story-${createHash('sha256').update(id, 'utf8').digest('hex')}`;
|
|
65
|
+
}
|
|
66
|
+
export function isAcceptanceCriterion(value) {
|
|
67
|
+
return typeof value !== 'string';
|
|
68
|
+
}
|
|
69
|
+
export function acceptanceText(value) {
|
|
70
|
+
return typeof value === 'string' ? value : value.text;
|
|
71
|
+
}
|
|
14
72
|
const PrdSchema = z.array(StorySchema);
|
|
73
|
+
export function parsePrd(file) {
|
|
74
|
+
return PrdSchema.parse(parse(readFileSync(file, 'utf8')));
|
|
75
|
+
}
|
|
15
76
|
export function loadPrd(file) {
|
|
16
|
-
const stories =
|
|
77
|
+
const stories = parsePrd(file);
|
|
17
78
|
const issues = validateDependencies(stories);
|
|
18
79
|
if (issues.length)
|
|
19
80
|
throw new Error(`Invalid PRD dependency graph:\n${issues.join('\n')}`);
|
package/dist/loop/reporter.js
CHANGED
|
@@ -143,20 +143,86 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
143
143
|
},
|
|
144
144
|
blocked(reason) {
|
|
145
145
|
const base = current ?? emptyStatus(now().toISOString());
|
|
146
|
-
persist({ ...base, state: 'blocked', reason, updatedAt: now().toISOString() }, 'blocked', `■ blocked on ${base.story ?? '?'}: ${reason}`);
|
|
146
|
+
persist({ ...withoutParallel(base), state: 'blocked', reason, updatedAt: now().toISOString() }, 'blocked', `■ blocked on ${base.story ?? '?'}: ${reason}`);
|
|
147
147
|
},
|
|
148
148
|
complete(progress) {
|
|
149
|
-
persist({ ...(current ?? emptyStatus(now().toISOString())), state: 'complete', phase: undefined,
|
|
149
|
+
persist({ ...withoutParallel(current ?? emptyStatus(now().toISOString())), state: 'complete', phase: undefined,
|
|
150
150
|
progress, reason: undefined, updatedAt: now().toISOString() }, 'complete', `✔ loop complete — ${progress.passed}/${progress.total}`);
|
|
151
151
|
},
|
|
152
152
|
capReached(progress) {
|
|
153
|
-
persist({ ...(current ?? emptyStatus(now().toISOString())), state: 'cap-reached', phase: undefined,
|
|
153
|
+
persist({ ...withoutParallel(current ?? emptyStatus(now().toISOString())), state: 'cap-reached', phase: undefined,
|
|
154
154
|
progress, updatedAt: now().toISOString() }, 'cap-reached', `◾ iteration cap reached — ${progress.passed}/${progress.total}`);
|
|
155
155
|
},
|
|
156
156
|
paused(progress) {
|
|
157
|
-
persist({ ...(current ?? emptyStatus(now().toISOString())), state: 'paused', phase: undefined,
|
|
157
|
+
persist({ ...withoutParallel(current ?? emptyStatus(now().toISOString())), state: 'paused', phase: undefined,
|
|
158
158
|
progress, updatedAt: now().toISOString() }, 'paused', `⏸ loop paused — ${progress.passed}/${progress.total}`);
|
|
159
159
|
},
|
|
160
|
+
quality(status) {
|
|
161
|
+
const base = current ?? emptyStatus(now().toISOString());
|
|
162
|
+
persist({ ...base, quality: status, updatedAt: now().toISOString() }, 'quality', ` · quality round ${status.currentRound}…`);
|
|
163
|
+
},
|
|
164
|
+
parallel(status) {
|
|
165
|
+
const base = current ?? emptyStatus(now().toISOString());
|
|
166
|
+
const previousWorkers = base.parallel?.workers ?? [];
|
|
167
|
+
const previousIntegrator = base.parallel?.integrator;
|
|
168
|
+
const workers = status.workers?.map(worker => {
|
|
169
|
+
const previous = previousWorkers.find(candidate => sameWorker(candidate, worker));
|
|
170
|
+
return previous
|
|
171
|
+
? {
|
|
172
|
+
...worker,
|
|
173
|
+
...(previous.phase ? { phase: previous.phase } : {}),
|
|
174
|
+
...(previous.quality ? { quality: previous.quality } : {}),
|
|
175
|
+
}
|
|
176
|
+
: worker;
|
|
177
|
+
});
|
|
178
|
+
const reportedCandidates = previousWorkers.filter(worker => worker.candidateId && !(workers ?? []).some(candidate => sameWorker(candidate, worker)));
|
|
179
|
+
const parallel = {
|
|
180
|
+
...status,
|
|
181
|
+
...(workers !== undefined
|
|
182
|
+
? { workers: [...workers, ...reportedCandidates] }
|
|
183
|
+
: previousWorkers.length > 0 ? { workers: previousWorkers } : {}),
|
|
184
|
+
...(status.integrator !== undefined
|
|
185
|
+
? { integrator: status.integrator }
|
|
186
|
+
: previousIntegrator ? { integrator: previousIntegrator } : {}),
|
|
187
|
+
};
|
|
188
|
+
persist({
|
|
189
|
+
...base,
|
|
190
|
+
iteration: status.iteration ?? base.iteration,
|
|
191
|
+
progress: status.progress ?? base.progress,
|
|
192
|
+
parallel,
|
|
193
|
+
updatedAt: now().toISOString(),
|
|
194
|
+
}, 'parallel', ` · parallel ${status.activeWorkers}/${status.maxConcurrency}`);
|
|
195
|
+
},
|
|
196
|
+
parallelWorker(status) {
|
|
197
|
+
const base = current;
|
|
198
|
+
const parallel = base?.parallel;
|
|
199
|
+
if (!base || !parallel?.workers)
|
|
200
|
+
return;
|
|
201
|
+
persist({
|
|
202
|
+
...base,
|
|
203
|
+
parallel: {
|
|
204
|
+
...parallel,
|
|
205
|
+
workers: upsertWorker(parallel.workers, status),
|
|
206
|
+
},
|
|
207
|
+
updatedAt: now().toISOString(),
|
|
208
|
+
}, 'parallel-worker', ` · ${status.story} ${status.phase ?? 'quality'}…`);
|
|
209
|
+
},
|
|
210
|
+
parallelIntegrator(status) {
|
|
211
|
+
const base = current;
|
|
212
|
+
const parallel = base?.parallel;
|
|
213
|
+
if (!base || !parallel)
|
|
214
|
+
return;
|
|
215
|
+
if (status) {
|
|
216
|
+
persist({
|
|
217
|
+
...base,
|
|
218
|
+
parallel: { ...parallel, integrator: status },
|
|
219
|
+
updatedAt: now().toISOString(),
|
|
220
|
+
}, 'parallel-integrator', ` · integrating ${status.story} ${status.phase ?? 'quality'}…`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const { integrator: _integrator, ...withoutIntegrator } = parallel;
|
|
224
|
+
persist({ ...base, parallel: withoutIntegrator, updatedAt: now().toISOString() }, 'parallel-integrator', ' · integration complete');
|
|
225
|
+
},
|
|
160
226
|
addTokens(usage) {
|
|
161
227
|
const model = usage.model ?? tokens?.model;
|
|
162
228
|
const cachedInputTokens = (tokens?.cachedInputTokens ?? 0) + (usage.cachedInputTokens ?? 0);
|
|
@@ -179,6 +245,21 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
179
245
|
function emptyStatus(ts) {
|
|
180
246
|
return { state: 'running', iteration: 0, progress: { passed: 0, total: 0 }, startedAt: ts, updatedAt: ts };
|
|
181
247
|
}
|
|
248
|
+
function sameWorker(left, right) {
|
|
249
|
+
return left.candidateId || right.candidateId
|
|
250
|
+
? left.story === right.story && left.candidateId === right.candidateId && left.worktree === right.worktree
|
|
251
|
+
: left.story === right.story;
|
|
252
|
+
}
|
|
253
|
+
function upsertWorker(workers, status) {
|
|
254
|
+
const index = workers.findIndex(worker => sameWorker(worker, status));
|
|
255
|
+
return index < 0
|
|
256
|
+
? [...workers, status]
|
|
257
|
+
: workers.map((worker, workerIndex) => workerIndex === index ? { ...worker, ...status } : worker);
|
|
258
|
+
}
|
|
259
|
+
function withoutParallel(status) {
|
|
260
|
+
const { parallel: _parallel, ...withoutParallel } = status;
|
|
261
|
+
return withoutParallel;
|
|
262
|
+
}
|
|
182
263
|
export const noopReporter = {
|
|
183
|
-
storyStart() { }, storyDone() { }, phase() { }, blocked() { }, complete() { }, capReached() { }, paused() { }, addTokens() { },
|
|
264
|
+
storyStart() { }, storyDone() { }, phase() { }, blocked() { }, complete() { }, capReached() { }, paused() { }, quality() { }, addTokens() { },
|
|
184
265
|
};
|