@hecer/yoke 1.22.0 → 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 (65) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +21 -0
  4. package/README.md +3 -1
  5. package/TODOS.md +6 -0
  6. package/canon/manifest.yaml +1 -1
  7. package/canon/skills/visual-verification/SKILL.md +25 -2
  8. package/canon/tools/codex-rtk-hook.mjs +6 -16
  9. package/dist/cli.js +79 -0
  10. package/dist/code-intelligence/adapters/mcp.js +1 -0
  11. package/dist/code-intelligence/coordinator.js +3 -1
  12. package/dist/code-intelligence/index.js +1 -0
  13. package/dist/code-intelligence/mcp-client.js +15 -4
  14. package/dist/code-intelligence/mcp-server.js +4 -1
  15. package/dist/code-intelligence/preflight.js +71 -0
  16. package/dist/loop/cache-isolation.js +36 -0
  17. package/dist/loop/loop.js +24 -4
  18. package/dist/loop/parallel-adapters.js +35 -2
  19. package/dist/loop/parallel-command.js +17 -2
  20. package/dist/loop/proof-retention.js +70 -0
  21. package/dist/loop/reporter.js +1 -1
  22. package/dist/loop/run-command.js +6 -1
  23. package/dist/loop/runner.js +1 -1
  24. package/dist/loop/worker.js +7 -0
  25. package/dist/observability/history.js +1 -0
  26. package/dist/observability/local-report.js +120 -0
  27. package/dist/observability/usage.js +2 -0
  28. package/dist/retrofit/config.js +3 -1
  29. package/dist/retrofit/gitignore.js +10 -0
  30. package/dist/retrofit/planners/codex.js +20 -20
  31. package/dist/routing/router.js +2 -0
  32. package/dist/smoke/command.js +87 -13
  33. package/dist/update/check.js +1 -1
  34. package/docs/DELIVERY-JOURNEYS.md +8 -1
  35. package/docs/RELEASE-VALIDATION-1.23.0.md +39 -0
  36. package/docs/benchmarks/2026-10-04-efficiency/ANALYSE.md +182 -0
  37. package/docs/benchmarks/2026-10-04-efficiency/compare-help.py +55 -0
  38. package/docs/benchmarks/2026-10-04-efficiency/manifest.json +125 -0
  39. package/docs/benchmarks/2026-10-04-efficiency/provenance-analysis.json +90 -0
  40. package/docs/benchmarks/2026-10-04-efficiency/provenance-design.json +90 -0
  41. package/docs/benchmarks/2026-10-04-efficiency/provenance-original-report.json +90 -0
  42. package/docs/benchmarks/2026-10-04-efficiency/raw/DEVELOPMENT_ANALYSIS.md +142 -0
  43. package/docs/benchmarks/2026-10-04-efficiency/raw/RESULT.md +21 -0
  44. package/docs/benchmarks/2026-10-04-efficiency/raw/commands.jsonl +26 -0
  45. package/docs/benchmarks/2026-10-04-efficiency/raw/environment.json +31 -0
  46. package/docs/benchmarks/2026-10-04-efficiency/raw/final-yoke-smoke.json +40 -0
  47. package/docs/benchmarks/2026-10-04-efficiency/raw/model-purpose-hints.csv +19 -0
  48. package/docs/benchmarks/2026-10-04-efficiency/raw/observations.jsonl +21 -0
  49. package/docs/benchmarks/2026-10-04-efficiency/raw/observer-command-phases.csv +12 -0
  50. package/docs/benchmarks/2026-10-04-efficiency/raw/roles.csv +5 -0
  51. package/docs/benchmarks/2026-10-04-efficiency/raw/shell-categories.csv +8 -0
  52. package/docs/benchmarks/2026-10-04-efficiency/raw/stories.csv +8 -0
  53. package/docs/benchmarks/2026-10-04-efficiency/raw/summary.json +469 -0
  54. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-history.jsonl +104 -0
  55. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-1.log +58 -0
  56. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-2.log +29 -0
  57. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-3.log +5 -0
  58. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-4.log +12 -0
  59. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-phases.csv +10 -0
  60. package/docs/benchmarks/2026-10-04-efficiency/regression-comparison.json +104 -0
  61. package/docs/superpowers/plans/2026-10-04-yoke-1.23-efficiency-prd.json +11 -0
  62. package/docs/superpowers/plans/2026-10-04-yoke-1.23-efficiency.md +83 -0
  63. package/docs/superpowers/specs/2026-10-04-yoke-1.23-efficiency-design.md +120 -0
  64. package/gemini-extension.json +1 -1
  65. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { cacheIsolationProblem } from './cache-isolation.js';
1
2
  import { existsSync, rmSync } from 'node:fs';
2
3
  import { observeFailure, clearFailureProgress } from './failure.js';
3
4
  import { acceptanceProtectionProblem } from '../check/command.js';
@@ -15,11 +16,23 @@ import { runStoryWorker } from './worker.js';
15
16
  import { acquireSharedWorker, sharedPoolStatus } from './resource-pool.js';
16
17
  import { providerTelemetryUsage } from '../observability/usage.js';
17
18
  export async function runParallelLoopCommand(input) {
18
- const originalVerify = input.verify;
19
+ const originalVerify = input.verify, originalCriterion = input.verifyCriterion;
20
+ const cacheProblem = (path) => !input.git && path !== input.targetDir ? cacheIsolationProblem(path) : undefined;
19
21
  input = { ...input, verify: path => {
20
- const problem = acceptanceProtectionProblem(path, input.targetDir);
22
+ const problem = cacheProblem(path) ?? acceptanceProtectionProblem(path, input.targetDir);
21
23
  return problem ? { passed: false, summary: problem } : originalVerify(path);
24
+ }, verifyCriterion: (path, story, criterion) => {
25
+ const problem = cacheProblem(path);
26
+ return problem ? { passed: false, summary: problem } : originalCriterion(path, story, criterion);
22
27
  } };
28
+ for (const name of ['design', 'perf', 'audit']) {
29
+ const gate = input[name];
30
+ if (gate)
31
+ input = { ...input, [name]: (path) => {
32
+ const problem = cacheProblem(path);
33
+ return problem ? { passed: false, summary: problem } : gate(path);
34
+ } };
35
+ }
23
36
  const adapters = makeParallelAdapters(input.targetDir, input.identity, input.git);
24
37
  if (!adapters.git.isClean(input.targetDir)) {
25
38
  input.reporter.blocked('target working tree is not clean');
@@ -137,6 +150,8 @@ export async function runParallelLoopCommand(input) {
137
150
  const gate = input.completion(input.targetDir);
138
151
  if (!gate.passed)
139
152
  reason = `integrated system did not verify: ${gate.summary}`;
153
+ else if (!adapters.git.isClean(input.targetDir))
154
+ reason = 'completion command left source or final assets dirty; preserve changes and move rerun proofs to ignored runtime paths before resuming';
140
155
  }
141
156
  catch (error) {
142
157
  reason = `integrated completion gate failed: ${error instanceof Error ? error.message : String(error)}`;
@@ -0,0 +1,70 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, relative, resolve, isAbsolute } from 'node:path';
4
+ import { workspaceFingerprint } from '../workspace/fingerprint.js';
5
+ import { storyPathSegment } from './prd.js';
6
+ const hash = (data) => createHash('sha256').update(data).digest('hex');
7
+ function safePath(root, path) {
8
+ const rel = relative(resolve(root), resolve(path));
9
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith('../') || rel.startsWith('..\\'))
10
+ throw new Error('Proof path escaped workspace');
11
+ let current = root;
12
+ for (const segment of rel.split(/[\\/]/u).filter(Boolean)) {
13
+ current = join(current, segment);
14
+ try {
15
+ if (lstatSync(current).isSymbolicLink())
16
+ throw new Error(`Proof path must not contain a link: ${current}`);
17
+ }
18
+ catch (error) {
19
+ if (error.code !== 'ENOENT')
20
+ throw error;
21
+ }
22
+ }
23
+ }
24
+ /** Copy selected proof into an immutable runtime snapshot; errors preserve the candidate. */
25
+ export function retainRuntimeProof(directory, storyId, targetDirectory) {
26
+ const entries = [];
27
+ const walk = (dir, base, prefix = '') => {
28
+ safePath(directory, dir);
29
+ if (!existsSync(dir))
30
+ return;
31
+ for (const name of readdirSync(dir).sort()) {
32
+ const file = join(dir, name);
33
+ safePath(directory, file);
34
+ const stat = lstatSync(file);
35
+ if (stat.isDirectory())
36
+ walk(file, base, prefix);
37
+ else if (stat.isFile()) {
38
+ const bytes = readFileSync(file);
39
+ entries.push({ path: (prefix + relative(base, file)).replace(/\\/gu, '/'), sha256: hash(bytes), bytes });
40
+ }
41
+ else
42
+ throw new Error(`Unsupported proof entry: ${file}`);
43
+ }
44
+ };
45
+ const artifacts = join(directory, '.yoke/artifacts'), proof = join(directory, '.yoke/proof');
46
+ walk(artifacts, artifacts);
47
+ walk(proof, proof, 'proof/');
48
+ if (!entries.length)
49
+ return;
50
+ const config = ['config.yaml', 'acceptance.yaml', 'prd.yaml'].map(name => {
51
+ const path = join(directory, '.yoke', name);
52
+ safePath(directory, path);
53
+ return [name, existsSync(path) ? hash(readFileSync(path)) : 'missing'];
54
+ });
55
+ const manifest = JSON.stringify({ version: 1, source: workspaceFingerprint(directory), config: hash(JSON.stringify(config)), environment: hash(JSON.stringify({ platform: process.platform, arch: process.arch, versions: process.versions })), files: entries.map(({ path, sha256 }) => ({ path, sha256 })) }, null, 2) + '\n';
56
+ const destination = join(targetDirectory, '.yoke/proof', storyPathSegment(storyId), 'runtime-artifacts', hash(manifest));
57
+ for (const { path, sha256, bytes } of [...entries, { path: 'manifest.json', sha256: hash(manifest), bytes: Buffer.from(manifest) }]) {
58
+ const copied = join(destination, path);
59
+ safePath(targetDirectory, copied);
60
+ mkdirSync(dirname(copied), { recursive: true });
61
+ if (existsSync(copied)) {
62
+ if (hash(readFileSync(copied)) !== sha256)
63
+ throw new Error(`Retained proof hash mismatch: ${path}`);
64
+ }
65
+ else
66
+ writeFileSync(copied, bytes, { mode: 0o600, flag: 'wx' });
67
+ if (hash(readFileSync(copied)) !== sha256)
68
+ throw new Error(`Proof copy hash mismatch: ${path}`);
69
+ }
70
+ }
@@ -335,7 +335,7 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
335
335
  return;
336
336
  }
337
337
  const { integrator: _integrator, ...withoutIntegrator } = parallel;
338
- persist({ ...base, parallel: withoutIntegrator, updatedAt: now().toISOString() }, 'parallel-integrator', ' · integration complete');
338
+ persist({ ...base, parallel: withoutIntegrator, updatedAt: now().toISOString() }, 'parallel-integrator', ' · integrator idle');
339
339
  },
340
340
  addTokens(usage) {
341
341
  if (![usage.inputTokens, usage.outputTokens].every(value => Number.isFinite(value) && value >= 0))
@@ -76,7 +76,12 @@ export function loopStatus(targetDir, now = () => new Date(), opts) {
76
76
  if (opts?.compact) {
77
77
  if (!st)
78
78
  return `state=${enabled ? 'enabled' : 'disabled'} prd="${prog}"`;
79
- return `state=${st.state} story=${st.story ?? 'none'} progress=${st.progress.passed}/${st.progress.total} phase=${st.phase} updated=${relativeTime(st.updatedAt, now())}`;
79
+ const workers = [...(st.parallel?.workers ?? [])].sort((a, b) => a.story.localeCompare(b.story));
80
+ const work = (worker) => `${encodeURIComponent(worker.story)}:${worker.phase ?? 'working'}`;
81
+ const integrator = st.parallel?.integrator;
82
+ const story = st.story ?? integrator?.story ?? workers[0]?.story ?? 'none';
83
+ const parallel = st.parallel ? ` workers=${workers.map(work).join(',') || 'none'} integrator=${integrator ? work(integrator) : 'none'} waiting=${st.parallel.waitingWorkers ?? 0} queued=${st.parallel.queuedIntegrations ?? st.parallel.queuedCandidates}` : '';
84
+ return `state=${st.state} story=${encodeURIComponent(story)} progress=${st.progress.passed}/${st.progress.total} phase=${st.phase ?? (st.parallel ? 'parallel' : st.state)}${parallel} updated=${relativeTime(st.updatedAt, now())}`;
80
85
  }
81
86
  const sharedPoolLine = () => {
82
87
  try {
@@ -32,7 +32,7 @@ export function buildClaudePrompt(story, context, onAmbiguity = 'resolve', perfC
32
32
  ];
33
33
  if (context)
34
34
  lines.push('', context);
35
- lines.push('', `Story ${story.id}: ${story.title}`, 'Acceptance criteria (Definition of Done):', criteria, ...(story.assessment ? ['Planner approach:', story.assessment.approach] : []), '', "When done, ensure the project's full test suite passes.", 'Do NOT commit — the loop commits on your behalf after verifying.', '', 'Working rules:', '- Add nothing beyond what the story requires: no extra features, abstractions, comments, or defensive code for cases that cannot happen.', '- Do not create summary, plan, or analysis documents — only files the story itself needs.', '- If a check fails, fix the root cause; never bypass it (e.g. --no-verify) or pass by weakening tests.', '- Report the outcome faithfully: if a criterion is unmet or tests fail, say so plainly instead of claiming success.', '- Never ask questions or wait for input — you run unattended and nobody can answer.', onAmbiguity === 'abort'
35
+ lines.push('', `Story ${story.id}: ${story.title}`, 'Acceptance criteria (Definition of Done):', criteria, ...(story.assessment ? ['Planner approach:', story.assessment.approach] : []), '', "When done, ensure the project's full test suite passes.", 'Do NOT commit — the loop commits on your behalf after verifying.', '', 'Working rules:', '- Use worktree-local writable dependency/runtime caches. Do not link node_modules to a target checkout when tools write .vite-temp or other caches there. Shared package download caches are allowed; preserve the configured sandbox. Report offline cache misses and network failures explicitly instead of blindly retrying.', '- Add nothing beyond what the story requires: no extra features, abstractions, comments, or defensive code for cases that cannot happen.', '- Do not create summary, plan, or analysis documents — only files the story itself needs.', '- If a check fails, fix the root cause; never bypass it (e.g. --no-verify) or pass by weakening tests.', '- Report the outcome faithfully: if a criterion is unmet or tests fail, say so plainly instead of claiming success.', '- Never ask questions or wait for input — you run unattended and nobody can answer.', onAmbiguity === 'abort'
36
36
  ? '- If an acceptance criterion is genuinely undecidable, do NOT guess: write the open question(s) to .yoke/ambiguity.md, change nothing else, and stop.'
37
37
  : onAmbiguity === 'critical'
38
38
  ? [
@@ -1,3 +1,4 @@
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";
@@ -44,6 +45,9 @@ function reviewOutcome(result) {
44
45
  }
45
46
  function runMechanicalGates(input, context, evidence) {
46
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 };
47
51
  const criteria = context.story.acceptance.filter(isAcceptanceCriterion);
48
52
  if (criteria.length === 0) {
49
53
  if (input.requireCriterionEvidence)
@@ -317,6 +321,9 @@ async function runWorkerImplementation(input, context, evidence) {
317
321
  let feedback = input.feedback;
318
322
  for (let attempt = 0;; attempt++) {
319
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 } };
320
327
  if (!result.routing?.canRetry || result.routing.blocked || attempt >= 7 || cancellationReason(input.cancellation) || input.pause?.())
321
328
  return { result };
322
329
  if (["decision-request.yaml", "ambiguity.md", "loop.pause"].some(name => existsSync(join(context.targetDir, ".yoke", name))) || acceptanceProtectionProblem(context.targetDir))
@@ -31,6 +31,7 @@ export function archiveMeasurement(root, event) {
31
31
  }
32
32
  const data = event.data ?? {};
33
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,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
+ }
@@ -13,5 +13,7 @@ export function providerTelemetryUsage(telemetry) {
13
13
  outputTokens: known.outputTokens ?? 0,
14
14
  measurementComplete: telemetry.usageAvailable,
15
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'),
16
18
  };
17
19
  }
@@ -31,7 +31,9 @@ const SmokeStepSchema = z.discriminatedUnion('action', [
31
31
  context.addIssue({ code: 'custom', message: 'A fill step needs exactly one of value or valueEnv' });
32
32
  });
33
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) });
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
+ });
35
37
  const OutputPolicySchema = z.object({
36
38
  previewBytes: z.number().int().positive().optional(),
37
39
  artifactThresholdBytes: z.number().int().positive().optional(),
@@ -1,6 +1,8 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
3
4
  export const YOKE_IGNORE_LINES = [
5
+ '.yoke/supervision/',
4
6
  '.yoke/worktrees/',
5
7
  '.yoke/integration-recovery/',
6
8
  '.yoke/failure-progress/',
@@ -42,6 +44,14 @@ const HEADER = '# Yoke runtime artifacts (managed by yoke retrofit)';
42
44
  // lines not already present (matched verbatim, line-wise). Preserves existing
43
45
  // content. Returns true if the file changed.
44
46
  export function ensureGitignore(targetDir) {
47
+ if (existsSync(join(targetDir, '.yoke', 'supervision'))) {
48
+ try {
49
+ const tracked = execFileSync('git', ['ls-files', '--', '.yoke/supervision/'], { cwd: targetDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
50
+ if (tracked)
51
+ console.warn('Yoke supervision files are already tracked; ignore rules preserve their index entries. Review and explicitly untrack these runtime files before the next loop.');
52
+ }
53
+ catch { /* A retrofit may precede git initialization. */ }
54
+ }
45
55
  const file = join(targetDir, '.gitignore');
46
56
  const current = existsSync(file) ? readFileSync(file, 'utf8') : '';
47
57
  const present = new Set(current.split(/\r?\n/).map((l) => l.trim()));
@@ -1,8 +1,23 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { loadManifest } from '../../canon/manifest.js';
4
4
  import { mcpServers, rtkInstruction } from '../tools.js';
5
5
  import { skillPackageActions } from '../skill-actions.js';
6
+ import { mergeJson } from '../merge-json.js';
7
+ function codexHooks(targetDir) {
8
+ const path = join(targetDir, '.codex/hooks.json');
9
+ const current = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {};
10
+ // Remove only the exact Yoke adapter command; keep other hooks in its block.
11
+ if (Array.isArray(current.hooks?.PreToolUse)) {
12
+ current.hooks.PreToolUse = current.hooks.PreToolUse.flatMap((block) => {
13
+ if (!Array.isArray(block.hooks))
14
+ return [block];
15
+ const hooks = block.hooks.filter((hook) => hook.command !== 'node "$(git rev-parse --show-toplevel)/.codex/hooks/rtk.mjs"');
16
+ return hooks.length ? [{ ...block, hooks }] : [];
17
+ });
18
+ }
19
+ return JSON.stringify(mergeJson(current, { hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'rtk hook codex' }] }] } }), null, 2) + '\n';
20
+ }
6
21
  function tomlMcp(codeGraph, codeIntelligence, targetDir) {
7
22
  const servers = mcpServers(codeGraph, codeIntelligence, targetDir);
8
23
  // Codex reads MCP servers from ~/.codex/config.toml. This project-level file is a
@@ -37,23 +52,8 @@ export function planCodex(canonDir, targetDir, codeGraph = 'graphify', codeIntel
37
52
  }, {
38
53
  kind: 'write',
39
54
  target: '.codex/hooks.json',
40
- merge: true,
41
- content: JSON.stringify({
42
- description: 'Yoke command compression for Codex',
43
- hooks: {
44
- PreToolUse: [{
45
- matcher: '^Bash$',
46
- hooks: [{
47
- type: 'command',
48
- command: 'node "$(git rev-parse --show-toplevel)/.codex/hooks/rtk.mjs"',
49
- commandWindows: 'powershell -NoProfile -ExecutionPolicy Bypass -Command "$root = git rev-parse --show-toplevel; node (Join-Path $root \'.codex/hooks/rtk.mjs\')"',
50
- timeout: 5,
51
- statusMessage: 'Compressing command output with RTK',
52
- }],
53
- }],
54
- },
55
- }, null, 2) + '\n',
56
- reason: 'rtk PreToolUse hook adapter',
55
+ content: codexHooks(targetDir),
56
+ reason: 'native RTK Codex hook; preserves foreign hooks and migrates the Yoke adapter',
57
57
  }, {
58
58
  kind: 'write',
59
59
  target: '.codex/hooks/rtk.mjs',
@@ -62,8 +62,8 @@ export function planCodex(canonDir, targetDir, codeGraph = 'graphify', codeIntel
62
62
  }, {
63
63
  kind: 'write',
64
64
  target: 'RTK.md',
65
- content: rtkInstruction() + '\n',
66
- reason: 'rtk instruction (Codex has no rewrite hook)',
65
+ content: rtkInstruction() + '\n\nCodex uses `rtk hook codex` when supported by the installed RTK. Run `yoke tools-preflight --json` to check native rewriting. Nested code-mode shell calls remain unverified; explicitly prefix verbose commands with RTK.\n',
66
+ reason: 'RTK guidance and operational verification',
67
67
  });
68
68
  for (const [name, description, sandbox, instructions] of roles) {
69
69
  actions.push({
@@ -114,6 +114,8 @@ function callUsage(role, provider, selection, tokens, durationMs, profile) {
114
114
  ...(selection.reasoningEffort ? { requestedReasoningEffort: selection.reasoningEffort } : {}),
115
115
  ...(selection.variant ? { requestedVariant: selection.variant } : {}),
116
116
  ...(tokens?.model ? { actualModel: tokens.model } : {}),
117
+ usageMissingFields: tokens?.usageMissingFields ?? (!tokens ? ['inputTokens', 'outputTokens', 'cachedInputTokens', 'reasoningOutputTokens', 'totalCostUsd'] : []),
118
+ ...(tokens?.usagePartialFields ? { usagePartialFields: tokens.usagePartialFields } : {}),
117
119
  inputTokens: tokens?.inputTokens ?? 0,
118
120
  ...(tokens?.cachedInputTokens !== undefined ? { cachedInputTokens: tokens.cachedInputTokens } : {}),
119
121
  ...(tokens?.cacheWriteInputTokens !== undefined ? { cacheWriteInputTokens: tokens.cacheWriteInputTokens } : {}),
@@ -16,21 +16,36 @@ const CONFIG_GUIDANCE = [
16
16
  ' landmark: "main h1"',
17
17
  ].join('\n');
18
18
  export async function launchPlaywright(targetDir) {
19
+ const req = createRequire(join(resolve(targetDir), 'package.json'));
19
20
  try {
20
- // createRequire needs an absolute anchor — a relative targetDir (the CLI
21
- // default '.') would throw and masquerade as "playwright not found".
22
- // Playwright is CJS, so load it with native require() rather than a
23
- // file:// dynamic import — the URL round-trip breaks under Windows 8.3
24
- // short paths (e.g. RUNNER~1 on CI) and test-runner import interception.
25
- const req = createRequire(join(resolve(targetDir), 'package.json'));
26
- const pw = req('playwright');
27
- const chromium = pw.chromium ?? pw.default?.chromium;
28
- if (!chromium)
21
+ req.resolve('playwright');
22
+ }
23
+ catch (error) {
24
+ if (error.code === 'MODULE_NOT_FOUND')
29
25
  return null;
26
+ throw Object.assign(new Error(`Playwright resolution failed: ${String(error)}`, { cause: error }), { code: 'browser-package-failed' });
27
+ }
28
+ // Native CJS loading also preserves Windows short-path compatibility.
29
+ let chromium;
30
+ try {
31
+ const pw = req('playwright');
32
+ chromium = pw.chromium ?? pw.default?.chromium;
33
+ }
34
+ catch (error) {
35
+ throw Object.assign(new Error(`Playwright package could not load: ${String(error)}`, { cause: error }), { code: 'browser-package-failed' });
36
+ }
37
+ if (!chromium)
38
+ throw Object.assign(new Error('Installed Playwright package has no chromium export'), { code: 'browser-export-missing' });
39
+ try {
30
40
  return await chromium.launch({ headless: true, timeout: 30_000 });
31
41
  }
32
- catch {
33
- return null;
42
+ catch (error) {
43
+ const detail = error instanceof Error ? error.message : String(error);
44
+ const code = /executable doesn't exist|executable does not exist/i.test(detail) ? 'browser-binary-missing'
45
+ : /no usable sandbox|running as root without --no-sandbox/i.test(detail) ? 'browser-sandbox-failed'
46
+ : /\bEPERM\b|\bEACCES\b|permission denied|operation not permitted/i.test(detail) ? 'browser-permission-denied'
47
+ : 'browser-launch-failed';
48
+ throw Object.assign(new Error(detail, { cause: error }), { code });
34
49
  }
35
50
  }
36
51
  // Flow names come from user config and become filenames — keep them safe.
@@ -44,6 +59,32 @@ export function safeLabel(label) {
44
59
  const cleaned = label.replace(/[^\w-]+/g, '-').replace(/^-+|-+$/g, '');
45
60
  return cleaned || 'latest';
46
61
  }
62
+ async function verifyServedSource(baseUrl, identity) {
63
+ const origin = new URL(baseUrl);
64
+ const url = new URL(identity.path, origin);
65
+ if (!['http:', 'https:'].includes(url.protocol) || url.origin !== origin.origin)
66
+ return false;
67
+ const response = await fetch(url, { signal: AbortSignal.timeout(5000), redirect: 'error' });
68
+ if (!response.ok || !response.body)
69
+ return false;
70
+ const reader = response.body.getReader(), hash = createHash('sha256');
71
+ let bytes = 0;
72
+ try {
73
+ while (true) {
74
+ const { done, value } = await reader.read();
75
+ if (done)
76
+ break;
77
+ bytes += value.byteLength;
78
+ if (bytes > 1024 * 1024)
79
+ return false;
80
+ hash.update(value);
81
+ }
82
+ return hash.digest('hex') === identity.sha256;
83
+ }
84
+ finally {
85
+ await reader.cancel().catch(() => { });
86
+ }
87
+ }
47
88
  class SmokeFailure extends Error {
48
89
  code;
49
90
  constructor(code) {
@@ -271,12 +312,33 @@ export async function runFlowSmoke(targetDir, opts = {}) {
271
312
  const configDigest = createHash('sha256').update(JSON.stringify({ smoke, baseUrl, resolvedFillValues: [...fillValues.values()] })).digest('hex');
272
313
  const redact = (text) => [...fillValues.values()].filter((value) => !!value).sort((a, b) => b.length - a.length).reduce((current, value) => current.split(value).join('[redacted]'), text);
273
314
  const launch = opts.launch ?? launchPlaywright;
315
+ if (!opts.launch && !smoke.sourceIdentity) {
316
+ console.error('Smoke sourceIdentity is required for a production browser run: configure a served path and expected SHA-256 for this source build; previous evidence was preserved.');
317
+ return 2;
318
+ }
319
+ if (smoke.sourceIdentity) {
320
+ let matched = false;
321
+ try {
322
+ matched = await verifyServedSource(baseUrl, smoke.sourceIdentity);
323
+ }
324
+ catch { /* Unavailable identity cannot pass. */ }
325
+ if (!matched) {
326
+ console.error('Smoke served-source identity could not be verified; previous evidence was preserved.');
327
+ return 2;
328
+ }
329
+ }
274
330
  let browser;
275
331
  try {
276
332
  browser = await bounded(() => launch(targetDir), 30_000);
277
333
  }
278
- catch {
279
- console.error('Smoke browser could not start; previous evidence was preserved.');
334
+ catch (error) {
335
+ const rawCode = error?.code;
336
+ const code = typeof rawCode === 'string' && ['browser-package-failed', 'browser-export-missing', 'browser-binary-missing', 'browser-sandbox-failed', 'browser-permission-denied'].includes(rawCode) ? rawCode : 'browser-launch-failed';
337
+ const detail = redact(error instanceof Error ? error.message : String(error))
338
+ .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[redacted]@')
339
+ .replace(/\b((?:access_|refresh_|id_)?token|password|secret|api[_-]?key)=([^\s&]+)/gi, '$1=[redacted]')
340
+ .replace(/\b(authorization\s*:\s*(?:bearer|basic)\s+)[^\s,;]+/gi, '$1[redacted]');
341
+ console.error(`Smoke browser could not start (${code}): ${detail.slice(0, 2000)}; previous evidence was preserved.`);
280
342
  return 2;
281
343
  }
282
344
  if (!browser) {
@@ -311,6 +373,7 @@ export async function runFlowSmoke(targetDir, opts = {}) {
311
373
  }
312
374
  catch { /* Optional runtime metadata. */ }
313
375
  const report = {
376
+ serverIdentity: smoke.sourceIdentity ? 'verified' : 'unverified',
314
377
  version: 1, startedAt: new Date(started).toISOString(), generatedAt: '', durationMs: 0, status: 'failed',
315
378
  sourceFingerprint, afterSourceFingerprint: null, sourceStable: false, configDigest,
316
379
  environment: { platform: process.platform, architecture: process.arch, nodeVersion: process.version, driver: opts.launch ? 'injected' : 'playwright-chromium', ...(browserVersion ? { browserVersion } : {}), baseOrigin: redact(baseOrigin), viewport: { width: 1280, height: 720 } },
@@ -348,6 +411,17 @@ export async function runFlowSmoke(targetDir, opts = {}) {
348
411
  report.afterSourceFingerprint = workspaceFingerprint(targetDir);
349
412
  }
350
413
  catch { /* Missing identity prevents a pass. */ }
414
+ if (smoke.sourceIdentity) {
415
+ let matched = false;
416
+ try {
417
+ matched = await verifyServedSource(baseUrl, smoke.sourceIdentity);
418
+ }
419
+ catch { /* Fail closed. */ }
420
+ if (!matched) {
421
+ report.serverIdentity = 'failed';
422
+ report.failure ??= 'server-identity-failed';
423
+ }
424
+ }
351
425
  report.sourceStable = report.sourceFingerprint === report.afterSourceFingerprint;
352
426
  if (!report.sourceStable)
353
427
  report.failure ??= 'source-changed-or-unavailable';
@@ -69,7 +69,7 @@ export function maybeNotifyUpdate(currentVersion, opts = {}) {
69
69
  if (env.YOKE_NO_UPDATE_CHECK || env.CI)
70
70
  return;
71
71
  const argv = opts.argv ?? process.argv;
72
- if (argv.includes('--json'))
72
+ if (argv.some(arg => ['--json', '--help', '-h'].includes(arg)))
73
73
  return;
74
74
  const tty = opts.tty ?? process.stderr.isTTY === true;
75
75
  if (!tty)
@@ -2,7 +2,7 @@
2
2
 
3
3
  Use executable acceptance criteria to decide whether a release is ready. A build command shows that an artifact can be produced; a journey checks a specific user interaction. Yoke can now record the relationship between those criteria, named journeys and the exact artifact files present during a check.
4
4
 
5
- The delivery declaration belongs in `.yoke/acceptance.yaml`. Browser smoke steps belong in `.yoke/config.yaml`. Existing smoke flows containing only `name`, `path` and an optional `landmark` continue to work.
5
+ The delivery declaration belongs in `.yoke/acceptance.yaml`. Browser smoke steps belong in `.yoke/config.yaml`. Existing flow definitions containing only `name`, `path` and an optional `landmark` remain valid, but production browser runs now also require `smoke.sourceIdentity`.
6
6
 
7
7
  ## Optional browser steps
8
8
 
@@ -13,6 +13,9 @@ For example, add this section to the project's existing `.yoke/config.yaml`:
13
13
  ```yaml
14
14
  smoke:
15
15
  baseUrl: http://localhost:3000
16
+ sourceIdentity:
17
+ path: /assets/app-specific-static-source.js
18
+ sha256: "<replace with SHA-256 of the served bytes>"
16
19
  flows:
17
20
  - name: profile-survives-reload
18
21
  path: /login
@@ -51,6 +54,10 @@ smoke:
51
54
  exact: true
52
55
  ```
53
56
 
57
+ Replace the source resource path and hash placeholder before running this example. Confirm the intended server's checkout/build and port, then fetch a stable, app-specific source resource from the effective `baseUrl` origin and calculate the SHA-256 of the exact served body bytes. Dev servers may transform local source, so hashing a local file alone is insufficient. Set `sha256` to the resulting 64-character lowercase hexadecimal digest. The resource must return a successful HTTP response without redirects, finish within five seconds and contain at most 1 MiB. It must remain stable through the run. Production smoke checks the pin before browser launch and again after the flows; a missing or mismatching pin prevents valid smoke evidence.
58
+
59
+ After an intentional source/build change, verify the server serves that change and explicitly refresh the pin. Do not update it automatically on a mismatch or accept a foreign process occupying the expected port. Choose a resource that changes with the relevant app source, rather than a shared health response or an unchanged marker. The static pin checks only that resource's bytes before and after the run; it does not independently prove the identity of every module, backend or dynamic response. Source fingerprints and other acceptance checks remain necessary.
60
+
54
61
  Supply `SMOKE_TEST_PASSWORD` through the environment using credentials for a dedicated test account, then run:
55
62
 
56
63
  ```sh