@hecer/yoke 1.3.0 → 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 (59) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +22 -0
  4. package/README.md +67 -15
  5. package/TODOS.md +0 -3
  6. package/canon/loop/loop-spec.md +22 -8
  7. package/canon/manifest.yaml +1 -1
  8. package/dist/agents/contracts.js +50 -0
  9. package/dist/agents/process-incarnation.js +15 -0
  10. package/dist/agents/process-record.js +65 -0
  11. package/dist/agents/process-streams.js +40 -0
  12. package/dist/agents/process.js +177 -0
  13. package/dist/agents/providers.js +10 -7
  14. package/dist/agents/telemetry.js +62 -0
  15. package/dist/cli.js +55 -3
  16. package/dist/loop/candidate-boundaries.js +43 -0
  17. package/dist/loop/candidate-cleanup.js +98 -0
  18. package/dist/loop/candidate-contracts.js +1 -0
  19. package/dist/loop/candidate-selection.js +84 -0
  20. package/dist/loop/candidates.js +228 -0
  21. package/dist/loop/claim-lease.js +131 -0
  22. package/dist/loop/claims.js +177 -40
  23. package/dist/loop/cleanup.js +117 -15
  24. package/dist/loop/decision.js +31 -0
  25. package/dist/loop/dispatcher.js +334 -0
  26. package/dist/loop/loop.js +109 -16
  27. package/dist/loop/merge-queue.js +12 -6
  28. package/dist/loop/parallel-adapters.js +185 -0
  29. package/dist/loop/parallel-command.js +287 -0
  30. package/dist/loop/parallel.js +2 -4
  31. package/dist/loop/prd.js +4 -1
  32. package/dist/loop/reporter.js +86 -5
  33. package/dist/loop/run-command.js +204 -51
  34. package/dist/loop/runner.js +67 -32
  35. package/dist/loop/watchdog.js +67 -8
  36. package/dist/loop/worker-cancellation.js +17 -0
  37. package/dist/loop/worker-cleanup.js +23 -0
  38. package/dist/loop/worker-contracts.js +1 -0
  39. package/dist/loop/worker.js +254 -0
  40. package/dist/quality/artifacts.js +59 -0
  41. package/dist/quality/candidate-comparison.js +130 -0
  42. package/dist/quality/command.js +316 -0
  43. package/dist/quality/loop.js +86 -0
  44. package/dist/quality/process-command.js +57 -0
  45. package/dist/quality/reference.js +187 -0
  46. package/dist/quality/repair.js +11 -0
  47. package/dist/quality/runner.js +66 -0
  48. package/dist/quality/types.js +60 -0
  49. package/dist/quality/verdict.js +142 -0
  50. package/dist/retrofit/config.js +4 -2
  51. package/dist/retrofit/gitignore.js +3 -0
  52. package/dist/review/command.js +27 -38
  53. package/dist/review/verdict.js +38 -7
  54. package/docs/MIGRATING-TO-1.4.md +70 -0
  55. package/docs/PUBLISHING.md +16 -2
  56. package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -0
  57. package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
  58. package/gemini-extension.json +1 -1
  59. package/package.json +1 -1
@@ -0,0 +1,185 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { isProviderTreeAlive, reapProviderProcesses } from './cleanup.js';
5
+ import { realGitOps } from './git.js';
6
+ import { isPidAlive } from './lock.js';
7
+ import { storyPathSegment } from './prd.js';
8
+ import { killProcessTreeForCleanup } from './watchdog.js';
9
+ export function makeParallelAdapters(targetDir, identity, injectedGit) {
10
+ return injectedGit
11
+ ? injectedAdapters(targetDir, identity, injectedGit)
12
+ : productionAdapters(targetDir, identity);
13
+ }
14
+ function injectedAdapters(targetDir, identity, git) {
15
+ const owned = new Set();
16
+ const removed = new Set();
17
+ return {
18
+ worktrees: {
19
+ create: input => {
20
+ const path = worktreePath(targetDir, input);
21
+ git.addWorktree(targetDir, path);
22
+ owned.add(path);
23
+ return { path, baseCommit: 'injected-base' };
24
+ },
25
+ cleanupProcess: cleanupProviderProcesses,
26
+ remove: input => removeOwnedWorktree(targetDir, input, owned, removed, git),
27
+ },
28
+ git: {
29
+ isClean: dir => git.isClean(dir),
30
+ rebase: input => ({ kind: 'rebased', expectedHead: input.worktree.baseCommit }),
31
+ commit: input => git.commitAll(input.worktree.path, `yoke: complete ${input.story.id} ${input.story.title}`, identity),
32
+ integrate: input => git.integrate(targetDir, input.worktree.path),
33
+ },
34
+ candidates: primary => makeCandidateLifecycle(targetDir, primary, owned, removed, git),
35
+ };
36
+ }
37
+ function productionAdapters(targetDir, identity) {
38
+ const owned = new Set();
39
+ const removed = new Set();
40
+ return {
41
+ worktrees: {
42
+ create: input => {
43
+ const path = worktreePath(targetDir, input);
44
+ const baseCommit = gitText(targetDir, ['rev-parse', 'HEAD']);
45
+ mkdirSync(dirname(path), { recursive: true });
46
+ realGitOps.addWorktree(targetDir, path);
47
+ owned.add(path);
48
+ return { path, baseCommit };
49
+ },
50
+ cleanupProcess: cleanupProviderProcesses,
51
+ remove: input => removeOwnedWorktree(targetDir, input, owned, removed, realGitOps),
52
+ },
53
+ git: {
54
+ isClean: dir => realGitOps.isClean(dir),
55
+ rebase: input => rebaseCandidate(targetDir, input),
56
+ commit: input => realGitOps.commitAll(input.worktree.path, `yoke: complete ${input.story.id} ${input.story.title}`, identity),
57
+ integrate: (input, expectedHead) => integrateCandidate(targetDir, input, expectedHead),
58
+ },
59
+ candidates: primary => makeCandidateLifecycle(targetDir, primary, owned, removed, realGitOps),
60
+ };
61
+ }
62
+ function cleanupProviderProcesses(input) {
63
+ reapProviderProcesses(input.worktree.path, isPidAlive, isProviderTreeAlive, killProcessTreeForCleanup);
64
+ }
65
+ function makeCandidateLifecycle(targetDir, primary, owned, removed, git) {
66
+ return {
67
+ reserve: input => {
68
+ const worktree = input.candidateId === 'candidate-1'
69
+ ? primary.worktree
70
+ : { path: candidateWorktreePath(targetDir, input), baseCommit: primary.worktree.baseCommit };
71
+ writeCandidateStatus(targetDir, input, worktree.path, 'reserved');
72
+ return worktree;
73
+ },
74
+ materialize: ownership => {
75
+ if (ownership.worktree.path !== primary.worktree.path) {
76
+ mkdirSync(dirname(ownership.worktree.path), { recursive: true });
77
+ git.addWorktree(targetDir, ownership.worktree.path);
78
+ owned.add(ownership.worktree.path);
79
+ }
80
+ writeCandidateStatus(targetDir, ownership, ownership.worktree.path, 'materialized');
81
+ },
82
+ cancel: (ownership, reason) => {
83
+ writeCandidateStatus(targetDir, ownership, ownership.worktree.path, 'cleaning', reason);
84
+ },
85
+ reap: ownership => {
86
+ reapProviderProcesses(ownership.worktree.path, isPidAlive, isProviderTreeAlive, killProcessTreeForCleanup);
87
+ },
88
+ remove: ownership => {
89
+ removeOwnedWorktree(targetDir, ownership, owned, removed, git);
90
+ writeCandidateStatus(targetDir, ownership, ownership.worktree.path, 'removed');
91
+ },
92
+ };
93
+ }
94
+ function worktreePath(targetDir, input) {
95
+ return join(targetDir, '.yoke', 'worktrees', `${storyPathSegment(input.story.id)}-${input.ownerToken}`);
96
+ }
97
+ function removeOwnedWorktree(targetDir, input, owned, removed, git) {
98
+ if (removed.has(input.worktree.path))
99
+ return;
100
+ if (!owned.has(input.worktree.path)) {
101
+ throw new Error(`refusing to remove a worktree not created by this dispatcher: ${input.worktree.path}`);
102
+ }
103
+ git.removeWorktree(targetDir, input.worktree.path);
104
+ owned.delete(input.worktree.path);
105
+ removed.add(input.worktree.path);
106
+ }
107
+ function candidateWorktreePath(targetDir, input) {
108
+ return join(targetDir, '.yoke', 'worktrees', `${storyPathSegment(input.storyId)}-${input.ownerToken}`);
109
+ }
110
+ function writeCandidateStatus(targetDir, input, worktree, state, reason) {
111
+ const file = join(targetDir, '.yoke', 'proof', storyPathSegment(input.storyId), 'candidates', input.candidateId, 'status.json');
112
+ mkdirSync(dirname(file), { recursive: true });
113
+ writeFileSync(file, JSON.stringify({ storyId: input.storyId, candidateId: input.candidateId, worktree, state, ...(reason ? { reason } : {}) }));
114
+ }
115
+ function rebaseCandidate(targetDir, input) {
116
+ const currentHead = gitText(targetDir, ['rev-parse', 'HEAD']);
117
+ const candidateHead = gitText(input.worktree.path, ['rev-parse', 'HEAD']);
118
+ try {
119
+ execFileSync('git', ['merge-base', '--is-ancestor', input.worktree.baseCommit, 'HEAD'], { cwd: input.worktree.path, stdio: 'pipe' });
120
+ }
121
+ catch {
122
+ return { kind: 'reopen', reason: `candidate history no longer descends from base ${input.worktree.baseCommit}` };
123
+ }
124
+ if (currentHead !== input.worktree.baseCommit) {
125
+ try {
126
+ execFileSync('git', ['merge-base', '--is-ancestor', input.worktree.baseCommit, currentHead], { cwd: targetDir, stdio: 'pipe' });
127
+ }
128
+ catch {
129
+ return { kind: 'reopen', reason: `candidate base ${input.worktree.baseCommit} is stale against target ${currentHead}` };
130
+ }
131
+ let candidateTree;
132
+ try {
133
+ execFileSync('git', ['add', '-A'], { cwd: input.worktree.path, stdio: 'pipe' });
134
+ const unstaged = spawnSync('git', ['diff', '--quiet', '--ignore-submodules=none'], { cwd: input.worktree.path, stdio: 'pipe' });
135
+ if (unstaged.error || unstaged.status !== 0)
136
+ return { kind: 'reopen', reason: 'candidate contains changes Git cannot snapshot' };
137
+ candidateTree = gitText(input.worktree.path, ['write-tree']);
138
+ }
139
+ catch (error) {
140
+ return { kind: 'reopen', reason: `candidate snapshot failed: ${errorMessage(error)}` };
141
+ }
142
+ const merge = spawnSync('git', ['merge-tree', '--write-tree', '--messages', '--name-only', `--merge-base=${input.worktree.baseCommit}`, currentHead, candidateTree], {
143
+ cwd: input.worktree.path,
144
+ encoding: 'utf8',
145
+ });
146
+ const mergedTree = merge.stdout.trim().split(/\r?\n/u)[0] ?? '';
147
+ if (merge.error || merge.status !== 0 || !/^[0-9a-f]{40,64}$/u.test(mergedTree)) {
148
+ const detail = [merge.stderr, merge.stdout].map(value => value.trim()).filter(Boolean).join(' · ');
149
+ return { kind: 'reopen', reason: `candidate tree merge failed${detail ? `: ${detail}` : ''}` };
150
+ }
151
+ try {
152
+ execFileSync('git', ['reset', '--soft', currentHead], { cwd: input.worktree.path, stdio: 'pipe' });
153
+ execFileSync('git', ['read-tree', '--reset', '-u', mergedTree], { cwd: input.worktree.path, stdio: 'pipe' });
154
+ const unstaged = spawnSync('git', ['diff', '--quiet', '--ignore-submodules=none'], { cwd: input.worktree.path, stdio: 'pipe' });
155
+ if (unstaged.error || unstaged.status !== 0 || gitText(input.worktree.path, ['ls-files', '--unmerged']) || gitText(input.worktree.path, ['write-tree']) !== mergedTree) {
156
+ throw new Error('materialized candidate tree did not match the computed merge');
157
+ }
158
+ }
159
+ catch (error) {
160
+ try {
161
+ execFileSync('git', ['reset', '--soft', candidateHead], { cwd: input.worktree.path, stdio: 'pipe' });
162
+ execFileSync('git', ['read-tree', '--reset', '-u', candidateTree], { cwd: input.worktree.path, stdio: 'pipe' });
163
+ }
164
+ catch { }
165
+ return { kind: 'reopen', reason: `candidate tree materialization failed: ${errorMessage(error)}` };
166
+ }
167
+ }
168
+ // Preserve the worker's candidate tree, but return commit authority to the dispatcher.
169
+ execFileSync('git', ['reset', '--soft', currentHead], { cwd: input.worktree.path, stdio: 'pipe' });
170
+ return { kind: 'rebased', expectedHead: currentHead };
171
+ }
172
+ function integrateCandidate(targetDir, input, expectedHead) {
173
+ if (!realGitOps.isClean(targetDir))
174
+ throw new Error('target working tree is not clean before integration');
175
+ const currentHead = gitText(targetDir, ['rev-parse', 'HEAD']);
176
+ if (currentHead !== expectedHead)
177
+ throw new Error(`target HEAD changed from ${expectedHead} to ${currentHead} during integrated gates`);
178
+ realGitOps.integrate(targetDir, input.worktree.path);
179
+ }
180
+ function gitText(dir, args) {
181
+ return execFileSync('git', args, { cwd: dir, stdio: 'pipe' }).toString().trim();
182
+ }
183
+ function errorMessage(error) {
184
+ return error instanceof Error ? error.message : String(error);
185
+ }
@@ -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
+ }
@@ -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,6 +1,8 @@
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';
4
6
  export const AcceptanceCriterionSchema = z.object({
5
7
  id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
6
8
  text: z.string().min(1),
@@ -39,6 +41,7 @@ export const StorySchema = z.object({
39
41
  agent: z.enum(['claude', 'codex', 'gemini']).optional(),
40
42
  /** Inbox request that created this story. Used for idempotent append-only intake. */
41
43
  sourceChange: z.string().min(1).optional(),
44
+ quality: StoryQualityDeclarationSchema.optional(),
42
45
  }).superRefine((story, ctx) => {
43
46
  const structured = story.acceptance.filter(isAcceptanceCriterion);
44
47
  const ids = structured.map(criterion => criterion.id);
@@ -58,7 +61,7 @@ export const StorySchema = z.object({
58
61
  }
59
62
  });
60
63
  export function storyPathSegment(id) {
61
- return `story-${Buffer.from(id, 'utf8').toString('base64url')}`;
64
+ return `story-${createHash('sha256').update(id, 'utf8').digest('hex')}`;
62
65
  }
63
66
  export function isAcceptanceCriterion(value) {
64
67
  return typeof value !== 'string';
@@ -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
  };