@hecer/yoke 1.16.0 → 1.18.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.
@@ -1,55 +1,118 @@
1
1
  import { estimateDurations } from './durations.js';
2
2
  import { criticalPathRanks, writeScopesOverlap } from '../loop/scheduler.js';
3
- /** Simulate the same dependency/area constraints as the loop scheduler. */
4
- export function estimateSchedule(stories, maxConcurrency, history) {
3
+ /** Simulate dependency-aware implementation slots and a separate serialized integration lane. */
4
+ export function estimateSchedule(stories, maxConcurrency, history, stages = {}) {
5
5
  if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1)
6
6
  return { available: false, reason: 'Invalid concurrency' };
7
7
  if (new Set(stories.map(story => story.id)).size !== stories.length)
8
8
  return { available: false, reason: 'Duplicate story IDs' };
9
- const estimate = estimateDurations(history.map(item => item.ms));
10
- if (!estimate)
9
+ const totalEstimate = estimateDurations(history.map(item => item.ms));
10
+ if (!totalEstimate)
11
11
  return { available: false, reason: 'No measured duration history' };
12
+ const integrationEstimate = estimateDurations((stages.integrationMs ?? []).filter(validSample));
13
+ const queueEstimate = estimateDurations((stages.integrationQueueWaitMs ?? []).filter(validSample));
14
+ const integrations = integrationEstimate ?? { typicalMs: 0, lowerMs: 0, upperMs: 0, sampleCount: 0, confidence: 'low' };
15
+ const queueWait = queueEstimate ?? { typicalMs: 0, lowerMs: 0, upperMs: 0, sampleCount: 0, confidence: 'low' };
16
+ const globalLimit = Number.isInteger(stages.globalLimit) && stages.globalLimit >= 1 ? stages.globalLimit : maxConcurrency;
17
+ const workerUnitsPerStory = Number.isInteger(stages.workerUnitsPerStory) && stages.workerUnitsPerStory >= 1 ? stages.workerUnitsPerStory : 1;
18
+ if (workerUnitsPerStory > globalLimit)
19
+ return { available: false, reason: 'Worker weight exceeds the shared resource limit' };
12
20
  const ranks = criticalPathRanks(stories);
13
- const durations = new Map(stories.filter(story => !story.passes).map(story => [story.id,
14
- estimateDurations(history.filter(item => item.storyId === story.id).map(item => item.ms)) ?? estimate,
15
- ]));
16
- const simulate = (field) => {
17
- const pending = stories.filter(story => !story.passes).sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0) || (ranks.get(b.id) ?? 0) - (ranks.get(a.id) ?? 0) || a.id.localeCompare(b.id));
21
+ const pending = stories.filter(story => !story.passes).sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0) || (ranks.get(b.id) ?? 0) - (ranks.get(a.id) ?? 0) || a.id.localeCompare(b.id));
22
+ const simulate = (implementationScale, integrationScale) => {
23
+ const pendingWork = [...pending];
24
+ const work = new Map(pending.map(story => {
25
+ const estimate = estimateDurations(history.filter(item => item.storyId === story.id).map(item => item.ms)) ?? totalEstimate;
26
+ const subtract = integrationEstimate ? integrations[integrationScale] + queueWait[integrationScale] : 0;
27
+ return [story.id, Math.max(0, estimate[implementationScale] - subtract)];
28
+ }));
29
+ const integrationDuration = integrations[integrationScale];
18
30
  const complete = new Set(stories.filter(story => story.passes).map(story => story.id));
19
31
  const active = [];
20
- const tasks = [];
21
- let time = 0;
22
- while (pending.length || active.length) {
23
- for (let index = 0; index < pending.length && active.length < maxConcurrency;) {
24
- const story = pending[index];
25
- if ((story.needs ?? []).every(id => complete.has(id)) && (!story.area || !active.some(item => item.story.area === story.area)) && !active.some(item => writeScopesOverlap(story.writes, item.story.writes))) {
26
- const end = time + durations.get(story.id)[field];
27
- active.push({ story, end });
28
- tasks.push({ storyId: story.id, startMs: time, endMs: end });
29
- pending.splice(index, 1);
32
+ const queued = [];
33
+ let integrating;
34
+ const taskTimes = new Map();
35
+ let time = 0, implementationWorkMs = 0, integrationQueueWaitMs = 0, integrationLaneMs = 0;
36
+ let completedEvents = 0;
37
+ while (pendingWork.length || active.length || queued.length || integrating) {
38
+ for (let index = 0; index < pendingWork.length && active.length < maxConcurrency && (active.length * workerUnitsPerStory) + (integrating ? 1 : 0) + workerUnitsPerStory <= globalLimit;) {
39
+ const story = pendingWork[index];
40
+ const occupied = [...active.map(item => item.story), ...queued.map(item => item.story), ...(integrating ? [integrating.story] : [])];
41
+ const dependenciesReady = (story.needs ?? []).every(id => complete.has(id));
42
+ const scopesFree = (!story.area || !occupied.some(item => item.area === story.area))
43
+ && !occupied.some(item => writeScopesOverlap(story.writes, item.writes));
44
+ if (dependenciesReady && scopesFree) {
45
+ const duration = work.get(story.id) ?? 0;
46
+ active.push({ story, ends: time + duration });
47
+ taskTimes.set(story.id, { storyId: story.id, startMs: time, endMs: 0 });
48
+ implementationWorkMs += duration;
49
+ pendingWork.splice(index, 1);
30
50
  }
31
51
  else
32
52
  index++;
33
53
  }
34
- if (!active.length)
54
+ if (!integrating && queued.length && active.length * workerUnitsPerStory + 1 <= globalLimit) {
55
+ const next = queued.shift();
56
+ integrationQueueWaitMs += Math.max(0, time - next.readyAt);
57
+ integrationLaneMs += integrationDuration;
58
+ integrating = { story: next.story, ends: time + integrationDuration };
59
+ }
60
+ const nextEnd = Math.min(...active.map(item => item.ends), ...(integrating ? [integrating.ends] : []));
61
+ if (!Number.isFinite(nextEnd))
35
62
  return undefined;
36
- time = Math.min(...active.map(item => item.end));
63
+ time = nextEnd;
64
+ const finishedIntegration = integrating && integrating.ends === time ? integrating : undefined;
65
+ if (finishedIntegration) {
66
+ complete.add(finishedIntegration.story.id);
67
+ const task = taskTimes.get(finishedIntegration.story.id);
68
+ if (task)
69
+ task.endMs = time;
70
+ integrating = undefined;
71
+ completedEvents++;
72
+ }
73
+ const finishedWorkers = active.filter(item => item.ends === time).sort((a, b) => a.story.id.localeCompare(b.story.id));
74
+ for (const item of finishedWorkers)
75
+ queued.push({ story: item.story, readyAt: time });
37
76
  for (let index = active.length - 1; index >= 0; index--)
38
- if (active[index].end === time) {
39
- complete.add(active[index].story.id);
77
+ if (active[index].ends === time)
40
78
  active.splice(index, 1);
41
- }
79
+ if (finishedWorkers.length || finishedIntegration)
80
+ continue;
81
+ if (!active.length && !queued.length && !integrating && pendingWork.length)
82
+ return undefined;
83
+ if (completedEvents > stories.length)
84
+ return undefined;
42
85
  }
43
- return { time, tasks };
86
+ return {
87
+ time,
88
+ implementationWorkMs,
89
+ integrationQueueWaitMs,
90
+ integrationLaneMs,
91
+ tasks: [...taskTimes.values()],
92
+ };
44
93
  };
45
- const typical = simulate('typicalMs'), lower = simulate('lowerMs'), upper = simulate('upperMs');
94
+ const typical = simulate('typicalMs', 'typicalMs');
95
+ const lower = simulate('lowerMs', 'lowerMs');
96
+ const upper = simulate('upperMs', 'upperMs');
46
97
  if (!typical || !lower || !upper)
47
98
  return { available: false, reason: 'Cyclic or missing dependencies' };
48
- // Different durations can change a greedy schedule's ordering. Keep the
49
- // empirical scenario envelope ordered; this is not a calibrated interval.
50
- return { available: true, etaMs: typical.time,
51
- lowerMs: Math.min(lower.time, typical.time, upper.time), upperMs: Math.max(lower.time, typical.time, upper.time),
52
- sampleCount: estimate.sampleCount,
53
- confidence: [...durations.values()].some(duration => duration.confidence === 'low') ? 'low' : estimate.confidence,
54
- tasks: typical.tasks };
99
+ return {
100
+ available: true,
101
+ etaMs: typical.time,
102
+ lowerMs: Math.min(lower.time, typical.time, upper.time),
103
+ upperMs: Math.max(lower.time, typical.time, upper.time),
104
+ sampleCount: totalEstimate.sampleCount,
105
+ confidence: !integrationEstimate || integrationEstimate.confidence === 'low' ? 'low' : totalEstimate.confidence,
106
+ implementationWorkMs: typical.implementationWorkMs,
107
+ integrationQueueWaitMs: typical.integrationQueueWaitMs,
108
+ integrationLaneMs: typical.integrationLaneMs,
109
+ integrationSampleCount: integrationEstimate?.sampleCount ?? 0,
110
+ maxConcurrency,
111
+ globalLimit,
112
+ workerUnitsPerStory,
113
+ tasks: typical.tasks,
114
+ };
115
+ }
116
+ function validSample(value) {
117
+ return Number.isFinite(value) && value > 0;
55
118
  }
@@ -113,8 +113,8 @@ export function withClaimOperations(file, context, readRecords, claimFiles, task
113
113
  if (!token)
114
114
  return null;
115
115
  const acquired = [];
116
- const orderedFiles = [...new Set(claimFiles(readRecords()).filter(candidate => candidate !== file))].sort((left, right) => left.localeCompare(right));
117
116
  try {
117
+ const orderedFiles = [...new Set(claimFiles(readRecords()).filter(candidate => candidate !== file))].sort((left, right) => left.localeCompare(right));
118
118
  for (const nextFile of orderedFiles) {
119
119
  const nextToken = acquireClaimOperation(nextFile, context);
120
120
  if (!nextToken)
@@ -55,6 +55,7 @@ export function createDispatcher(options) {
55
55
  const queue = new MergeQueue();
56
56
  const active = new Map();
57
57
  const queued = new Map();
58
+ const integrationControllers = new Map();
58
59
  const areas = new Set();
59
60
  const reservedWrites = new Map();
60
61
  const integrated = [];
@@ -66,23 +67,34 @@ export function createDispatcher(options) {
66
67
  let providerIndex = 0;
67
68
  let paused = false;
68
69
  let cancellationReason;
69
- const reportProgress = () => options.onProgress?.({
70
- dispatcherId,
71
- maxConcurrency: options.maxConcurrency,
72
- activeWorkers: active.size,
73
- queuedCandidates: queued.size,
74
- integrated: integrated.length,
75
- reopened: reopened.length,
76
- iteration: iterations,
77
- progress: progress(options.stories),
78
- workers: [...active.values()].map(worker => ({
79
- story: worker.input.story.id,
80
- storyTitle: worker.input.story.title,
81
- provider: worker.input.provider.provider,
82
- ...(worker.input.provider.model ? { model: worker.input.provider.model } : {}),
83
- phase: 'implementing',
84
- })),
85
- });
70
+ const reportProgress = () => {
71
+ let globalPool;
72
+ try {
73
+ globalPool = options.resourceStatus?.();
74
+ }
75
+ catch { /* Resource status is observational; admission remains fail-closed. */ }
76
+ options.onProgress?.({
77
+ dispatcherId,
78
+ maxConcurrency: options.maxConcurrency,
79
+ workerUnitsPerStory: options.candidateCount && options.candidateCount > 1 && options.candidateCoordinator ? options.candidateCount : 1,
80
+ activeWorkers: active.size,
81
+ waitingWorkers: [...active.values()].filter(worker => worker.phase === 'waiting-resource').length,
82
+ queuedCandidates: queued.size,
83
+ queuedIntegrations: queued.size,
84
+ ...(globalPool ? { globalPool } : {}),
85
+ integrated: integrated.length,
86
+ reopened: reopened.length,
87
+ iteration: iterations,
88
+ progress: progress(options.stories),
89
+ workers: [...active.values()].map(worker => ({
90
+ story: worker.input.story.id,
91
+ storyTitle: worker.input.story.title,
92
+ provider: worker.input.provider.provider,
93
+ ...(worker.input.provider.model ? { model: worker.input.provider.model } : {}),
94
+ phase: worker.phase,
95
+ })),
96
+ });
97
+ };
86
98
  const providerFor = (story) => {
87
99
  if (story.agent) {
88
100
  const configured = options.affinityProviders?.find(provider => provider.provider === story.agent)
@@ -123,9 +135,22 @@ export function createDispatcher(options) {
123
135
  };
124
136
  const enqueue = (input, result) => {
125
137
  let reason = 'integrated verification failed';
138
+ const integrationQueuedAt = Date.now();
139
+ let integrationStartedAt;
140
+ const integrationController = new AbortController();
141
+ integrationControllers.set(input.story.id, integrationController);
142
+ let integrationLease;
126
143
  const task = queue.enqueue({
127
144
  storyId: input.story.id,
128
145
  rebase: async () => {
146
+ if (cancellationReason)
147
+ throw new Error(cancellationReason);
148
+ options.onIntegrationMetrics?.(input, Date.now() - integrationQueuedAt, undefined);
149
+ if (options.acquireResource) {
150
+ options.gates.integrationPhase?.(input, 'waiting-resource');
151
+ integrationLease = await options.acquireResource({ story: input.story, provider: input.provider, role: 'integration', units: 1, signal: integrationController.signal });
152
+ }
153
+ integrationStartedAt = Date.now();
129
154
  if (cancellationReason)
130
155
  throw new Error(cancellationReason);
131
156
  if (!options.git.isClean(options.targetDir))
@@ -182,11 +207,19 @@ export function createDispatcher(options) {
182
207
  throw new Error(`unexpected merge result: ${String(unexpected)}`);
183
208
  }
184
209
  }
185
- }).finally(() => {
210
+ }).finally(async () => {
186
211
  queued.delete(input.story.id);
212
+ integrationControllers.delete(input.story.id);
187
213
  options.gates.integrationPhase?.(input, undefined);
188
- cleanup(input);
189
- reportProgress();
214
+ try {
215
+ if (integrationLease)
216
+ await integrationLease.release();
217
+ options.onIntegrationMetrics?.(input, Date.now() - integrationQueuedAt, integrationStartedAt === undefined ? undefined : Date.now() - integrationStartedAt);
218
+ }
219
+ finally {
220
+ cleanup(input);
221
+ reportProgress();
222
+ }
190
223
  });
191
224
  queued.set(input.story.id, task);
192
225
  reportProgress();
@@ -207,9 +240,23 @@ export function createDispatcher(options) {
207
240
  if (story.area)
208
241
  areas.add(story.area);
209
242
  const candidateDispatch = candidateRace;
210
- const task = (candidateDispatch && options.candidateCoordinator
211
- ? Promise.resolve(options.candidateCoordinator(input)).then(coordinateCandidates).then(result => ({ source: 'candidates', result }))
212
- : options.worker(input).then(result => ({ source: 'worker', result }))).then(async (outcome) => {
243
+ const activeWorker = { input, controller, task: Promise.resolve(), phase: options.acquireResource ? 'waiting-resource' : 'implementing' };
244
+ const task = Promise.resolve().then(async () => {
245
+ const workerLease = options.acquireResource
246
+ ? await options.acquireResource({ story, provider, role: 'implementation', units: candidateRace ? options.candidateCount : 1, signal: controller.signal })
247
+ : undefined;
248
+ activeWorker.phase = 'implementing';
249
+ reportProgress();
250
+ try {
251
+ return await (candidateDispatch && options.candidateCoordinator
252
+ ? Promise.resolve(options.candidateCoordinator(input)).then(coordinateCandidates).then(result => ({ source: 'candidates', result }))
253
+ : options.worker(input).then(result => ({ source: 'worker', result })));
254
+ }
255
+ finally {
256
+ if (workerLease)
257
+ await workerLease.release();
258
+ }
259
+ }).then(async (outcome) => {
213
260
  active.delete(story.id);
214
261
  claims.heartbeat(input);
215
262
  if (outcome.source === 'candidates') {
@@ -285,7 +332,8 @@ export function createDispatcher(options) {
285
332
  if (!(error instanceof Error))
286
333
  throw error;
287
334
  });
288
- active.set(story.id, { input, controller, task });
335
+ Object.assign(activeWorker, { task });
336
+ active.set(story.id, activeWorker);
289
337
  reportProgress();
290
338
  };
291
339
  const cancel = (reason) => {
@@ -296,6 +344,8 @@ export function createDispatcher(options) {
296
344
  claims.cancel?.(worker.input, reason);
297
345
  worker.controller.abort(reason);
298
346
  }
347
+ for (const controller of integrationControllers.values())
348
+ controller.abort(reason);
299
349
  };
300
350
  const run = async () => {
301
351
  for (;;) {
@@ -303,8 +353,8 @@ export function createDispatcher(options) {
303
353
  paused = true;
304
354
  if (!paused && !cancellationReason && integrationBlocks.length === 0 && iterations < options.maxIterations) {
305
355
  const busy = new Set([...active.keys(), ...queued.keys(), ...failed]);
306
- // Integration/review retains its execution slot until the candidate lands.
307
- const slots = Math.max(0, options.maxConcurrency - active.size - queued.size);
356
+ // Implementation slots are reusable while candidates wait for the separate integration lane.
357
+ const slots = Math.max(0, options.maxConcurrency - active.size);
308
358
  const ready = readyStories(options.stories, { activeAreas: areas, activeWrites: [...reservedWrites.values()] }).filter(story => !busy.has(story.id));
309
359
  let launched = 0;
310
360
  for (const story of ready) {
package/dist/loop/git.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { sanitizeCommitMessage } from './identity.js';
3
- export const RUNTIME_PATHS = [".yoke/supervision", ".yoke/provider-processes", '.yoke/artifacts', '.yoke/events', '.yoke/history', '.yoke/routing', '.yoke/checks', '.yoke/goal.json', '.yoke/goal.pause', '.yoke/loop-status.json', '.yoke/loop.lock', '.yoke/loop.lock.takeover', '.yoke/loop.lock.takeover.recovery', '.yoke/runner.pid', '.yoke/story-durations.json'];
3
+ export const RUNTIME_PATHS = [".yoke/supervision", ".yoke/provider-processes", '.yoke/artifacts', '.yoke/events', '.yoke/history', '.yoke/routing', '.yoke/checks', '.yoke/goal.json', '.yoke/goal.pause', '.yoke/loop.pause', '.yoke/loop-status.json', '.yoke/loop.lock', '.yoke/loop.lock.takeover', '.yoke/loop.lock.takeover.recovery', '.yoke/runner.pid', '.yoke/story-durations.json'];
4
4
  // Directory pathspecs include descendants and also exclude the directory entry.
5
5
  export const RUNTIME_EXCLUDES = RUNTIME_PATHS.map(path => `:(exclude)${path}`);
6
6
  export function stageImplementation(dir) {
package/dist/loop/loop.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { knownInfrastructureFailure } from "../routing/capability.js";
2
- import { existsSync, unlinkSync, readFileSync } from 'node:fs';
2
+ import { existsSync, unlinkSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { acceptanceProtectionProblem } from '../check/command.js';
4
4
  import { join, relative } from 'node:path';
5
5
  import { isAcceptanceCriterion, loadPrd, savePrd, selectNextStory, allPass, progress, storyPathSegment } from './prd.js';
@@ -102,6 +102,18 @@ function runQualityReview(opts, executionDir, story, reporter) {
102
102
  export function pauseFilePath(targetDir) {
103
103
  return join(targetDir, '.yoke', 'loop.pause');
104
104
  }
105
+ /** Request a pause at the next safe story or exploration boundary. */
106
+ export function requestLoopPause(targetDir) {
107
+ const file = pauseFilePath(targetDir);
108
+ mkdirSync(join(targetDir, '.yoke'), { recursive: true });
109
+ try {
110
+ writeFileSync(file, `${new Date().toISOString()}\n`, { flag: 'wx' });
111
+ }
112
+ catch (error) {
113
+ if (error.code !== 'EEXIST')
114
+ throw error;
115
+ }
116
+ }
105
117
  function consumePause(targetDir) {
106
118
  const file = pauseFilePath(targetDir);
107
119
  if (!existsSync(file))
@@ -11,6 +11,7 @@ import { makeParallelAdapters } from './parallel-adapters.js';
11
11
  import { loadPrd, progress } from './prd.js';
12
12
  import { makeAsyncRunner } from './runner.js';
13
13
  import { runStoryWorker } from './worker.js';
14
+ import { acquireSharedWorker, sharedPoolStatus } from './resource-pool.js';
14
15
  export async function runParallelLoopCommand(input) {
15
16
  const originalVerify = input.verify;
16
17
  input = { ...input, verify: path => {
@@ -39,6 +40,26 @@ export async function runParallelLoopCommand(input) {
39
40
  stories: loadPrd(input.prdPath),
40
41
  maxConcurrency: input.maxConcurrency,
41
42
  maxIterations: input.maxIterations,
43
+ acquireResource: async (request) => {
44
+ const started = Date.now();
45
+ const lease = await acquireSharedWorker({
46
+ targetDir: input.targetDir,
47
+ storyId: request.story.id,
48
+ provider: request.provider.provider,
49
+ role: request.role,
50
+ units: request.units,
51
+ signal: request.signal,
52
+ });
53
+ input.reporter.resourceWait?.({ id: request.story.id, title: request.story.title }, request.role, request.units, Date.now() - started, request.provider.provider);
54
+ return lease;
55
+ },
56
+ resourceStatus: () => sharedPoolStatus(),
57
+ onIntegrationMetrics: (worker, queueWaitMs, integrationMs) => {
58
+ if (integrationMs === undefined)
59
+ input.reporter.integrationQueueWait?.({ id: worker.story.id, title: worker.story.title }, queueWaitMs, worker.provider.provider);
60
+ else
61
+ input.reporter.integrationDuration?.({ id: worker.story.id, title: worker.story.title }, integrationMs, worker.provider.provider);
62
+ },
42
63
  providers: input.providers,
43
64
  affinityProviders: input.affinityProviders,
44
65
  worktrees: adapters.worktrees,
package/dist/loop/prd.js CHANGED
@@ -11,6 +11,15 @@ export const AcceptanceCriterionSchema = z.object({
11
11
  text: z.string().min(1),
12
12
  verify: z.array(z.string().min(1)).min(1),
13
13
  });
14
+ export const ExplorationOriginSchema = z.object({
15
+ version: z.literal(1),
16
+ rationale: z.string().min(1).max(2_000),
17
+ expectedBenefit: z.string().min(1).max(2_000),
18
+ confidence: z.number().min(0.8).max(1),
19
+ risk: z.enum(['low', 'medium']),
20
+ evidence: z.array(z.object({ path: z.string().min(1).max(500), observation: z.string().min(1).max(1_000) }).strict()).min(1).max(6),
21
+ discoveredAt: z.string().datetime(),
22
+ }).strict();
14
23
  export function criterionCommandProblem(criterion) {
15
24
  const normalizedId = criterion.id.toLowerCase().replace(/[^a-z0-9]/g, '');
16
25
  for (const raw of criterion.verify) {
@@ -46,6 +55,8 @@ export const StorySchema = z.object({
46
55
  agent: AgentSchema.optional(),
47
56
  /** Inbox request that created this story. Used for idempotent append-only intake. */
48
57
  sourceChange: z.string().min(1).optional(),
58
+ /** Evidence retained for stories created by opt-in continuous exploration. */
59
+ exploration: ExplorationOriginSchema.optional(),
49
60
  quality: StoryQualityDeclarationSchema.optional(),
50
61
  assessment: AssessmentSchema.optional(),
51
62
  /** Binding to the task, upstream contracts and approved planning brief. */
@@ -220,10 +220,12 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
220
220
  persist({ ...base, state: 'running', phase: undefined, story: story.id, storyTitle: story.title,
221
221
  progress, ...(eta ? { eta } : {}), updatedAt: now().toISOString() }, 'story-done', `✓ ${story.id} done${took} — ${progress.passed}/${progress.total} (${percentOf(progress)}%)${hint}`);
222
222
  },
223
- phase(phase) {
224
- if (!current)
225
- return;
226
- persist({ ...current, phase, updatedAt: now().toISOString() }, phase, ` · ${phase}…`);
223
+ phase(phase, reason, progress) {
224
+ const status = current ?? emptyStatus(now().toISOString());
225
+ const base = phase === 'exploring' || phase === 'waiting-exploration' || phase === 'waiting-recovery'
226
+ ? (({ story: _story, storyTitle: _storyTitle, ...withoutStory }) => withoutStory)(status)
227
+ : status;
228
+ persist({ ...base, state: 'running', phase, ...(progress ? { progress } : {}), ...(reason ? { reason } : { reason: undefined }), updatedAt: now().toISOString() }, phase, ` · ${phase}…`);
227
229
  },
228
230
  blocked(reason) {
229
231
  const base = current ?? emptyStatus(now().toISOString());
@@ -237,9 +239,9 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
237
239
  persist({ ...withoutParallel(current ?? emptyStatus(now().toISOString())), state: 'cap-reached', phase: undefined,
238
240
  progress, updatedAt: now().toISOString() }, 'cap-reached', `◾ iteration cap reached — ${progress.passed}/${progress.total}`);
239
241
  },
240
- paused(progress) {
242
+ paused(progress, reason) {
241
243
  persist({ ...withoutParallel(current ?? emptyStatus(now().toISOString())), state: 'paused', phase: undefined,
242
- progress, updatedAt: now().toISOString() }, 'paused', `⏸ loop paused — ${progress.passed}/${progress.total}`);
244
+ progress, ...(reason ? { reason } : {}), updatedAt: now().toISOString() }, 'paused', `⏸ loop paused — ${progress.passed}/${progress.total}`);
243
245
  },
244
246
  quality(status) {
245
247
  const base = current ?? emptyStatus(now().toISOString());
@@ -260,7 +262,7 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
260
262
  selectedVariant: previous.selectedVariant,
261
263
  ...(previous.model ? { model: previous.model } : {}),
262
264
  provider: previous.provider,
263
- ...(previous.phase ? { phase: previous.phase } : {}),
265
+ phase: worker.phase === 'implementing' && previous.phase && previous.phase !== 'waiting-resource' ? previous.phase : worker.phase,
264
266
  ...(previous.quality ? { quality: previous.quality } : {}),
265
267
  }
266
268
  : { ...worker, startedAt: now().toISOString() };
@@ -283,6 +285,24 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
283
285
  updatedAt: now().toISOString(),
284
286
  }, 'parallel', ` · parallel ${status.activeWorkers}/${status.maxConcurrency}`);
285
287
  },
288
+ resourceWait(story, role, units, waitMs, provider) {
289
+ appendEvent(dir, {
290
+ runId,
291
+ timestamp: now().toISOString(),
292
+ type: 'phase-ended',
293
+ storyId: story.id,
294
+ phase: `resource-wait-${role}`,
295
+ provider,
296
+ durationMs: Math.max(0, waitMs),
297
+ data: { units },
298
+ });
299
+ },
300
+ integrationQueueWait(story, waitMs, provider) {
301
+ appendEvent(dir, { runId, timestamp: now().toISOString(), type: 'phase-ended', storyId: story.id, phase: 'integration-queue', provider, durationMs: Math.max(0, waitMs) });
302
+ },
303
+ integrationDuration(story, durationMs, provider) {
304
+ appendEvent(dir, { runId, timestamp: now().toISOString(), type: 'phase-ended', storyId: story.id, phase: 'integration', provider, durationMs: Math.max(0, durationMs) });
305
+ },
286
306
  parallelWorker(status) {
287
307
  const base = current;
288
308
  const parallel = base?.parallel;