@hecer/yoke 1.21.1 → 1.22.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 (63) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +27 -0
  4. package/README.md +6 -1
  5. package/bench/analyze-codex-comparison.mjs +90 -17
  6. package/bench/compare-codex.mjs +159 -36
  7. package/bench/result-schema.mjs +132 -0
  8. package/canon/manifest.yaml +1 -1
  9. package/dist/agents/pi-telemetry.js +2 -1
  10. package/dist/agents/process-streams.js +12 -64
  11. package/dist/agents/provider-selection.js +12 -0
  12. package/dist/agents/telemetry.js +52 -52
  13. package/dist/change/inbox.js +8 -3
  14. package/dist/check/command.js +69 -17
  15. package/dist/check/delivery.js +121 -0
  16. package/dist/cli.js +12 -3
  17. package/dist/code-intelligence/budgets.js +138 -0
  18. package/dist/code-intelligence/contracts.js +2 -0
  19. package/dist/code-intelligence/coordinator.js +156 -84
  20. package/dist/code-intelligence/evidence.js +87 -34
  21. package/dist/code-intelligence/mcp-client.js +10 -2
  22. package/dist/code-intelligence/mcp-server.js +10 -10
  23. package/dist/dashboard/analytics.js +5 -3
  24. package/dist/goals/command.js +183 -53
  25. package/dist/goals/usage.js +87 -0
  26. package/dist/loop/candidate-cleanup.js +47 -17
  27. package/dist/loop/candidates.js +17 -11
  28. package/dist/loop/dispatcher.js +89 -26
  29. package/dist/loop/failure.js +104 -0
  30. package/dist/loop/gate-snapshot.js +19 -0
  31. package/dist/loop/git.js +1 -1
  32. package/dist/loop/loop.js +100 -66
  33. package/dist/loop/parallel-adapters.js +22 -4
  34. package/dist/loop/parallel-command.js +32 -5
  35. package/dist/loop/recovery.js +23 -5
  36. package/dist/loop/reporter.js +21 -4
  37. package/dist/loop/run-command.js +95 -46
  38. package/dist/loop/runner.js +5 -4
  39. package/dist/loop/worker.js +145 -91
  40. package/dist/observability/history.js +1 -1
  41. package/dist/observability/invocation.js +42 -0
  42. package/dist/observability/usage.js +17 -0
  43. package/dist/prd/command.js +20 -7
  44. package/dist/prd/decompose.js +5 -2
  45. package/dist/retrofit/config.js +26 -1
  46. package/dist/retrofit/gitignore.js +2 -0
  47. package/dist/routing/attempts.js +241 -0
  48. package/dist/routing/capability.js +13 -9
  49. package/dist/routing/optimization.js +73 -0
  50. package/dist/routing/registry.js +7 -1
  51. package/dist/routing/router.js +280 -127
  52. package/dist/setup/command.js +8 -2
  53. package/dist/smoke/command.js +302 -74
  54. package/docs/BENCHMARK-MANIFEST.md +131 -0
  55. package/docs/CODE-INTELLIGENCE.md +43 -1
  56. package/docs/CODEX-COMPARISON-2026-09-29.md +15 -0
  57. package/docs/DELIVERY-JOURNEYS.md +199 -0
  58. package/docs/ECONOMIC-ROUTING.md +180 -0
  59. package/docs/GOALS.md +61 -4
  60. package/docs/RELEASE-VALIDATION-1.22.0.md +115 -0
  61. package/docs/parallel-execution.md +37 -9
  62. package/gemini-extension.json +1 -1
  63. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { parseProviderTelemetry } from './telemetry.js';
1
+ import { createStepTelemetry, parseProviderTelemetry } from './telemetry.js';
2
2
  import { createPiTelemetry } from './pi-telemetry.js';
3
3
  export function createBoundedOutput(limitBytes) {
4
4
  let text = '';
@@ -22,11 +22,17 @@ export function createTelemetryAccumulator(agent) {
22
22
  let trailing = '';
23
23
  let telemetry = { usageAvailable: false };
24
24
  let reportedModels = [];
25
- const stepTotals = agent === 'opencode' || agent === 'kilo'
26
- ? { input: 0, output: 0, cached: 0, cacheWrite: 0, reasoning: 0, cost: 0, hasInput: false, hasOutput: false, hasCached: false, hasCacheWrite: false, hasReasoning: false, hasCost: false }
27
- : undefined;
25
+ const steps = agent === 'opencode' || agent === 'kilo' ? createStepTelemetry() : undefined;
28
26
  const update = (lines) => {
29
27
  for (const line of lines) {
28
+ if (steps) {
29
+ try {
30
+ const event = JSON.parse(line);
31
+ if (event && typeof event === 'object' && !Array.isArray(event))
32
+ steps.consume(event);
33
+ }
34
+ catch { /* non-JSON diagnostics carry no usage */ }
35
+ }
30
36
  if (pi) {
31
37
  try {
32
38
  const event = JSON.parse(line);
@@ -45,35 +51,6 @@ export function createTelemetryAccumulator(agent) {
45
51
  // never add it to earlier results or to assistant-message snapshots.
46
52
  if (next.tokens || next.partialUsage)
47
53
  telemetry = next;
48
- if (stepTotals && isStepFinish(line)) {
49
- const usage = next.tokens ?? next.partialUsage;
50
- if (usage) {
51
- if (usage.inputTokens !== undefined) {
52
- stepTotals.input += usage.inputTokens;
53
- stepTotals.hasInput = true;
54
- }
55
- if (usage.outputTokens !== undefined) {
56
- stepTotals.output += usage.outputTokens;
57
- stepTotals.hasOutput = true;
58
- }
59
- if (usage.cachedInputTokens !== undefined) {
60
- stepTotals.cached += usage.cachedInputTokens;
61
- stepTotals.hasCached = true;
62
- }
63
- if (usage.cacheWriteInputTokens !== undefined) {
64
- stepTotals.cacheWrite += usage.cacheWriteInputTokens;
65
- stepTotals.hasCacheWrite = true;
66
- }
67
- if (usage.reasoningOutputTokens !== undefined) {
68
- stepTotals.reasoning += usage.reasoningOutputTokens;
69
- stepTotals.hasReasoning = true;
70
- }
71
- if (usage.totalCostUsd !== undefined) {
72
- stepTotals.cost += usage.totalCostUsd;
73
- stepTotals.hasCost = true;
74
- }
75
- }
76
- }
77
54
  }
78
55
  };
79
56
  return {
@@ -88,42 +65,13 @@ export function createTelemetryAccumulator(agent) {
88
65
  trailing = '';
89
66
  if (pi)
90
67
  return pi.finish();
91
- if (stepTotals && (stepTotals.hasInput || stepTotals.hasOutput)) {
92
- const latest = telemetry.tokens;
93
- const inputTokens = stepTotals.hasInput ? stepTotals.input : latest?.inputTokens;
94
- const outputTokens = stepTotals.hasOutput ? stepTotals.output : latest?.outputTokens;
95
- const partialUsage = {
96
- ...(inputTokens !== undefined ? { inputTokens } : {}),
97
- ...(outputTokens !== undefined ? { outputTokens } : {}),
98
- ...(stepTotals.hasCached ? { cachedInputTokens: stepTotals.cached } : latest?.cachedInputTokens !== undefined ? { cachedInputTokens: latest.cachedInputTokens } : {}),
99
- ...(stepTotals.hasCacheWrite ? { cacheWriteInputTokens: stepTotals.cacheWrite } : latest?.cacheWriteInputTokens !== undefined ? { cacheWriteInputTokens: latest.cacheWriteInputTokens } : {}),
100
- ...(stepTotals.hasReasoning ? { reasoningOutputTokens: stepTotals.reasoning } : latest?.reasoningOutputTokens !== undefined ? { reasoningOutputTokens: latest.reasoningOutputTokens } : {}),
101
- ...(stepTotals.hasCost ? { totalCostUsd: stepTotals.cost } : latest?.totalCostUsd !== undefined ? { totalCostUsd: latest.totalCostUsd } : {}),
102
- ...(latest?.model ? { model: latest.model } : {}),
103
- };
104
- if (inputTokens !== undefined && outputTokens !== undefined) {
105
- telemetry = { usageAvailable: true, tokens: { ...partialUsage, inputTokens, outputTokens } };
106
- }
107
- else {
108
- telemetry = { usageAvailable: false, partialUsage };
109
- }
110
- }
68
+ telemetry = steps?.finish() ?? telemetry;
111
69
  if (telemetry.tokens) {
112
70
  const { model: _model, ...tokens } = telemetry.tokens;
113
- return { usageAvailable: telemetry.usageAvailable, tokens: { ...tokens, ...(reportedModels.length === 1 ? { model: reportedModels[0] } : {}) },
71
+ return { ...telemetry, tokens: { ...tokens, ...(reportedModels.length === 1 ? { model: reportedModels[0] } : {}) },
114
72
  ...(reportedModels.length > 1 ? { reportedModels } : {}) };
115
73
  }
116
74
  return { ...telemetry, ...(reportedModels.length ? { reportedModels } : {}) };
117
75
  },
118
76
  };
119
77
  }
120
- function isStepFinish(line) {
121
- try {
122
- const value = JSON.parse(line);
123
- const part = value.part && typeof value.part === 'object' ? value.part : undefined;
124
- return value.type === 'step_finish' || part?.type === 'step-finish';
125
- }
126
- catch {
127
- return false;
128
- }
129
- }
@@ -0,0 +1,12 @@
1
+ /** Runner defaults belong to the runner that selected them. Undefined overrides
2
+ * are absent; explicit false values (for example bare: false) still override. */
3
+ export function resolveProviderSelection(agent, defaults, overrides = {}) {
4
+ const inherited = defaults && (defaults.agent ?? 'codex') === agent ? defaults : {};
5
+ const result = {};
6
+ for (const key of ['provider', 'model', 'reasoningEffort', 'variant', 'bare', 'nativeMultiAgent']) {
7
+ const value = overrides[key] ?? inherited[key];
8
+ if (value !== undefined)
9
+ Object.assign(result, { [key]: value });
10
+ }
11
+ return result;
12
+ }
@@ -13,6 +13,50 @@ function parseJson(value) {
13
13
  function isRecord(value) {
14
14
  return typeof value === 'object' && value !== null && !Array.isArray(value);
15
15
  }
16
+ /** OpenCode-family usage belongs to individual completed steps. Each field needs
17
+ * coverage of every step before it can be called a total. Shared by batch and
18
+ * streaming readers so an omitted measurement cannot turn into a measured zero. */
19
+ export function createStepTelemetry() {
20
+ let steps = 0;
21
+ const totals = {};
22
+ const counts = {};
23
+ return {
24
+ consume(event) {
25
+ const part = isRecord(event.part) ? event.part : undefined;
26
+ if (event.type !== 'step_finish' && part?.type !== 'step-finish')
27
+ return;
28
+ steps++;
29
+ const tokens = isRecord(part?.tokens) ? part.tokens : {};
30
+ const cache = isRecord(tokens.cache) ? tokens.cache : undefined;
31
+ const fields = {
32
+ inputTokens: tokens.input,
33
+ outputTokens: tokens.output,
34
+ cachedInputTokens: tokens.cached ?? tokens.cacheRead ?? cache?.read,
35
+ cacheWriteInputTokens: tokens.cacheWrite ?? cache?.write,
36
+ reasoningOutputTokens: tokens.reasoning,
37
+ totalCostUsd: part?.cost ?? event.cost,
38
+ };
39
+ for (const [field, raw] of Object.entries(fields)) {
40
+ const value = finite(raw);
41
+ if (value === undefined)
42
+ continue;
43
+ totals[field] = (totals[field] ?? 0) + value;
44
+ counts[field] = (counts[field] ?? 0) + 1;
45
+ }
46
+ },
47
+ finish() {
48
+ if (!steps)
49
+ return undefined;
50
+ const complete = counts.inputTokens === steps && counts.outputTokens === steps;
51
+ if (!complete)
52
+ return { usageAvailable: false, ...(Object.keys(totals).length ? { partialUsage: { ...totals } } : {}) };
53
+ const measured = Object.fromEntries(Object.entries(totals).filter(([field]) => counts[field] === steps));
54
+ const partialUsage = Object.fromEntries(Object.entries(totals).filter(([field]) => counts[field] !== steps));
55
+ return { usageAvailable: true, tokens: { ...measured, inputTokens: totals.inputTokens, outputTokens: totals.outputTokens },
56
+ ...(Object.keys(partialUsage).length ? { partialUsage } : {}) };
57
+ },
58
+ };
59
+ }
16
60
  function textContent(value) {
17
61
  if (typeof value === 'string')
18
62
  return value;
@@ -154,9 +198,7 @@ export function parseProviderTelemetry(agent, lines) {
154
198
  let totalCostUsd;
155
199
  let model;
156
200
  let reportedModels = [];
157
- const harnessTotals = agent === 'opencode' || agent === 'kilo'
158
- ? { input: 0, output: 0, cached: 0, cacheWrite: 0, reasoning: 0, cost: 0, hasInput: false, hasOutput: false, hasCached: false, hasCacheWrite: false, hasReasoning: false, hasCost: false }
159
- : undefined;
201
+ const steps = agent === 'opencode' || agent === 'kilo' ? createStepTelemetry() : undefined;
160
202
  for (const line of lines) {
161
203
  let parsed;
162
204
  try {
@@ -168,45 +210,11 @@ export function parseProviderTelemetry(agent, lines) {
168
210
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
169
211
  continue;
170
212
  const event = parsed;
213
+ steps?.consume(event);
171
214
  if ((agent === 'qwen' || agent === 'claude') && event.parent_tool_use_id != null)
172
215
  continue;
173
216
  const message = event.message && typeof event.message === 'object' ? event.message : undefined;
174
217
  const stats = event.stats && typeof event.stats === 'object' ? event.stats : undefined;
175
- const part = event.part && typeof event.part === 'object' ? event.part : undefined;
176
- const partTokens = part?.tokens && typeof part.tokens === 'object' ? part.tokens : undefined;
177
- if (harnessTotals && (event.type === 'step_finish' || part?.type === 'step-finish') && partTokens) {
178
- const cache = partTokens.cache && typeof partTokens.cache === 'object' ? partTokens.cache : undefined;
179
- const stepInput = finite(partTokens.input);
180
- const stepOutput = finite(partTokens.output);
181
- const stepCached = finite(partTokens.cached ?? partTokens.cacheRead ?? cache?.read);
182
- const stepCacheWrite = finite(partTokens.cacheWrite ?? cache?.write);
183
- const stepReasoning = finite(partTokens.reasoning);
184
- const stepCost = finite(part?.cost ?? event.cost);
185
- if (stepInput !== undefined) {
186
- harnessTotals.input += stepInput;
187
- harnessTotals.hasInput = true;
188
- }
189
- if (stepOutput !== undefined) {
190
- harnessTotals.output += stepOutput;
191
- harnessTotals.hasOutput = true;
192
- }
193
- if (stepCached !== undefined) {
194
- harnessTotals.cached += stepCached;
195
- harnessTotals.hasCached = true;
196
- }
197
- if (stepCacheWrite !== undefined) {
198
- harnessTotals.cacheWrite += stepCacheWrite;
199
- harnessTotals.hasCacheWrite = true;
200
- }
201
- if (stepReasoning !== undefined) {
202
- harnessTotals.reasoning += stepReasoning;
203
- harnessTotals.hasReasoning = true;
204
- }
205
- if (stepCost !== undefined) {
206
- harnessTotals.cost += stepCost;
207
- harnessTotals.hasCost = true;
208
- }
209
- }
210
218
  const usage = (event.usage && typeof event.usage === 'object'
211
219
  ? event.usage
212
220
  : message?.usage && typeof message.usage === 'object'
@@ -277,20 +285,12 @@ export function parseProviderTelemetry(agent, lines) {
277
285
  if (typeof eventModel === 'string' && eventModel && reportedModels.length <= 1)
278
286
  model = eventModel;
279
287
  }
280
- if (harnessTotals) {
281
- if (harnessTotals.hasInput)
282
- inputTokens = harnessTotals.input;
283
- if (harnessTotals.hasOutput)
284
- outputTokens = harnessTotals.output;
285
- if (harnessTotals.hasCached)
286
- cachedInputTokens = harnessTotals.cached;
287
- if (harnessTotals.hasCacheWrite)
288
- cacheWriteInputTokens = harnessTotals.cacheWrite;
289
- if (harnessTotals.hasReasoning)
290
- reasoningOutputTokens = harnessTotals.reasoning;
291
- if (harnessTotals.hasCost)
292
- totalCostUsd = harnessTotals.cost;
293
- }
288
+ const stepUsage = steps?.finish();
289
+ if (stepUsage)
290
+ return { ...stepUsage,
291
+ ...(stepUsage.tokens && model ? { tokens: { ...stepUsage.tokens, model } } : {}),
292
+ ...(!stepUsage.tokens && model ? { reportedModels: [model] } : {}),
293
+ };
294
294
  if (inputTokens === undefined || outputTokens === undefined) {
295
295
  const partialUsage = {
296
296
  ...(inputTokens !== undefined ? { inputTokens } : {}),
@@ -9,8 +9,9 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, w
9
9
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
10
10
  import { z } from 'zod';
11
11
  import { criterionCommandProblem, isAcceptanceCriterion, loadPrd, parsePrd, savePrd, validateDependencies } from '../loop/prd.js';
12
- import { agentInvocation, buildWatchdogInvocation, isAgentAvailable, runAgent, } from '../loop/runner.js';
12
+ import { agentInvocation, buildWatchdogInvocation, isAgentAvailable, runCapturedAgent, } from '../loop/runner.js';
13
13
  import { commitPaths } from '../loop/git.js';
14
+ import { measureInvocation } from '../observability/invocation.js';
14
15
  const ChangeRequestSchema = z.object({
15
16
  version: z.literal(1),
16
17
  id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
@@ -184,7 +185,9 @@ export function runChangeApply(targetDir, opts) {
184
185
  rmSync(proposal, { force: true });
185
186
  const base = agentInvocation(opts.runner, buildChangePrompt(request, proposal, existing, brief), plannerDir, opts.permissions ?? 'safe', opts.selection);
186
187
  const invocation = buildWatchdogInvocation(base, opts.timeoutMs ?? 0);
187
- const result = (opts.run ?? runAgent)(invocation);
188
+ const result = measureInvocation({ root: targetDir, agent: opts.runner, role: 'change-planner', storyId: request.id, runId: `change:${request.id}`, selection: opts.selection, invocation,
189
+ execute: opts.run ?? (item => runCapturedAgent(opts.runner, item)),
190
+ });
188
191
  if (!result.success)
189
192
  return { ok: false, added: 0, summary: `planner failed: ${result.summary}`, changeId: request.id };
190
193
  if (readFileSync(prdPath, 'utf8') !== existingText || (readPlanningFile(targetDir, '.yoke/plan.md', 80_000) ?? '') !== brief) {
@@ -246,7 +249,9 @@ export function runChangeApply(targetDir, opts) {
246
249
  rmSync(reviewPath, { force: true });
247
250
  const reviewBase = agentInvocation(reviewer, buildChangeReviewPrompt(request, reviewPath, appended), plannerDir, opts.permissions ?? 'safe', reviewer === opts.runner ? opts.selection : undefined);
248
251
  const reviewInvocation = buildWatchdogInvocation(reviewBase, opts.timeoutMs ?? 0);
249
- const reviewResult = (opts.review ?? runAgent)(reviewInvocation);
252
+ const reviewResult = measureInvocation({ root: targetDir, agent: reviewer, role: 'coverage-review', storyId: request.id, runId: `change:${request.id}`, selection: reviewer === opts.runner ? opts.selection : undefined, invocation: reviewInvocation,
253
+ execute: opts.review ?? (item => runCapturedAgent(reviewer, item)),
254
+ });
250
255
  if (!reviewResult.success) {
251
256
  return { ok: false, added: 0, summary: `coverage review failed: ${reviewResult.summary}`, changeId: request.id };
252
257
  }
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
2
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, writeFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
5
  import { parse } from 'yaml';
@@ -15,10 +15,17 @@ import { writeOutputArtifact } from '../output/artifact.js';
15
15
  import { DEFAULT_OUTPUT_POLICY } from '../output/types.js';
16
16
  import { createProviderProcessRecord, filesystemProviderProcessRecordAdapter } from '../agents/process-record.js';
17
17
  import { trackProcessRecordIdentity } from '../agents/process-record-identity.js';
18
+ import { DeliverySchema, startDelivery, finishDelivery } from './delivery.js';
18
19
  const Criterion = z.object({ id: z.string().min(1).max(120), text: z.string().min(1).max(8000), commands: z.array(z.string().min(1).max(8000)).max(30) }).strict();
19
- const Acceptance = z.object({ version: z.literal(1), criteria: z.array(Criterion).max(200), protected: z.array(z.string().min(1)).max(500).default([]) }).strict().superRefine((value, ctx) => {
20
+ const Acceptance = z.object({ version: z.literal(1), criteria: z.array(Criterion).max(200), protected: z.array(z.string().min(1)).max(500).default([]), delivery: DeliverySchema.optional() }).strict().superRefine((value, ctx) => {
20
21
  if (new Set(value.criteria.map(c => c.id)).size !== value.criteria.length)
21
22
  ctx.addIssue({ code: 'custom', message: 'Duplicate acceptance criterion id' });
23
+ const ids = new Set(value.criteria.map(criterion => criterion.id));
24
+ for (const item of [...(value.delivery?.artifacts ?? []), ...(value.delivery?.journeys ?? [])]) {
25
+ for (const id of item.criteria)
26
+ if (!ids.has(id))
27
+ ctx.addIssue({ code: 'custom', path: ['delivery'], message: `Delivery references unknown criterion: ${id}` });
28
+ }
22
29
  });
23
30
  export async function checkProjectAsync(directory, options = {}) {
24
31
  const controller = new AbortController();
@@ -162,8 +169,36 @@ function verifyCommandAsync(command, root, signal) {
162
169
  });
163
170
  }
164
171
  export function loadAcceptance(root) {
172
+ return loadAcceptanceSnapshot(root).manifest;
173
+ }
174
+ /** Hash the exact bounded bytes that were parsed; never reread an untrusted path for the digest. */
175
+ function loadAcceptanceSnapshot(root) {
165
176
  const file = statePath(root, 'acceptance.yaml');
166
- return existsSync(file) ? Acceptance.parse(parse(readFileSync(file, 'utf8'))) : null;
177
+ if (!existsSync(file))
178
+ return { manifest: null, digest: null };
179
+ const fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
180
+ try {
181
+ const before = fstatSync(fd);
182
+ if (!before.isFile() || before.size > 64 * 1024 * 1024)
183
+ throw Error('Acceptance must be a regular file of at most 64 MiB');
184
+ const bytes = Buffer.alloc(before.size + 1);
185
+ let length = 0;
186
+ while (length < bytes.length) {
187
+ const count = readSync(fd, bytes, length, bytes.length - length, null);
188
+ if (!count)
189
+ break;
190
+ length += count;
191
+ }
192
+ const after = fstatSync(fd), named = lstatSync(statePath(root, 'acceptance.yaml'));
193
+ if (length !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs ||
194
+ named.ino !== before.ino || named.dev !== before.dev || named.size !== before.size || named.mtimeMs !== before.mtimeMs || named.ctimeMs !== before.ctimeMs)
195
+ throw Error('Acceptance changed while loading');
196
+ const content = bytes.subarray(0, length);
197
+ return { manifest: Acceptance.parse(parse(content.toString('utf8'))), digest: createHash('sha256').update(content).digest('hex') };
198
+ }
199
+ finally {
200
+ closeSync(fd);
201
+ }
167
202
  }
168
203
  function protectedPath(root, path) {
169
204
  if (isAbsolute(path))
@@ -209,17 +244,41 @@ export function acceptanceProtectionProblem(root, baselineRoot = root) {
209
244
  return `Protected acceptance cannot be verified: ${error.message}`;
210
245
  }
211
246
  }
247
+ function finalizeDelivery(root, manifest, start, fingerprint, criteria, options = {}) {
248
+ let changed = false;
249
+ const delivery = finishDelivery(root, manifest, start, fingerprint, criteria, { ...options, checkIntegrity: () => {
250
+ let problem = null;
251
+ try {
252
+ changed = workspaceFingerprint(root) !== fingerprint;
253
+ }
254
+ catch (error) {
255
+ changed = true;
256
+ problem = `Checked source cannot be verified: ${error.message}`;
257
+ }
258
+ problem = problem ?? acceptanceProtectionProblem(root) ?? (changed ? 'Source changed during verification; run check again on a stable tree' : null);
259
+ if (problem)
260
+ criteria.push({ id: 'source-integrity', text: 'Checked source remained stable', commands: [], status: 'failed', summary: problem });
261
+ if (options.signal?.aborted || (options.deadline !== undefined && Date.now() >= options.deadline)) {
262
+ const cancelled = 'Verification cancelled or deadline reached';
263
+ criteria.push({ id: 'verification-cancelled', text: 'Verification completed within its allowed run', commands: [], status: 'failed', summary: cancelled });
264
+ problem = problem ?? cancelled;
265
+ }
266
+ return problem;
267
+ } });
268
+ return { delivery, changed };
269
+ }
212
270
  export function checkProject(directory, options = {}) {
213
271
  const root = realpathSync(directory);
214
272
  const started = Date.now();
215
273
  const before = workspaceFingerprint(root);
216
274
  const problem = acceptanceProtectionProblem(root);
275
+ const { manifest, digest } = problem ? { manifest: null, digest: null } : loadAcceptanceSnapshot(root);
276
+ const deliveryStart = startDelivery(root, manifest, digest);
217
277
  const criteria = [];
218
278
  const execute = options.execute ?? ((command, cwd) => commandVerifier(command, { phase: 'verify' })(cwd));
219
279
  if (problem)
220
280
  criteria.push({ id: 'protected-acceptance', text: 'Acceptance infrastructure unchanged', commands: [], status: 'failed', summary: problem });
221
281
  else {
222
- const manifest = loadAcceptance(root);
223
282
  for (const criterion of manifest?.criteria ?? []) {
224
283
  const results = criterion.commands.map(command => {
225
284
  try {
@@ -247,14 +306,11 @@ export function checkProject(directory, options = {}) {
247
306
  if (criteria.length === 0)
248
307
  criteria.push({ id: 'acceptance', text: 'Project acceptance', commands: [], status: 'unverified', summary: 'No acceptance manifest or project verification command found' });
249
308
  }
250
- const changed = workspaceFingerprint(root) !== before;
251
- const afterProblem = acceptanceProtectionProblem(root);
252
- if (changed || afterProblem)
253
- criteria.push({ id: 'source-integrity', text: 'Checked source remained stable', commands: [], status: 'failed', summary: afterProblem ?? 'Source changed during verification; run check again on a stable tree' });
309
+ const { changed, delivery } = finalizeDelivery(root, manifest, deliveryStart, before, criteria);
254
310
  const status = criteria.some(c => c.status === 'failed') ? 'failed' : criteria.some(c => c.status === 'unverified') ? 'unverified' : 'passed';
255
311
  const id = randomUUID();
256
312
  const evidencePath = statePath(root, 'checks', `${id}.json`);
257
- const report = { version: 1, id, generatedAt: new Date().toISOString(), fingerprint: before, status, summary: changed ? 'Source changed during verification' : `${criteria.filter(c => c.status === 'passed').length}/${criteria.length} checks passed; ${status}`, criteria, durationMs: Date.now() - started, evidencePath };
313
+ const report = { version: 1, id, generatedAt: new Date().toISOString(), fingerprint: before, status, summary: changed ? 'Source changed during verification' : `${criteria.filter(c => c.status === 'passed').length}/${criteria.length} checks passed; ${status}`, criteria, delivery, durationMs: Date.now() - started, evidencePath };
258
314
  mkdirSync(dirname(evidencePath), { recursive: true });
259
315
  writeFileSync(evidencePath, JSON.stringify(report, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
260
316
  return report;
@@ -265,6 +321,8 @@ async function checkProjectAsyncAdmitted(directory, options) {
265
321
  const started = Date.now();
266
322
  const before = workspaceFingerprint(root);
267
323
  const problem = acceptanceProtectionProblem(root);
324
+ const { manifest, digest } = problem ? { manifest: null, digest: null } : loadAcceptanceSnapshot(root);
325
+ const deliveryStart = startDelivery(root, manifest, digest, options);
268
326
  const criteria = [];
269
327
  let cleanupUnconfirmed = false;
270
328
  const rawExecute = options.execute ?? ((command, cwd) => verifyCommandAsync(command, cwd, options.signal));
@@ -281,7 +339,6 @@ async function checkProjectAsyncAdmitted(directory, options) {
281
339
  if (problem)
282
340
  criteria.push({ id: 'protected-acceptance', text: 'Acceptance infrastructure unchanged', commands: [], status: 'failed', summary: problem });
283
341
  else {
284
- const manifest = loadAcceptance(root);
285
342
  for (const criterion of manifest?.criteria ?? []) {
286
343
  const results = [];
287
344
  for (const command of criterion.commands) {
@@ -316,16 +373,11 @@ async function checkProjectAsyncAdmitted(directory, options) {
316
373
  if (criteria.length === 0)
317
374
  criteria.push({ id: 'acceptance', text: 'Project acceptance', commands: [], status: 'unverified', summary: 'No acceptance manifest or project verification command found' });
318
375
  }
319
- const changed = workspaceFingerprint(root) !== before;
320
- const afterProblem = acceptanceProtectionProblem(root);
321
- if (changed || afterProblem)
322
- criteria.push({ id: 'source-integrity', text: 'Checked source remained stable', commands: [], status: 'failed', summary: afterProblem ?? 'Source changed during verification; run check again on a stable tree' });
323
- if (options.signal?.aborted)
324
- criteria.push({ id: 'verification-cancelled', text: 'Verification completed within its allowed run', commands: [], status: 'failed', summary: 'Verification cancelled or deadline reached' });
376
+ const { changed, delivery } = finalizeDelivery(root, manifest, deliveryStart, before, criteria, options);
325
377
  const status = criteria.some(c => c.status === 'failed') ? 'failed' : criteria.some(c => c.status === 'unverified') ? 'unverified' : 'passed';
326
378
  const id = randomUUID();
327
379
  const evidencePath = statePath(root, 'checks', `${id}.json`);
328
- const report = { ...(cleanupUnconfirmed ? { cleanupUnconfirmed: true } : {}), version: 1, id, generatedAt: new Date().toISOString(), fingerprint: before, status, summary: changed ? 'Source changed during verification' : `${criteria.filter(c => c.status === 'passed').length}/${criteria.length} checks passed; ${status}`, criteria, durationMs: Date.now() - started, evidencePath };
380
+ const report = { ...(cleanupUnconfirmed ? { cleanupUnconfirmed: true } : {}), version: 1, id, generatedAt: new Date().toISOString(), fingerprint: before, status, summary: changed ? 'Source changed during verification' : `${criteria.filter(c => c.status === 'passed').length}/${criteria.length} checks passed; ${status}`, criteria, delivery, durationMs: Date.now() - started, evidencePath };
329
381
  mkdirSync(dirname(evidencePath), { recursive: true });
330
382
  writeFileSync(evidencePath, JSON.stringify(report, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
331
383
  return report;
@@ -0,0 +1,121 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync } from 'node:fs';
3
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ import { z } from 'zod';
5
+ const References = z.array(z.string().min(1).max(120)).min(1).max(200);
6
+ export const DeliverySchema = z.object({
7
+ version: z.literal(1),
8
+ artifacts: z.array(z.object({ path: z.string().min(1).max(1000), criteria: References }).strict()).max(32).default([]),
9
+ journeys: z.array(z.object({ id: z.string().min(1).max(120), text: z.string().min(1).max(8000), criteria: References }).strict()).max(100).default([]),
10
+ environment: z.object({ name: z.string().min(1).max(200), url: z.string().url().max(2000).optional() }).strict().optional(),
11
+ }).strict().superRefine((value, context) => {
12
+ if (new Set(value.artifacts.map(item => item.path)).size !== value.artifacts.length)
13
+ context.addIssue({ code: 'custom', message: 'Duplicate delivery artifact path' });
14
+ if (new Set(value.journeys.map(item => item.id)).size !== value.journeys.length)
15
+ context.addIssue({ code: 'custom', message: 'Duplicate delivery journey id' });
16
+ });
17
+ export const DELIVERY_HASH_LIMITS = Object.freeze({ fileBytes: 512 * 1024 * 1024, totalBytes: 1024 * 1024 * 1024, timeoutMs: 10_000 });
18
+ function snapshotBudget(options) {
19
+ return { signal: options.signal, remaining: DELIVERY_HASH_LIMITS.totalBytes, deadline: Math.min(options.deadline ?? Infinity, Date.now() + DELIVERY_HASH_LIMITS.timeoutMs) };
20
+ }
21
+ function checkBudget(budget) {
22
+ if (budget.signal?.aborted || Date.now() >= budget.deadline)
23
+ throw Error('Artifact hashing cancelled or deadline reached');
24
+ }
25
+ function artifactPath(root, path) {
26
+ if (isAbsolute(path))
27
+ throw Error('Artifact path must be relative');
28
+ const full = resolve(root, path), rel = relative(root, full);
29
+ if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))
30
+ throw Error('Artifact path escapes project');
31
+ let current = root;
32
+ for (const component of rel.split(sep)) {
33
+ current = join(current, component);
34
+ if (lstatSync(current).isSymbolicLink())
35
+ throw Error('Artifact paths must not contain symbolic links');
36
+ }
37
+ if (realpathSync(full) !== full)
38
+ throw Error('Artifact path is not canonical');
39
+ return full;
40
+ }
41
+ /** Bounded bytes and constant memory; the deadline is checked between synchronous reads. */
42
+ function snapshotArtifact(root, path, budget) {
43
+ let fd;
44
+ try {
45
+ checkBudget(budget);
46
+ const full = artifactPath(root, path);
47
+ const named = lstatSync(full);
48
+ if (!named.isFile())
49
+ throw Error('Artifact must be a regular file');
50
+ if (named.size > DELIVERY_HASH_LIMITS.fileBytes)
51
+ throw Error(`Artifact exceeds the ${DELIVERY_HASH_LIMITS.fileBytes} byte file limit`);
52
+ if (named.size > budget.remaining)
53
+ throw Error(`Artifact exceeds the remaining aggregate hash budget (${budget.remaining} bytes)`);
54
+ fd = openSync(full, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
55
+ const before = fstatSync(fd);
56
+ if (!before.isFile() || before.ino !== named.ino || before.dev !== named.dev || before.size !== named.size)
57
+ throw Error('Artifact changed before hashing');
58
+ const hash = createHash('sha256'), buffer = Buffer.alloc(256 * 1024);
59
+ let bytes = 0;
60
+ while (bytes < before.size) {
61
+ checkBudget(budget);
62
+ const count = readSync(fd, buffer, 0, Math.min(buffer.length, before.size - bytes), null);
63
+ if (!count)
64
+ break;
65
+ hash.update(buffer.subarray(0, count));
66
+ bytes += count;
67
+ budget.remaining -= count;
68
+ }
69
+ checkBudget(budget);
70
+ const after = fstatSync(fd);
71
+ const finalNamed = lstatSync(artifactPath(root, path));
72
+ if (bytes !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs ||
73
+ !finalNamed.isFile() || finalNamed.ino !== before.ino || finalNamed.dev !== before.dev || finalNamed.size !== before.size || finalNamed.mtimeMs !== before.mtimeMs || finalNamed.ctimeMs !== before.ctimeMs)
74
+ throw Error('Artifact changed while hashing');
75
+ return { sha256: hash.digest('hex'), bytes };
76
+ }
77
+ catch (error) {
78
+ return { problem: `Artifact cannot be bound: ${error.message}` };
79
+ }
80
+ finally {
81
+ if (fd !== undefined) {
82
+ try {
83
+ closeSync(fd);
84
+ }
85
+ catch { /* Preserve the snapshot failure. */ }
86
+ }
87
+ }
88
+ }
89
+ export function startDelivery(root, manifest, acceptanceDigest, options = {}) {
90
+ const budget = snapshotBudget(options);
91
+ return { acceptanceDigest, artifacts: (manifest?.delivery?.artifacts ?? []).map(item => snapshotArtifact(root, item.path, budget)) };
92
+ }
93
+ export function finishDelivery(root, manifest, start, fingerprint, criteria, options = {}) {
94
+ const budget = snapshotBudget(options);
95
+ const snapshots = (manifest?.delivery?.artifacts ?? []).map((artifact, index) => {
96
+ const before = start.artifacts[index], after = snapshotArtifact(root, artifact.path, budget);
97
+ const stable = !!before?.sha256 && before.sha256 === after.sha256;
98
+ const problem = before?.problem ?? after.problem ?? (!stable ? 'Artifact changed during verification; rebuild and check the stable artifact again' : undefined);
99
+ if (problem)
100
+ criteria.push({ id: `delivery-artifact-${index + 1}`, text: `Artifact ${artifact.path} remained stable`, commands: [], status: 'failed', summary: problem });
101
+ return { artifact, before, after, stable, problem };
102
+ });
103
+ // Recheck source and cancellation after the final artifact I/O, before assigning proof status.
104
+ const bindingProblem = options.checkIntegrity?.() ?? snapshots.find(item => item.problem)?.problem;
105
+ const statusFor = (ids) => {
106
+ const statuses = ids.flatMap(id => { const matches = criteria.filter(criterion => criterion.id === id); return matches.length ? matches.map(item => item.status) : ['unverified']; });
107
+ return statuses.some(status => status === 'failed') ? 'failed' : !bindingProblem && statuses.every(status => status === 'passed') ? 'passed' : 'unverified';
108
+ };
109
+ const artifacts = snapshots.map(({ artifact, before, after, stable, problem }) => {
110
+ return { ...artifact, binding: 'declared-criteria', status: problem ? 'failed' : statusFor(artifact.criteria), stable, sha256: before?.sha256, bytes: before?.bytes, afterSha256: after.sha256, summary: problem ?? bindingProblem ?? 'Artifact hashes matched before and after checking its declared criteria; the project commands define how this artifact is exercised' };
111
+ });
112
+ const requirements = manifest?.criteria.map(item => item.id) ?? [];
113
+ return {
114
+ version: 1, sourceFingerprint: fingerprint, acceptanceDigest: start.acceptanceDigest, ...(bindingProblem ? { bindingProblem } : {}),
115
+ environment: { platform: process.platform, architecture: process.arch, nodeVersion: process.version, declared: manifest?.delivery?.environment },
116
+ artifacts, journeys: (manifest?.delivery?.journeys ?? []).map(journey => ({ ...journey, status: statusFor(journey.criteria) })),
117
+ passedRequirementIds: requirements.filter(id => statusFor([id]) === 'passed'),
118
+ failedRequirementIds: requirements.filter(id => statusFor([id]) === 'failed'),
119
+ unverifiedRequirementIds: requirements.filter(id => statusFor([id]) === 'unverified'),
120
+ };
121
+ }
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { realpathSync } from 'node:fs';
5
5
  import { checkProject, checkExitCode, protectAcceptance } from './check/command.js';
6
6
  import { registerProject, listProjects, unregisterProject } from './dashboard/registry.js';
7
7
  import { startDashboard } from './dashboard/server.js';
8
- import { createProjectGoal, readProjectGoal, runProjectGoal, pauseProjectGoal, goalHandoff, budgetProjectGoal, bindProjectGoal } from './goals/command.js';
8
+ import { createProjectGoal, readProjectGoal, runProjectGoal, pauseProjectGoal, goalHandoff, budgetProjectGoal, bindProjectGoal, assessProjectGoal } from './goals/command.js';
9
9
  import { validateCanon } from './canon/validate.js';
10
10
  import { runRetrofit } from './retrofit/command.js';
11
11
  import { setLoopEnabled, loopStatus, runLoopCommand } from './loop/run-command.js';
@@ -339,6 +339,15 @@ export function main(argv) {
339
339
  console.log('Pause requested at next safe boundary');
340
340
  return 0;
341
341
  }
342
+ if (sub === 'assess') {
343
+ const provider = value('runner');
344
+ if (provider && !SUPPORTED_AGENTS.includes(provider))
345
+ throw new Error('Unknown runner');
346
+ return assessProjectGoal(targetDir, { provider: provider, selection: { model: value('model'), reasoningEffort: value('effort'), bare: rest.includes('--bare') ? true : undefined } }).then(result => {
347
+ console.log(JSON.stringify(result));
348
+ return result.assessed ? 0 : 1;
349
+ }).catch(error => { console.error(`Goal assessment: ${error.message}`); return 2; });
350
+ }
342
351
  if (sub === 'run' || sub === 'resume') {
343
352
  const provider = value('runner');
344
353
  if (provider && !SUPPORTED_AGENTS.includes(provider))
@@ -348,7 +357,7 @@ export function main(argv) {
348
357
  return goal.status === 'complete' ? 0 : 1;
349
358
  }).catch(error => { console.error(`Goal: ${error.message}`); return 2; });
350
359
  }
351
- throw new Error('Use goal set|bind|status|run|resume|pause|handoff|budget');
360
+ throw new Error('Use goal set|bind|assess|status|run|resume|pause|handoff|budget');
352
361
  }
353
362
  catch (error) {
354
363
  console.error(`Goal: ${error.message}`);
@@ -832,7 +841,7 @@ export function main(argv) {
832
841
  case 'upgrade':
833
842
  return runUpgrade();
834
843
  default:
835
- console.log('Project workflows: yoke check [dir] [--json|--protect] | goal set|run|resume|pause|status|handoff|budget [dir] | projects add|list|remove | dashboard [dir] [--port=N]');
844
+ console.log('Project workflows: yoke check [dir] [--json|--protect] | goal set|bind|assess|run|resume|pause|status|handoff|budget [dir] | projects add|list|remove | dashboard [dir] [--port=N]');
836
845
  console.log(`usage: yoke <setup [dir] | new <dir> [--idea="..."] | validate [canonDir] | retrofit [targetDir] [--agent=${AGENT_LIST}|all] [--code-graph=graphify|serena] [--code-intelligence=off|shadow|active] [--clean-worktrees] [--runner-model=<model>] [--runner-reasoning=<effort>] [--model=<agent>:<model>] [--reasoning=<agent>:<effort>] [--loop] | worktrees <list|prune> [dir] [--all] [--force] | change <add|status> [dir] | code-intelligence-server --workspace=<dir> --mode=<mode> | prd <draft|check|assess|decompose> [dir] | loop <on|off|status|decision|answer|resume|run|cleanup> | context <init|status> | review [dir] | design-scan [dir] | flow-smoke [dir] | upgrade>`);
837
846
  return cmd ? 1 : 0;
838
847
  }