@hecer/yoke 1.21.1 → 1.23.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 (103) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +48 -0
  4. package/README.md +8 -1
  5. package/TODOS.md +6 -0
  6. package/bench/analyze-codex-comparison.mjs +90 -17
  7. package/bench/compare-codex.mjs +159 -36
  8. package/bench/result-schema.mjs +132 -0
  9. package/canon/manifest.yaml +1 -1
  10. package/canon/skills/visual-verification/SKILL.md +25 -2
  11. package/canon/tools/codex-rtk-hook.mjs +6 -16
  12. package/dist/agents/pi-telemetry.js +2 -1
  13. package/dist/agents/process-streams.js +12 -64
  14. package/dist/agents/provider-selection.js +12 -0
  15. package/dist/agents/telemetry.js +52 -52
  16. package/dist/change/inbox.js +8 -3
  17. package/dist/check/command.js +69 -17
  18. package/dist/check/delivery.js +121 -0
  19. package/dist/cli.js +91 -3
  20. package/dist/code-intelligence/adapters/mcp.js +1 -0
  21. package/dist/code-intelligence/budgets.js +138 -0
  22. package/dist/code-intelligence/contracts.js +2 -0
  23. package/dist/code-intelligence/coordinator.js +159 -85
  24. package/dist/code-intelligence/evidence.js +87 -34
  25. package/dist/code-intelligence/index.js +1 -0
  26. package/dist/code-intelligence/mcp-client.js +25 -6
  27. package/dist/code-intelligence/mcp-server.js +14 -11
  28. package/dist/code-intelligence/preflight.js +71 -0
  29. package/dist/dashboard/analytics.js +5 -3
  30. package/dist/goals/command.js +183 -53
  31. package/dist/goals/usage.js +87 -0
  32. package/dist/loop/cache-isolation.js +36 -0
  33. package/dist/loop/candidate-cleanup.js +47 -17
  34. package/dist/loop/candidates.js +17 -11
  35. package/dist/loop/dispatcher.js +89 -26
  36. package/dist/loop/failure.js +104 -0
  37. package/dist/loop/gate-snapshot.js +19 -0
  38. package/dist/loop/git.js +1 -1
  39. package/dist/loop/loop.js +124 -70
  40. package/dist/loop/parallel-adapters.js +57 -6
  41. package/dist/loop/parallel-command.js +49 -7
  42. package/dist/loop/proof-retention.js +70 -0
  43. package/dist/loop/recovery.js +23 -5
  44. package/dist/loop/reporter.js +22 -5
  45. package/dist/loop/run-command.js +101 -47
  46. package/dist/loop/runner.js +6 -5
  47. package/dist/loop/worker.js +152 -91
  48. package/dist/observability/history.js +2 -1
  49. package/dist/observability/invocation.js +42 -0
  50. package/dist/observability/local-report.js +120 -0
  51. package/dist/observability/usage.js +19 -0
  52. package/dist/prd/command.js +20 -7
  53. package/dist/prd/decompose.js +5 -2
  54. package/dist/retrofit/config.js +29 -2
  55. package/dist/retrofit/gitignore.js +12 -0
  56. package/dist/retrofit/planners/codex.js +20 -20
  57. package/dist/routing/attempts.js +241 -0
  58. package/dist/routing/capability.js +13 -9
  59. package/dist/routing/optimization.js +73 -0
  60. package/dist/routing/registry.js +7 -1
  61. package/dist/routing/router.js +282 -127
  62. package/dist/setup/command.js +8 -2
  63. package/dist/smoke/command.js +387 -85
  64. package/dist/update/check.js +1 -1
  65. package/docs/BENCHMARK-MANIFEST.md +131 -0
  66. package/docs/CODE-INTELLIGENCE.md +43 -1
  67. package/docs/CODEX-COMPARISON-2026-09-29.md +15 -0
  68. package/docs/DELIVERY-JOURNEYS.md +206 -0
  69. package/docs/ECONOMIC-ROUTING.md +180 -0
  70. package/docs/GOALS.md +61 -4
  71. package/docs/RELEASE-VALIDATION-1.22.0.md +115 -0
  72. package/docs/RELEASE-VALIDATION-1.23.0.md +39 -0
  73. package/docs/benchmarks/2026-10-04-efficiency/ANALYSE.md +182 -0
  74. package/docs/benchmarks/2026-10-04-efficiency/compare-help.py +55 -0
  75. package/docs/benchmarks/2026-10-04-efficiency/manifest.json +125 -0
  76. package/docs/benchmarks/2026-10-04-efficiency/provenance-analysis.json +90 -0
  77. package/docs/benchmarks/2026-10-04-efficiency/provenance-design.json +90 -0
  78. package/docs/benchmarks/2026-10-04-efficiency/provenance-original-report.json +90 -0
  79. package/docs/benchmarks/2026-10-04-efficiency/raw/DEVELOPMENT_ANALYSIS.md +142 -0
  80. package/docs/benchmarks/2026-10-04-efficiency/raw/RESULT.md +21 -0
  81. package/docs/benchmarks/2026-10-04-efficiency/raw/commands.jsonl +26 -0
  82. package/docs/benchmarks/2026-10-04-efficiency/raw/environment.json +31 -0
  83. package/docs/benchmarks/2026-10-04-efficiency/raw/final-yoke-smoke.json +40 -0
  84. package/docs/benchmarks/2026-10-04-efficiency/raw/model-purpose-hints.csv +19 -0
  85. package/docs/benchmarks/2026-10-04-efficiency/raw/observations.jsonl +21 -0
  86. package/docs/benchmarks/2026-10-04-efficiency/raw/observer-command-phases.csv +12 -0
  87. package/docs/benchmarks/2026-10-04-efficiency/raw/roles.csv +5 -0
  88. package/docs/benchmarks/2026-10-04-efficiency/raw/shell-categories.csv +8 -0
  89. package/docs/benchmarks/2026-10-04-efficiency/raw/stories.csv +8 -0
  90. package/docs/benchmarks/2026-10-04-efficiency/raw/summary.json +469 -0
  91. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-history.jsonl +104 -0
  92. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-1.log +58 -0
  93. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-2.log +29 -0
  94. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-3.log +5 -0
  95. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-4.log +12 -0
  96. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-phases.csv +10 -0
  97. package/docs/benchmarks/2026-10-04-efficiency/regression-comparison.json +104 -0
  98. package/docs/parallel-execution.md +37 -9
  99. package/docs/superpowers/plans/2026-10-04-yoke-1.23-efficiency-prd.json +11 -0
  100. package/docs/superpowers/plans/2026-10-04-yoke-1.23-efficiency.md +83 -0
  101. package/docs/superpowers/specs/2026-10-04-yoke-1.23-efficiency-design.md +120 -0
  102. package/gemini-extension.json +1 -1
  103. package/package.json +1 -1
@@ -1,8 +1,12 @@
1
+ import { cacheIsolationProblem } from './cache-isolation.js';
1
2
  import { knownInfrastructureFailure } from "../routing/capability.js";
2
3
  import { existsSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import { acceptanceProtectionProblem } from "../check/command.js";
5
6
  import { isAcceptanceCriterion } from './prd.js';
7
+ import { observeFailure } from './failure.js';
8
+ import { consumeAmbiguity } from './loop.js';
9
+ import { gateIdentity, snapshotGates, reuseGates } from './gate-snapshot.js';
6
10
  import { runQualityRepairLoop } from '../quality/loop.js';
7
11
  function emptyEvidence() { return { criteria: [] }; }
8
12
  function errorMessage(error) {
@@ -40,6 +44,10 @@ function reviewOutcome(result) {
40
44
  return { kind: 'malformed', summary: result.summary };
41
45
  }
42
46
  function runMechanicalGates(input, context, evidence) {
47
+ evidence.criteria = [];
48
+ const cacheProblem = input.failureRoot && input.failureRoot !== context.targetDir ? cacheIsolationProblem(context.targetDir) : undefined;
49
+ if (cacheProblem)
50
+ return { kind: 'failed', stage: 'verify', summary: cacheProblem };
43
51
  const criteria = context.story.acceptance.filter(isAcceptanceCriterion);
44
52
  if (criteria.length === 0) {
45
53
  if (input.requireCriterionEvidence)
@@ -170,8 +178,13 @@ export async function runStoryWorker(input) {
170
178
  return finalResult(input, { ...baseResult(input, evidence, preflight.summary), kind: 'mechanical-failure', stage: 'quality' });
171
179
  }
172
180
  let implementation;
181
+ let verified;
182
+ let failure;
173
183
  try {
174
- implementation = await runWorkerImplementation(input, context, evidence);
184
+ const outcome = await runWorkerImplementation(input, context, evidence);
185
+ implementation = outcome.result;
186
+ verified = outcome.gates;
187
+ failure = outcome.failure;
175
188
  }
176
189
  catch (error) {
177
190
  return finalResult(input, {
@@ -185,110 +198,158 @@ export async function runStoryWorker(input) {
185
198
  if (implementation.infrastructureFailure)
186
199
  implementation.routing?.recordOutcome(false, 'infrastructure');
187
200
  if (implementation.infrastructureFailure || implementation.routing?.blocked)
188
- return finalResult(input, { ...baseResult(input, evidence, implementation.summary), kind: "mechanical-failure", stage: "implementation" });
189
- const afterImplementationCancellation = cancellationReason(input.cancellation);
190
- if (afterImplementationCancellation) {
191
- return finalResult(input, { ...baseResult(input, evidence, afterImplementationCancellation), kind: 'cancelled' });
192
- }
193
- const decision = input.beforeGates?.(context);
194
- if (decision) {
195
- return finalResult(input, { ...baseResult(input, evidence, decision), kind: 'paused', reason: 'decision' });
196
- }
197
- const gates = runMechanicalGates(input, context, evidence);
198
- if (gates.kind === 'cancelled') {
199
- return finalResult(input, { ...baseResult(input, evidence, gates.summary), kind: 'cancelled' });
200
- }
201
- if (gates.kind === 'failed') {
202
- implementation.routing?.recordOutcome(false);
203
- return finalResult(input, { ...baseResult(input, evidence, gates.summary), kind: 'mechanical-failure', stage: gates.stage });
204
- }
205
- const summary = implementation.success
206
- ? implementation.summary
207
- : `${implementation.summary} (runner exited non-zero but verify is green)`;
208
- if (!qualityEnabled && !input.review) {
209
- return finalResult(input, {
210
- ...baseResult(input, evidence, summary),
211
- kind: 'candidate',
212
- routing: { outcome: 'pending-integration', ...(implementation.routing ? { recordOutcome: implementation.routing.recordOutcome } : {}) },
213
- ...(implementation.tokens ? { tokens: implementation.tokens } : {}),
201
+ return finalResult(input, { ...baseResult(input, evidence, implementation.summary), kind: "mechanical-failure", stage: "implementation", ...(failure ? { failure } : {}) });
202
+ try {
203
+ const afterImplementationCancellation = cancellationReason(input.cancellation);
204
+ if (afterImplementationCancellation) {
205
+ implementation.routing?.recordOutcome(false, 'infrastructure');
206
+ return finalResult(input, { ...baseResult(input, evidence, afterImplementationCancellation), kind: 'cancelled' });
207
+ }
208
+ const ambiguity = consumeAmbiguity(context.targetDir);
209
+ if (ambiguity) {
210
+ implementation.routing?.recordOutcome(false, 'infrastructure');
211
+ return finalResult(input, {
212
+ ...baseResult(input, evidence, `story ${context.story.id} stopped: ambiguous acceptance criteria — ${ambiguity}`),
213
+ kind: 'paused', reason: 'ambiguity',
214
+ });
215
+ }
216
+ const decision = input.beforeGates?.(context);
217
+ if (decision) {
218
+ implementation.routing?.recordOutcome(false, 'infrastructure');
219
+ return finalResult(input, { ...baseResult(input, evidence, decision), kind: 'paused', reason: 'decision' });
220
+ }
221
+ if (input.pause?.()) {
222
+ implementation.routing?.recordOutcome(false, 'infrastructure');
223
+ return finalResult(input, { ...baseResult(input, evidence, 'worker paused before acceptance checks'), kind: 'paused' });
224
+ }
225
+ const gates = reuseGates(context.targetDir, context.story, verified) ?? runMechanicalGates(input, context, evidence);
226
+ if (gates.kind === 'cancelled') {
227
+ implementation.routing?.recordOutcome(false, 'infrastructure');
228
+ return finalResult(input, { ...baseResult(input, evidence, gates.summary), kind: 'cancelled' });
229
+ }
230
+ if (gates.kind === 'failed') {
231
+ implementation.routing?.recordOutcome(false);
232
+ const observed = input.failureRoot ? observeFailure({ root: input.failureRoot, scope: input.failureScope, directory: context.targetDir, story: context.story, stage: gates.stage, summary: gates.summary }) : undefined;
233
+ return finalResult(input, { ...baseResult(input, evidence, observed?.feedback ?? gates.summary), kind: 'mechanical-failure', stage: gates.stage, ...(observed ? { failure: observed.failure } : {}) });
234
+ }
235
+ const summary = implementation.success
236
+ ? implementation.summary
237
+ : `${implementation.summary} (runner exited non-zero but verify is green)`;
238
+ if (!qualityEnabled && !input.review) {
239
+ return finalResult(input, {
240
+ ...baseResult(input, evidence, summary),
241
+ kind: 'candidate',
242
+ routing: { outcome: 'pending-integration', ...(implementation.routing ? { recordOutcome: implementation.routing.recordOutcome } : {}) },
243
+ ...(implementation.tokens ? { tokens: implementation.tokens } : {}),
244
+ });
245
+ }
246
+ const beforeQualityCancellation = cancellationReason(input.cancellation);
247
+ if (beforeQualityCancellation) {
248
+ implementation.routing?.recordOutcome(false, 'infrastructure');
249
+ return finalResult(input, { ...baseResult(input, evidence, beforeQualityCancellation), kind: 'cancelled' });
250
+ }
251
+ const qualityStage = qualityEnabled ? input.qualityStage : undefined;
252
+ const review = input.review;
253
+ let qualityFailure;
254
+ const quality = runQualityRepairLoop({
255
+ ...(qualityStage
256
+ ? { quality: (round) => {
257
+ input.reporter?.phase('comparing');
258
+ return qualityStage(context, round);
259
+ } }
260
+ : {}),
261
+ ...(review
262
+ ? { review: () => {
263
+ input.reporter?.phase('reviewing');
264
+ return reviewOutcome(review(context));
265
+ } }
266
+ : {}),
267
+ repair: request => {
268
+ if (!input.repair)
269
+ return { kind: 'blocked', summary: 'repair callback is not configured' };
270
+ input.reporter?.phase('repairing');
271
+ const observed = input.failureRoot ? observeFailure({ root: input.failureRoot, scope: input.failureScope, directory: context.targetDir, story: context.story, stage: 'quality', summary: JSON.stringify(request.finding) }) : undefined;
272
+ if (observed?.action === 'blocked') {
273
+ qualityFailure = observed.failure;
274
+ return { kind: 'blocked', summary: observed.feedback };
275
+ }
276
+ const targetedRequest = observed?.action === 'diagnose' ? { ...request, finding: { ...request.finding, message: observed.feedback } } : request;
277
+ const repair = input.repair(context, targetedRequest);
278
+ return repair.success ? { kind: 'repaired' } : { kind: 'blocked', summary: repair.summary };
279
+ },
280
+ rerunGates: () => {
281
+ const rerun = runMechanicalGates(input, context, evidence);
282
+ if (rerun.kind === 'passed')
283
+ return { kind: 'passed' };
284
+ if (rerun.kind === 'cancelled')
285
+ return rerun;
286
+ return { kind: 'failed', stage: rerun.stage, summary: rerun.summary };
287
+ },
288
+ ...(input.repairLimits ? { limits: input.repairLimits } : {}),
289
+ pause: input.pause,
290
+ onStatus: status => {
291
+ const metadata = input.qualityMetadata?.(context);
292
+ input.reporter?.quality({ ...status, ...(metadata ?? { policy: 'blocking' }) });
293
+ },
214
294
  });
295
+ evidence.quality = quality;
296
+ const afterQualityCancellation = cancellationReason(input.cancellation);
297
+ if (afterQualityCancellation) {
298
+ implementation.routing?.recordOutcome(false, 'infrastructure');
299
+ return finalResult(input, { ...baseResult(input, evidence, afterQualityCancellation), kind: 'cancelled' });
300
+ }
301
+ const result = { ...resultFromQuality(input, evidence, summary, quality), ...(qualityFailure ? { failure: qualityFailure } : {}) };
302
+ if (result.kind === 'paused' || result.kind === 'cancelled' || result.kind === 'review-failure' || result.kind === 'quality-failure')
303
+ implementation.routing?.recordOutcome(false, 'infrastructure');
304
+ else if (result.kind !== 'candidate')
305
+ implementation.routing?.recordOutcome(false);
306
+ if (result.kind === 'candidate') {
307
+ return finalResult(input, {
308
+ ...result,
309
+ routing: { outcome: 'pending-integration', ...(implementation.routing ? { recordOutcome: implementation.routing.recordOutcome } : {}) },
310
+ ...(implementation.tokens ? { tokens: implementation.tokens } : {}),
311
+ });
312
+ }
313
+ return finalResult(input, result);
215
314
  }
216
- const beforeQualityCancellation = cancellationReason(input.cancellation);
217
- if (beforeQualityCancellation) {
218
- return finalResult(input, { ...baseResult(input, evidence, beforeQualityCancellation), kind: 'cancelled' });
219
- }
220
- const qualityStage = qualityEnabled ? input.qualityStage : undefined;
221
- const review = input.review;
222
- const quality = runQualityRepairLoop({
223
- ...(qualityStage
224
- ? { quality: (round) => {
225
- input.reporter?.phase('comparing');
226
- return qualityStage(context, round);
227
- } }
228
- : {}),
229
- ...(review
230
- ? { review: () => {
231
- input.reporter?.phase('reviewing');
232
- return reviewOutcome(review(context));
233
- } }
234
- : {}),
235
- repair: request => {
236
- if (!input.repair)
237
- return { kind: 'blocked', summary: 'repair callback is not configured' };
238
- input.reporter?.phase('repairing');
239
- const repair = input.repair(context, request);
240
- return repair.success ? { kind: 'repaired' } : { kind: 'blocked', summary: repair.summary };
241
- },
242
- rerunGates: () => {
243
- const rerun = runMechanicalGates(input, context, evidence);
244
- if (rerun.kind === 'passed')
245
- return { kind: 'passed' };
246
- if (rerun.kind === 'cancelled')
247
- return rerun;
248
- return { kind: 'failed', stage: rerun.stage, summary: rerun.summary };
249
- },
250
- ...(input.repairLimits ? { limits: input.repairLimits } : {}),
251
- pause: input.pause,
252
- onStatus: status => {
253
- const metadata = input.qualityMetadata?.(context);
254
- input.reporter?.quality({ ...status, ...(metadata ?? { policy: 'blocking' }) });
255
- },
256
- });
257
- evidence.quality = quality;
258
- const afterQualityCancellation = cancellationReason(input.cancellation);
259
- if (afterQualityCancellation) {
260
- return finalResult(input, { ...baseResult(input, evidence, afterQualityCancellation), kind: 'cancelled' });
261
- }
262
- const result = resultFromQuality(input, evidence, summary, quality);
263
- if (result.kind !== 'candidate' && result.kind !== 'paused')
264
- implementation.routing?.recordOutcome(false);
265
- if (result.kind === 'candidate') {
266
- return finalResult(input, {
267
- ...result,
268
- routing: { outcome: 'pending-integration', ...(implementation.routing ? { recordOutcome: implementation.routing.recordOutcome } : {}) },
269
- ...(implementation.tokens ? { tokens: implementation.tokens } : {}),
270
- });
315
+ catch (error) {
316
+ implementation.routing?.recordOutcome(false, 'infrastructure');
317
+ throw error;
271
318
  }
272
- return finalResult(input, result);
273
319
  }
274
320
  async function runWorkerImplementation(input, context, evidence) {
275
- let feedback;
321
+ let feedback = input.feedback;
276
322
  for (let attempt = 0;; attempt++) {
277
323
  const result = await input.runner({ ...context, feedback });
324
+ const cacheProblem = input.failureRoot && input.failureRoot !== context.targetDir ? cacheIsolationProblem(context.targetDir) : undefined;
325
+ if (cacheProblem)
326
+ return { result: { ...result, success: false, infrastructureFailure: true, summary: cacheProblem } };
278
327
  if (!result.routing?.canRetry || result.routing.blocked || attempt >= 7 || cancellationReason(input.cancellation) || input.pause?.())
279
- return result;
328
+ return { result };
280
329
  if (["decision-request.yaml", "ambiguity.md", "loop.pause"].some(name => existsSync(join(context.targetDir, ".yoke", name))) || acceptanceProtectionProblem(context.targetDir))
281
- return result;
282
- const gates = runMechanicalGates(input, context, evidence);
283
- if (gates.kind !== "failed")
284
- return result;
330
+ return { result };
331
+ const before = gateIdentity(context.targetDir, context.story);
332
+ let gates;
333
+ try {
334
+ gates = runMechanicalGates(input, context, evidence);
335
+ }
336
+ catch (error) {
337
+ result.routing.recordOutcome(false, 'infrastructure');
338
+ throw error;
339
+ }
340
+ if (gates.kind !== 'failed') {
341
+ return { result, ...(gates.kind === 'passed' ? { gates: snapshotGates(context.targetDir, context.story, before, gates) } : {}) };
342
+ }
285
343
  if (knownInfrastructureFailure(gates.summary)) {
286
344
  result.routing.recordOutcome(false, 'infrastructure');
287
- return { ...result, success: false, infrastructureFailure: true, summary: gates.summary, routing: { ...result.routing, blocked: true, canRetry: false } };
345
+ return { result: { ...result, success: false, infrastructureFailure: true, summary: gates.summary, routing: { ...result.routing, blocked: true, canRetry: false } } };
288
346
  }
289
347
  result.routing.recordOutcome(false);
348
+ const observed = input.failureRoot ? observeFailure({ root: input.failureRoot, scope: input.failureScope, directory: context.targetDir, story: context.story, stage: gates.stage, summary: gates.summary }) : undefined;
349
+ if (observed?.action === 'blocked')
350
+ return { result: { ...result, success: false, summary: observed.feedback, routing: { ...result.routing, blocked: true, canRetry: false } }, failure: observed.failure };
290
351
  if (result.tokens)
291
352
  input.reporter?.addTokens(result.tokens);
292
- feedback = gates.summary;
353
+ feedback = observed?.feedback ?? gates.summary;
293
354
  }
294
355
  }
@@ -30,7 +30,8 @@ export function archiveMeasurement(root, event) {
30
30
  throw error;
31
31
  }
32
32
  const data = event.data ?? {};
33
- const allowed = ['inputTokens', 'outputTokens', 'cachedInputTokens', 'cacheWriteInputTokens', 'reasoningOutputTokens', 'totalCostUsd', 'agent', 'provider', 'model', 'actualModel', 'requestedModel', 'variant', 'role', 'calls', 'measurementComplete', 'costMeasurementComplete', 'usageAvailable', 'prediction', 'errorMs', 'withinObservedRange', 'escalated'];
33
+ const allowed = ['inputTokens', 'outputTokens', 'cachedInputTokens', 'cacheWriteInputTokens', 'reasoningOutputTokens', 'totalCostUsd', 'agent', 'provider', 'model', 'actualModel', 'requestedProvider', 'requestedModel', 'requestedReasoningEffort', 'requestedVariant', 'variant', 'role', 'callId', 'parentCallId', 'calls', 'measurementComplete', 'costMeasurementComplete', 'usageAvailable', 'prediction', 'errorMs', 'withinObservedRange', 'escalated'];
34
+ allowed.push('usageSource', 'usageMissingFields', 'usagePartialFields', 'failureCategory');
34
35
  const compact = { ...event, data: Object.fromEntries(allowed.filter(key => data[key] !== undefined).map(key => [key, data[key]])) };
35
36
  appendFileSync(file, JSON.stringify(compact) + '\n');
36
37
  }
@@ -0,0 +1,42 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { parseProviderTelemetry } from '../agents/telemetry.js';
3
+ import { appendEvent } from './events.js';
4
+ import { providerTelemetryUsage } from './usage.js';
5
+ /** A single invocation boundary, including failures and injected runner seams. */
6
+ export function measureInvocation(options) {
7
+ const started = Date.now(), callId = randomUUID(), runId = options.runId ?? randomUUID();
8
+ let result;
9
+ let failure;
10
+ const base = { runId, storyId: options.storyId, attemptId: callId, agent: options.agent, role: options.role };
11
+ appendEvent(options.root, { ...base, timestamp: new Date(started).toISOString(), type: 'status', data: { callId, parentCallId: options.parentCallId, state: 'started' } });
12
+ try {
13
+ result = options.execute(options.invocation);
14
+ return result;
15
+ }
16
+ catch (error) {
17
+ failure = error;
18
+ throw error;
19
+ }
20
+ finally {
21
+ let salvaged;
22
+ try {
23
+ const stdout = failure && typeof failure === 'object' && 'stdout' in failure ? failure.stdout : undefined;
24
+ salvaged = stdout == null ? undefined : providerTelemetryUsage(parseProviderTelemetry(options.agent, String(stdout).split(/\r?\n/u)));
25
+ }
26
+ catch { /* Failed measurement must not replace the original provider error. */ }
27
+ const usage = result?.tokens ?? salvaged;
28
+ const complete = usage !== undefined && usage.measurementComplete !== false;
29
+ const durationMs = Date.now() - started;
30
+ appendEvent(options.root, {
31
+ ...base, timestamp: new Date().toISOString(), type: 'tokens', durationMs, outcome: result?.success ? 'succeeded' : 'failed',
32
+ data: {
33
+ ...usage, callId, parentCallId: options.parentCallId,
34
+ provider: options.agent, role: options.role,
35
+ requestedProvider: options.selection?.provider, requestedModel: options.selection?.model,
36
+ requestedReasoningEffort: options.selection?.reasoningEffort, requestedVariant: options.selection?.variant,
37
+ actualModel: usage?.model, measurementComplete: complete, usageAvailable: complete,
38
+ costMeasurementComplete: usage?.costMeasurementComplete ?? (typeof usage?.totalCostUsd === 'number'),
39
+ },
40
+ });
41
+ }
42
+ }
@@ -0,0 +1,120 @@
1
+ import { EVENT_CAP, readEvents } from './events.js';
2
+ import { readMeasurements } from './history.js';
3
+ const fields = ['inputTokens', 'cachedInputTokens', 'outputTokens', 'reasoningOutputTokens', 'totalCostUsd'];
4
+ const finite = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
5
+ const text = (value) => typeof value === 'string' && value.length > 0 ? value : undefined;
6
+ function intervalTotals(events) {
7
+ const intervals = events.flatMap(event => {
8
+ const end = Date.parse(event.timestamp);
9
+ return finite(event.durationMs) && Number.isFinite(end) ? [[end - event.durationMs, end]] : [];
10
+ }).sort((a, b) => a[0] - b[0]);
11
+ if (!intervals.length)
12
+ return { sumMs: null, unionMs: null };
13
+ let sumMs = 0, unionMs = 0, end = -Infinity;
14
+ for (const [start, stop] of intervals) {
15
+ sumMs += stop - start;
16
+ unionMs += Math.max(0, stop - Math.max(start, end));
17
+ end = Math.max(end, stop);
18
+ }
19
+ return { sumMs, unionMs };
20
+ }
21
+ /** Recorded evidence only. Parent views conservatively cover their identified children. */
22
+ export function summarizeUsageEvents(input) {
23
+ const events = [...new Map(input.map(event => [event.id, event])).values()];
24
+ const byId = new Map();
25
+ for (const event of events) {
26
+ if (event.type !== 'tokens')
27
+ continue;
28
+ const data = event.data ?? {};
29
+ const breakdown = Array.isArray(data.calls) && data.calls.length > 0;
30
+ const records = breakdown ? data.calls : [data];
31
+ records.forEach((record, index) => {
32
+ if (!record || typeof record !== 'object' || Array.isArray(record))
33
+ return;
34
+ const raw = record;
35
+ const callId = text(raw.callId) ?? `${event.id}:${index}`;
36
+ // IDs identify views of the same invocation even across reporter run IDs.
37
+ const durationMs = finite(raw.durationMs) ? raw.durationMs : !breakdown && finite(event.durationMs) ? event.durationMs : null;
38
+ const previous = byId.get(callId);
39
+ if (previous) {
40
+ previous.durationMs ??= durationMs;
41
+ return;
42
+ }
43
+ const missing = Array.isArray(raw.usageMissingFields) ? raw.usageMissingFields : [];
44
+ const partial = Array.isArray(raw.usagePartialFields) ? raw.usagePartialFields : [];
45
+ const metrics = Object.fromEntries(fields.map(field => [field, !missing.includes(field) && finite(raw[field]) ? raw[field] : null]));
46
+ const complete = raw.usageAvailable !== false && raw.measurementComplete !== false
47
+ && (breakdown || (data.usageAvailable !== false && data.measurementComplete !== false))
48
+ && metrics.inputTokens !== null && metrics.outputTokens !== null;
49
+ const coverage = complete ? 'measured' : fields.some(field => metrics[field] !== null) ? 'partial' : 'unknown';
50
+ byId.set(callId, {
51
+ ...metrics, callId, parentCallId: text(raw.parentCallId ?? data.parentCallId), runId: event.runId,
52
+ attemptId: event.attemptId, storyId: event.storyId,
53
+ role: text(raw.role ?? event.role ?? data.role) ?? 'unknown',
54
+ provider: text(raw.provider ?? event.provider ?? data.provider ?? event.agent),
55
+ source: text(raw.usageSource ?? data.usageSource) ?? 'local-event', coverage,
56
+ costCoverage: metrics.totalCostUsd === null ? 'unknown' : raw.costMeasurementComplete === false || (!breakdown && data.costMeasurementComplete === false) ? 'partial' : 'measured',
57
+ missingFields: fields.filter(field => metrics[field] === null),
58
+ fieldCoverage: Object.fromEntries(fields.map(field => [field, metrics[field] === null ? 'unknown' : partial.includes(field) ? 'partial' : 'measured'])),
59
+ durationMs,
60
+ counted: true,
61
+ });
62
+ });
63
+ }
64
+ const calls = [...byId.values()];
65
+ for (const call of calls) {
66
+ if (call.parentCallId && byId.has(call.parentCallId)) {
67
+ call.counted = false;
68
+ call.exclusion = 'parent-view-overlap';
69
+ }
70
+ }
71
+ const counted = calls.filter(call => call.counted);
72
+ const sum = (key, values = counted) => {
73
+ const measured = values.map(call => call[key]).filter((value) => value !== null);
74
+ return measured.length ? measured.reduce((total, value) => total + value, 0) : null;
75
+ };
76
+ const coverage = (values) => !values.length || values.every(call => call.coverage === 'unknown') ? 'unknown' : values.every(call => call.coverage === 'measured') ? 'measured' : 'partial';
77
+ const unmeasuredAttempts = events.filter(event => event.type === 'attempt-ended' && !calls.some(call => call.runId === event.runId && call.attemptId === event.attemptId)).length;
78
+ const inputTokens = sum('inputTokens'), outputTokens = sum('outputTokens');
79
+ const phases = events.filter(event => event.type === 'phase-ended');
80
+ const phaseTime = intervalTotals(phases), attemptTime = intervalTotals(events.filter(event => event.type === 'attempt-ended'));
81
+ const failures = { observer: 0, infrastructure: 0, product: 0, unknown: 0 };
82
+ for (const event of events) {
83
+ if (!['failed', 'blocked', 'rejected', 'error'].includes(event.outcome ?? ''))
84
+ continue;
85
+ const category = event.data?.failureCategory;
86
+ failures[category === 'observer' || category === 'infrastructure' || category === 'product' ? category : 'unknown']++;
87
+ }
88
+ return {
89
+ calls,
90
+ total: { inputTokens, cachedInputTokens: sum('cachedInputTokens'), outputTokens, reasoningOutputTokens: sum('reasoningOutputTokens'),
91
+ totalTokens: inputTokens !== null && outputTokens !== null ? inputTokens + outputTokens : null,
92
+ totalCostUsd: sum('totalCostUsd'), coverage: unmeasuredAttempts && coverage(counted) === 'measured' ? 'partial' : coverage(counted),
93
+ costCoverage: !counted.length || counted.every(call => call.costCoverage === 'unknown') ? 'unknown' : counted.every(call => call.costCoverage === 'measured') && !unmeasuredAttempts ? 'measured' : 'partial', },
94
+ hostCoverage: { guardian: coverage(calls.filter(call => call.role === 'guardian')), approval: coverage(calls.filter(call => call.role === 'approval')) },
95
+ // Serial makeRunner labels story implementation as parent; routing controllers use orchestrator.
96
+ time: { workerProcessDurationMs: sum('durationMs', counted.filter(call => call.role === 'worker' || (call.role === 'parent' && call.storyId !== undefined))),
97
+ phaseDurationSumMs: phaseTime.sumMs, phaseDurationUnionMs: phaseTime.unionMs,
98
+ attemptDurationSumMs: attemptTime.sumMs, attemptDurationUnionMs: attemptTime.unionMs,
99
+ phases: [...new Set(phases.map(event => event.phase ?? 'unknown'))].sort().map(phase => ({ phase, ...intervalTotals(phases.filter(event => (event.phase ?? 'unknown') === phase)) })),
100
+ },
101
+ failures, unmeasuredAttempts,
102
+ limitations: ['Recorded local events only; unrecorded host sessions and approvals remain unknown.',
103
+ 'Totals are known lower bounds when coverage is partial; missing prices remain unknown.',
104
+ 'Cache read is contained in input; reasoning is contained in output.',
105
+ 'Identified child views are excluded when a parent view exists; disjoint usage is not assumed.',
106
+ 'Intervals derive from recorded end times and durations; unrecorded waiting time is unknown.'],
107
+ };
108
+ }
109
+ /** No model/provider invocation: reads bounded local history and recent events. */
110
+ export function localUsageReport(root, options) {
111
+ const { from, to, runId } = options;
112
+ if (!Number.isFinite(from) || !Number.isFinite(to) || from >= to || to - from > 366 * 86400000)
113
+ throw Error('Choose a valid period of at most 366 days');
114
+ const history = readMeasurements(root, from, to);
115
+ const events = [...history.events, ...readEvents(root, EVENT_CAP)].filter(event => {
116
+ const time = Date.parse(event.timestamp);
117
+ return time >= from && time < to && (!runId || event.runId === runId);
118
+ });
119
+ return { from: new Date(from).toISOString(), to: new Date(to).toISOString(), ...summarizeUsageEvents(events), errors: history.errors };
120
+ }
@@ -0,0 +1,19 @@
1
+ /** Preserve known lower bounds; missing telemetry must never become a free call. */
2
+ export function providerTelemetryUsage(telemetry) {
3
+ if (!telemetry.tokens && !telemetry.partialUsage)
4
+ return undefined;
5
+ const known = { ...telemetry.partialUsage, ...telemetry.tokens };
6
+ if (!Object.values(known).some(value => typeof value === 'number'))
7
+ return undefined;
8
+ const model = known.model ?? (telemetry.reportedModels?.length === 1 ? telemetry.reportedModels[0] : undefined);
9
+ return {
10
+ ...known,
11
+ ...(model ? { model } : {}),
12
+ inputTokens: known.inputTokens ?? 0,
13
+ outputTokens: known.outputTokens ?? 0,
14
+ measurementComplete: telemetry.usageAvailable,
15
+ costMeasurementComplete: typeof telemetry.tokens?.totalCostUsd === 'number',
16
+ usageMissingFields: ['inputTokens', 'cachedInputTokens', 'outputTokens', 'reasoningOutputTokens', 'totalCostUsd'].filter(field => typeof known[field] !== 'number'),
17
+ usagePartialFields: Object.keys(telemetry.partialUsage ?? {}).filter(field => typeof telemetry.tokens?.[field] !== 'number' && typeof known[field] === 'number'),
18
+ };
19
+ }
@@ -7,9 +7,11 @@ import { bindAssessments, preparedProblems } from './assess.js';
7
7
  import { resolvePlanner } from '../routing/planning.js';
8
8
  import { readPlanningFile } from '../routing/contracts.js';
9
9
  import { acquireLock, releaseLock } from '../loop/lock.js';
10
- import { agentInvocation, buildWatchdogInvocation, runAgent, isAgentAvailable, } from '../loop/runner.js';
10
+ import { agentInvocation, buildWatchdogInvocation, runCapturedAgent, isAgentAvailable, } from '../loop/runner.js';
11
11
  import { resolveIdleMs } from '../loop/run-command.js';
12
12
  import { detectHostAgent, resolveRunnerAgent } from '../agents/host.js';
13
+ import { measureInvocation } from '../observability/invocation.js';
14
+ import { statePath } from '../workspace/state.js';
13
15
  export const PRD_TEMPLATE = `# Yoke PRD — the loop picks the lowest-priority open story each iteration.
14
16
  # Story format (see canon/loop/prd.schema.md):
15
17
  # - id: STORY-1
@@ -92,14 +94,25 @@ export function runPrdDraft(targetDir, opts) {
92
94
  }
93
95
  try {
94
96
  const before = readPlanningFile(targetDir, '.yoke/prd.yaml');
95
- const rollback = () => { if (before === undefined)
96
- rmSync(path, { force: true });
97
- else
98
- writeFileSync(path, before); };
97
+ const rollback = () => {
98
+ const destination = join(statePath(targetDir), 'prd.yaml');
99
+ // Unlink a provider-created file link instead of writing through it.
100
+ rmSync(destination, { force: true });
101
+ if (before !== undefined)
102
+ writeFileSync(destination, before, { flag: 'wx' });
103
+ };
99
104
  const inv = agentInvocation(agent, buildPrdDraftPrompt(idea, planningBrief), targetDir, 'safe', planner.selection);
100
105
  console.log(`Drafting PRD with ${agent}...`);
101
- const run = opts.run ?? ((i) => runAgent(buildWatchdogInvocation(i, idleMs)));
102
- const result = run(inv);
106
+ const run = opts.run ?? ((i) => runCapturedAgent(agent, buildWatchdogInvocation(i, idleMs)));
107
+ let result;
108
+ try {
109
+ result = measureInvocation({ root: targetDir, agent, role: 'prd-draft', selection: planner.selection, invocation: inv, execute: run });
110
+ }
111
+ catch (error) {
112
+ rollback();
113
+ console.error(`PRD draft failed: ${error.message}`);
114
+ return 1;
115
+ }
103
116
  if (!result.success) {
104
117
  rollback();
105
118
  console.error(`PRD draft failed: ${result.summary}`);
@@ -11,7 +11,8 @@ import { writeScopesOverlap, validWriteScope } from '../loop/scheduler.js';
11
11
  import { withSharedWorkerSync } from '../loop/resource-pool.js';
12
12
  import { loadConfig } from '../retrofit/config.js';
13
13
  import { detectHostAgent, resolveRunnerAgent } from '../agents/host.js';
14
- import { agentInvocation, buildWatchdogInvocation, isAgentAvailable, runAgent } from '../loop/runner.js';
14
+ import { agentInvocation, buildWatchdogInvocation, isAgentAvailable, runCapturedAgent } from '../loop/runner.js';
15
+ import { measureInvocation } from '../observability/invocation.js';
15
16
  const ProposalSchema = z.array(z.object({
16
17
  id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/u),
17
18
  title: z.string().min(1).max(300),
@@ -181,7 +182,9 @@ export function runPrdDecompose(root, options) {
181
182
  if (prompt.length > 60_000)
182
183
  throw Error('Planning input exceeds 60000 characters; condense .yoke/plan.md first');
183
184
  const invocation = agentInvocation(planner.agent, prompt, root, 'safe', planner.selection);
184
- const result = withSharedWorkerSync({ targetDir: root, storyId: `prd-decompose:${parent.id}`, provider: planner.agent, role: 'implementation' }, () => (options.run ?? (item => runAgent(buildWatchdogInvocation(item, options.timeoutMinutes === undefined ? 20 * 60_000 : options.timeoutMinutes > 0 ? options.timeoutMinutes * 60_000 : 0))))(invocation));
185
+ const result = withSharedWorkerSync({ targetDir: root, storyId: `prd-decompose:${parent.id}`, provider: planner.agent, role: 'implementation' }, () => measureInvocation({ root, agent: planner.agent, role: 'prd-decompose', storyId: parent.id, selection: planner.selection, invocation,
186
+ execute: options.run ?? (item => runCapturedAgent(planner.agent, buildWatchdogInvocation(item, options.timeoutMinutes === undefined ? 20 * 60_000 : options.timeoutMinutes > 0 ? options.timeoutMinutes * 60_000 : 0))),
187
+ }));
185
188
  if (!result.success)
186
189
  throw Error(`planning request failed: ${result.summary}`);
187
190
  if (!existsSync(proposalPath))
@@ -16,8 +16,24 @@ const CodeIntelligenceSchema = z.object({
16
16
  policy: z.object({ allowNetwork: z.boolean().default(false), excludePatterns: z.array(z.string().min(1)).max(100).default([]), maxFileBytes: z.number().int().positive().max(20_000_000).optional() }).optional(),
17
17
  limits: z.object({ tokenBudget: z.number().int().min(128).max(16000).default(2400), timeoutMs: z.number().int().min(100).max(600000).default(10000), maxBytes: z.number().int().positive().max(10_000_000).default(2_000_000), maxBackends: z.number().int().min(1).max(3).default(3) }).optional(),
18
18
  });
19
- const SmokeFlowSchema = z.object({ name: z.string().min(1), path: z.string().min(1), landmark: z.string().optional() });
20
- const SmokeSchema = z.object({ baseUrl: z.string().min(1), flows: z.array(SmokeFlowSchema).min(1) });
19
+ const SmokeStepTimeout = z.number().int().min(1).max(30000).optional();
20
+ const SmokeSelector = z.string().min(1).max(4096);
21
+ const SmokeStepSchema = z.discriminatedUnion('action', [
22
+ z.object({ action: z.literal('click'), selector: SmokeSelector, timeoutMs: SmokeStepTimeout }).strict(),
23
+ z.object({ action: z.literal('fill'), selector: SmokeSelector, value: z.string().max(8192).optional(), valueEnv: z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/u).optional(), timeoutMs: SmokeStepTimeout }).strict(),
24
+ z.object({ action: z.literal('press'), selector: SmokeSelector, key: z.string().min(1).max(128), timeoutMs: SmokeStepTimeout }).strict(),
25
+ z.object({ action: z.literal('expect-visible'), selector: SmokeSelector, timeoutMs: SmokeStepTimeout }).strict(),
26
+ z.object({ action: z.literal('expect-text'), selector: SmokeSelector, text: z.string().max(8192), exact: z.boolean().optional(), timeoutMs: SmokeStepTimeout }).strict(),
27
+ z.object({ action: z.literal('expect-url'), url: z.string().min(1).max(4096), timeoutMs: SmokeStepTimeout }).strict(),
28
+ z.object({ action: z.literal('reload'), timeoutMs: SmokeStepTimeout }).strict(),
29
+ ]).superRefine((step, context) => {
30
+ if (step.action === 'fill' && (step.value === undefined) === (step.valueEnv === undefined))
31
+ context.addIssue({ code: 'custom', message: 'A fill step needs exactly one of value or valueEnv' });
32
+ });
33
+ const SmokeFlowSchema = z.object({ name: z.string().min(1), path: z.string().min(1), landmark: z.string().optional(), timeoutMs: z.number().int().min(1).max(120000).optional(), steps: z.array(SmokeStepSchema).min(1).max(50).optional() });
34
+ const SmokeSchema = z.object({ baseUrl: z.string().min(1), flows: z.array(SmokeFlowSchema).min(1),
35
+ sourceIdentity: z.object({ path: z.string().regex(/^\/(?!\/)/).max(4096), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).optional(),
36
+ });
21
37
  const OutputPolicySchema = z.object({
22
38
  previewBytes: z.number().int().positive().optional(),
23
39
  artifactThresholdBytes: z.number().int().positive().optional(),
@@ -56,6 +72,12 @@ const RoutingWorkerSchema = z.object({
56
72
  capabilities: z.array(z.string().min(1)).default([]),
57
73
  tier: z.enum(['light', 'standard', 'strong', 'frontier']).optional(),
58
74
  roles: z.array(z.enum(['implementation', 'reviewer', 'critic', 'repair'])).optional(),
75
+ profileMetadata: z.object({
76
+ version: z.literal(1),
77
+ catalog: z.string().min(1).max(120),
78
+ source: z.enum(['yoke-default', 'operator']),
79
+ basis: z.literal('configured-prior'),
80
+ }).strict().optional(),
59
81
  });
60
82
  const RoutingRuleSchema = z.object({
61
83
  area: z.string().min(1).optional(),
@@ -104,6 +126,11 @@ export const YokeConfigSchema = z.object({
104
126
  assessmentPolicy: z.enum(['on-demand', 'prepared']).optional(),
105
127
  fallback: z.enum(['parent', 'block']).optional(),
106
128
  maxTier: z.enum(['light', 'standard', 'strong', 'frontier']).optional(),
129
+ optimization: z.object({
130
+ version: z.literal(1),
131
+ objective: z.enum(['cost', 'speed', 'balanced']),
132
+ minSamples: z.number().int().min(10).max(10000).optional(),
133
+ }).strict().optional(),
107
134
  maxCandidates: z.number().int().min(1).max(5).default(3),
108
135
  orchestrator: z.object({
109
136
  provider: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/).optional(),