@withtruing/companion 0.13.2 → 0.14.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 (57) hide show
  1. package/README.md +25 -26
  2. package/dist/anthropic-output-schema.js +41 -0
  3. package/dist/api-key-caller.js +9 -2
  4. package/dist/claude-cli-caller.js +107 -8
  5. package/dist/cli.js +837 -207
  6. package/dist/codex-binary.js +96 -0
  7. package/dist/codex-cli-caller.js +90 -16
  8. package/dist/config.js +177 -2
  9. package/dist/git-range.js +88 -46
  10. package/dist/http-client.js +95 -10
  11. package/dist/investigation-reader.js +12 -6
  12. package/dist/managed-repo.js +132 -0
  13. package/dist/observed-sources.js +132 -6
  14. package/dist/reader-result-contract.js +296 -2
  15. package/dist/repository-provider.js +608 -0
  16. package/dist/schema-entities.js +18 -18
  17. package/dist/service.js +7 -2
  18. package/dist/surface-mapper.js +53 -11
  19. package/dist/text-model.js +91 -8
  20. package/dist/whole-read/archive.js +196 -0
  21. package/dist/whole-read/checks.js +297 -0
  22. package/dist/whole-read/claims.js +196 -0
  23. package/dist/whole-read/coverage-ledger.js +51 -0
  24. package/dist/whole-read/decode.js +234 -0
  25. package/dist/whole-read/drafting.js +502 -0
  26. package/dist/whole-read/generated-rules.js +68 -0
  27. package/dist/whole-read/go-proof.js +255 -0
  28. package/dist/whole-read/grouping-prompt.js +19 -0
  29. package/dist/whole-read/grouping.js +247 -0
  30. package/dist/whole-read/identity.js +103 -0
  31. package/dist/whole-read/import-graph.js +293 -0
  32. package/dist/whole-read/inventory-collector.js +55 -0
  33. package/dist/whole-read/inventory-rules.js +141 -0
  34. package/dist/whole-read/lockfiles.js +947 -0
  35. package/dist/whole-read/notes.js +216 -0
  36. package/dist/whole-read/npm-proof.js +272 -0
  37. package/dist/whole-read/owner-decision-detectors.js +270 -0
  38. package/dist/whole-read/packing.js +612 -0
  39. package/dist/whole-read/pages.js +429 -0
  40. package/dist/whole-read/phase-policy.js +54 -0
  41. package/dist/whole-read/prd-build.js +279 -0
  42. package/dist/whole-read/prd-fidelity.js +14 -0
  43. package/dist/whole-read/python-proof.js +489 -0
  44. package/dist/whole-read/read-prompt.js +67 -0
  45. package/dist/whole-read/redaction.js +411 -0
  46. package/dist/whole-read/relationship-mapping.js +98 -0
  47. package/dist/whole-read/sanitize.js +266 -0
  48. package/dist/whole-read/secret-rules.js +26 -0
  49. package/dist/whole-read/structure.js +300 -0
  50. package/dist/whole-read/types.js +2 -0
  51. package/dist/whole-read-claims.js +12 -0
  52. package/dist/whole-read-git.js +340 -0
  53. package/dist/whole-read-pool.js +80 -0
  54. package/dist/whole-read-runner.js +945 -0
  55. package/dist/whole-read-shared-source.js +194 -0
  56. package/dist/whole-read-temp.js +479 -0
  57. package/package.json +8 -2
@@ -0,0 +1,945 @@
1
+ import { createHash } from 'node:crypto';
2
+ import crossSpawn from 'cross-spawn';
3
+ import { enumerateBuildTree, readBuildBlob, resolveLocalBuildCommit, WholeReadGitError } from './whole-read-git.js';
4
+ export { WHOLE_READ_COMPANION_CAPABILITIES } from './whole-read-claims.js';
5
+ import { WholeReadTransportError } from './text-model.js';
6
+ import { decodeCanonicalBlob } from './whole-read/decode.js';
7
+ import { collectInventory } from './whole-read/inventory-collector.js';
8
+ import { DRAFT_PROMPT_VERSION, MERGE_PROMPT_INSTRUCTIONS, MERGE_PROMPT_VERSION, createDraftPromptFromNotes, draftClaimsSchema, mergeDraftClaims, mergeDraftClaimsSchema, planModuleDraft, validateDraftClaims, } from './whole-read/drafting.js';
9
+ import { assembleGroupingPrompt, GROUPING_SCHEMA_VERSION, GroupingValidationError, groupingResultSchema, validateGroupingResult, } from './whole-read/grouping.js';
10
+ import { createGroupingPrompt } from './whole-read/grouping-prompt.js';
11
+ import { validateReadNotes, readNotesSchema } from './whole-read/notes.js';
12
+ import { collectionDigest, encodeCollection } from './whole-read/pages.js';
13
+ import { WHOLE_READ_PHASE_POLICY } from './whole-read/phase-policy.js';
14
+ import { assembleReadPrompt, createReadPrompt, READ_PROMPT_VERSION } from './whole-read/read-prompt.js';
15
+ import { sanitizeBuildMetadata } from './whole-read/sanitize.js';
16
+ import { assembleWholeReadPrompt, deliveredCanonicalSlice, planUnits, WHOLE_READ_PACKING_VERSION } from './whole-read/packing.js';
17
+ import { CLAIM_CHECK_PROMPT_VERSION, claimCheckVerdictSchema, prepareClaimCheck, validateClaimCheck, } from './whole-read/checks.js';
18
+ import { runPrdUnit } from './whole-read/prd-build.js';
19
+ import { createRelationshipPrompt, RELATIONSHIP_PROMPT_VERSION, relationshipTypingSchema, validateRelationshipTypingResult, } from './whole-read/relationship-mapping.js';
20
+ const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
21
+ const COMMAND_TIMEOUT_MS = 120_000;
22
+ export function assignmentRouteReadinessCode(route) {
23
+ return route === 'local' || route === 'github_companion' ? 'ready' : 'companion_update_required';
24
+ }
25
+ /** Selects only the acquisition route sealed into the assignment by the owner. */
26
+ export async function acquireAssignedSource(input) {
27
+ if (assignmentRouteReadinessCode(input.route) === 'companion_update_required')
28
+ return undefined;
29
+ if (input.route === 'local')
30
+ return input.acquireLocal();
31
+ if (input.route === 'github_companion')
32
+ return input.acquireGithubCompanion();
33
+ return undefined;
34
+ }
35
+ function runGitCommand(cwd, args, signal) {
36
+ if (signal.aborted)
37
+ return Promise.reject(new WholeReadGitError('cancelled'));
38
+ return new Promise((resolve, reject) => {
39
+ let child;
40
+ try {
41
+ child = crossSpawn('git', [...args], {
42
+ cwd,
43
+ env: {
44
+ ...process.env,
45
+ GIT_NO_REPLACE_OBJECTS: '1',
46
+ GIT_NO_LAZY_FETCH: '1',
47
+ GIT_OPTIONAL_LOCKS: '0',
48
+ GIT_TERMINAL_PROMPT: '0',
49
+ GCM_INTERACTIVE: 'never',
50
+ },
51
+ stdio: ['ignore', 'pipe', 'pipe'],
52
+ windowsHide: true,
53
+ });
54
+ }
55
+ catch {
56
+ reject(new WholeReadGitError('git_unavailable'));
57
+ return;
58
+ }
59
+ const stdout = [];
60
+ let bytes = 0;
61
+ let settled = false;
62
+ const finish = (error) => {
63
+ if (settled)
64
+ return;
65
+ settled = true;
66
+ clearTimeout(timer);
67
+ signal.removeEventListener('abort', abort);
68
+ if (error)
69
+ reject(error);
70
+ else
71
+ resolve({ stdout: Buffer.concat(stdout, bytes).toString('utf8') });
72
+ };
73
+ const abort = () => { child.kill('SIGKILL'); finish(new WholeReadGitError('cancelled')); };
74
+ signal.addEventListener('abort', abort, { once: true });
75
+ child.stdout?.on('data', (chunk) => {
76
+ bytes += chunk.length;
77
+ if (bytes > 16 * 1024 * 1024) {
78
+ child.kill('SIGKILL');
79
+ finish(new WholeReadGitError('resource_limit'));
80
+ }
81
+ else
82
+ stdout.push(chunk);
83
+ });
84
+ child.stderr?.on('data', () => undefined);
85
+ child.on('error', () => finish(new WholeReadGitError('git_unavailable')));
86
+ child.on('close', (code, closeSignal) => {
87
+ if (!settled)
88
+ finish(code === 0 && closeSignal === null
89
+ ? undefined : new WholeReadGitError('snapshot_unavailable'));
90
+ });
91
+ const timer = setTimeout(() => {
92
+ child.kill('SIGKILL');
93
+ finish(new WholeReadGitError('resource_limit'));
94
+ }, COMMAND_TIMEOUT_MS);
95
+ });
96
+ }
97
+ function source(repoPath, commit, verification) {
98
+ return {
99
+ repoPath,
100
+ commit,
101
+ verification,
102
+ entries: (signal) => enumerateBuildTree(repoPath, commit, signal),
103
+ readBlob: (blobId, signal) => readBuildBlob(repoPath, blobId, signal),
104
+ release: async () => undefined,
105
+ };
106
+ }
107
+ /** Resolve the selected local branch and reject any assignment for a different commit. */
108
+ export async function acquireLocalSource(input) {
109
+ if (!SHA.test(input.snapshotSha))
110
+ throw new WholeReadGitError('snapshot_unavailable');
111
+ const commit = await resolveLocalBuildCommit(input.repoPath, input.branch, input.signal);
112
+ if (commit !== input.snapshotSha)
113
+ throw new WholeReadGitError('snapshot_unavailable');
114
+ return source(input.repoPath, commit, { lsRemoteVerified: true, exactCommitFetched: true });
115
+ }
116
+ /** Fetch exactly one requested object into an attempt-owned bare repository, without checkout. */
117
+ export async function acquireGithubCompanionSource(input) {
118
+ if (!SHA.test(input.snapshotSha) || !input.repositoryUrl
119
+ || input.repositoryUrl.startsWith('-') || input.repositoryUrl.includes('\0')) {
120
+ throw new WholeReadGitError('snapshot_unavailable');
121
+ }
122
+ const run = input.runGit ?? runGitCommand;
123
+ await run(input.lease.path, ['init', '--bare', input.lease.bareRepositoryPath], input.signal);
124
+ await run(input.lease.path, ['ls-remote', '--exit-code', input.repositoryUrl], input.signal);
125
+ await run(input.lease.path, [
126
+ '--git-dir', input.lease.bareRepositoryPath,
127
+ 'fetch', '--no-tags', '--depth=1', input.repositoryUrl, input.snapshotSha,
128
+ ], input.signal);
129
+ const type = await run(input.lease.path, [
130
+ '--git-dir', input.lease.bareRepositoryPath, 'cat-file', '-t', input.snapshotSha,
131
+ ], input.signal);
132
+ const resolved = await run(input.lease.path, [
133
+ '--git-dir', input.lease.bareRepositoryPath, 'rev-parse', '--verify', `${input.snapshotSha}^{commit}`,
134
+ ], input.signal);
135
+ if (type.stdout.trim() !== 'commit' || resolved.stdout.trim() !== input.snapshotSha) {
136
+ throw new WholeReadGitError('snapshot_unavailable');
137
+ }
138
+ return source(input.lease.bareRepositoryPath, input.snapshotSha, { lsRemoteVerified: true, exactCommitFetched: true });
139
+ }
140
+ function parseJson(value) {
141
+ try {
142
+ return JSON.parse(value);
143
+ }
144
+ catch {
145
+ throw new TypeError('build_input_invalid');
146
+ }
147
+ }
148
+ function assertInputDigest(claim) {
149
+ const actual = `sha256:${createHash('sha256').update(claim.inputRefsJson, 'utf8').digest('hex')}`;
150
+ if (actual !== claim.inputDigest)
151
+ throw new TypeError('build_input_invalid');
152
+ }
153
+ function entryId(buildId, pathBase64) {
154
+ return `entry_${createHash('sha256').update(`${buildId}\0${pathBase64}`, 'utf8').digest('hex').slice(0, 32)}`;
155
+ }
156
+ function legacyEntryId(pathBase64) {
157
+ return `entry_${createHash('sha256').update(pathBase64).digest('hex').slice(0, 32)}`;
158
+ }
159
+ function entriesById(buildId, entries) {
160
+ const result = new Map();
161
+ for (const entry of entries) {
162
+ result.set(entryId(buildId, entry.pathBase64), entry);
163
+ result.set(legacyEntryId(entry.pathBase64), entry);
164
+ }
165
+ return result;
166
+ }
167
+ async function tree(sourceHandle, signal) {
168
+ const entries = [];
169
+ for await (const entry of sourceHandle.entries(signal))
170
+ entries.push(entry);
171
+ return entries;
172
+ }
173
+ async function sendCollection(claim, kind, collectionId, records, transport, usage, metadata, preparedPages) {
174
+ const pages = preparedPages ?? [...encodeCollection(records)];
175
+ for (const page of pages)
176
+ await transport.putPage(claim, page);
177
+ const digest = collectionDigest(pages, { collectionId, kind });
178
+ const sealed = await transport.seal(claim, {
179
+ collectionId,
180
+ kind,
181
+ pageCount: pages.length,
182
+ transportCount: pages.reduce((count, page) => count + page.recordCount, 0),
183
+ logicalCount: records.length,
184
+ digest,
185
+ });
186
+ return transport.accept(claim, {
187
+ resultDigest: digest,
188
+ resultRefJson: sealed.resultRefJson,
189
+ usage,
190
+ ...(metadata ? {
191
+ ...(metadata.model ? { model: metadata.model } : {}),
192
+ finishReason: metadata.finishReason,
193
+ usageAvailable: metadata.usageAvailable,
194
+ } : {}),
195
+ });
196
+ }
197
+ async function runInventory(claim, dependencies) {
198
+ const { source: sourceHandle, transport, signal } = required(dependencies);
199
+ assertInputDigest(claim);
200
+ const refs = parseJson(claim.inputRefsJson);
201
+ if (!refs.sourceId)
202
+ throw new TypeError('build_input_invalid');
203
+ const entries = await tree(sourceHandle, signal);
204
+ const collectionId = `inventory:${claim.attemptId}`;
205
+ const blobs = new Map();
206
+ for (const entry of entries) {
207
+ if (entry.mode === '160000')
208
+ continue;
209
+ try {
210
+ blobs.set(entry.blobId, { bytes: await sourceHandle.readBlob(entry.blobId, signal) });
211
+ }
212
+ catch (error) {
213
+ blobs.set(entry.blobId, { acquisitionFailure: {
214
+ code: error instanceof WholeReadGitError ? error.code : 'blob_unavailable', source: 'git',
215
+ } });
216
+ }
217
+ }
218
+ const collected = collectInventory({ sourceId: refs.sourceId, collectionId, entries, blobs });
219
+ return sendCollection(claim, 'inventory', collectionId, collected.records, transport, { status: 'unavailable' }, undefined, collected.pages);
220
+ }
221
+ async function runPlanning(claim, dependencies) {
222
+ const { source: sourceHandle, transport, signal } = required(dependencies);
223
+ assertInputDigest(claim);
224
+ const protocol = parseJson(claim.inputRefsJson).protocolVersion;
225
+ if (protocol === 'whole-read-draft-plan-request@1') {
226
+ const refs = parseJson(claim.inputRefsJson);
227
+ if (refs.packingVersion !== WHOLE_READ_PACKING_VERSION || refs.promptVersion !== DRAFT_PROMPT_VERSION
228
+ || sourceHandle.commit !== refs.snapshotSha || !refs.laneId
229
+ || !Number.isSafeInteger(refs.planRevision) || refs.planRevision < 1
230
+ || !Number.isSafeInteger(refs.inventoryRevision) || refs.inventoryRevision < 1
231
+ || !Number.isSafeInteger(refs.maxPromptCharacters) || refs.maxPromptCharacters < 1
232
+ || !Number.isSafeInteger(refs.maxOutputTokens) || refs.maxOutputTokens < 1
233
+ || !Number.isSafeInteger(refs.thinkingAllowanceTokens) || refs.thinkingAllowanceTokens < 0
234
+ || !Number.isSafeInteger(refs.outputTokensPerItem) || refs.outputTokensPerItem < 1
235
+ || !Array.isArray(refs.groups) || refs.groups.length === 0)
236
+ throw new TypeError('build_input_invalid');
237
+ const inventory = await tree(sourceHandle, signal);
238
+ const byId = entriesById(claim.buildId, inventory);
239
+ const groups = [];
240
+ for (const group of refs.groups) {
241
+ if (!group.groupId || typeof group.label !== 'string' || !group.moduleId || !group.writeKey
242
+ || !Array.isArray(group.members) || group.members.length === 0 || !Array.isArray(group.notes)) {
243
+ throw new TypeError('build_input_invalid');
244
+ }
245
+ const sources = [];
246
+ for (const requested of group.members) {
247
+ const entry = byId.get(requested.entryId);
248
+ if (!entry || entry.pathDisplay !== requested.path || entry.blobId !== requested.blobId
249
+ || entry.mode === '160000' || requested.inventoryRevision !== refs.inventoryRevision) {
250
+ throw new TypeError('build_input_invalid');
251
+ }
252
+ const bytes = await sourceHandle.readBlob(entry.blobId, signal);
253
+ if (bytes.byteLength !== requested.bytes
254
+ || companionGitObjectId(bytes, requested.blobId.length) !== requested.blobId) {
255
+ throw new TypeError('canonical_source_invalid');
256
+ }
257
+ const decoded = decodeCanonicalBlob(bytes);
258
+ if (decoded.status !== 'decoded')
259
+ throw new TypeError('canonical_source_invalid');
260
+ const notes = group.notes.filter((note) => note.entryId === requested.entryId);
261
+ if (!notes.length)
262
+ throw new TypeError('build_input_invalid');
263
+ sources.push({ path: requested.path, entry: { entryId: requested.entryId, path: requested.path,
264
+ blobId: requested.blobId, inventoryRevision: requested.inventoryRevision, decoded }, notes });
265
+ }
266
+ groups.push({ groupId: group.groupId, plan: planModuleDraft({ groupId: group.groupId,
267
+ groupLabel: group.label, sources, maxPromptCharacters: refs.maxPromptCharacters,
268
+ maxOutputTokens: refs.maxOutputTokens, thinkingAllowanceTokens: refs.thinkingAllowanceTokens,
269
+ outputTokensPerItem: refs.outputTokensPerItem }) });
270
+ }
271
+ const result = { protocolVersion: 'whole-read-draft-plan-result@1', laneId: refs.laneId,
272
+ planRevision: refs.planRevision, inventoryRevision: refs.inventoryRevision, groups };
273
+ if (!sanitizeBuildMetadata(result).ok)
274
+ throw new TypeError('unsafe_model_output');
275
+ const collectionId = `draft-plan:${claim.attemptId}`;
276
+ const records = [{ collectionId, collectionKind: 'progress',
277
+ semanticId: `${refs.laneId}:${refs.planRevision}`, recordKind: 'progress',
278
+ payload: result }];
279
+ return sendCollection(claim, 'progress', collectionId, records, transport, { status: 'unavailable' });
280
+ }
281
+ const refs = parseJson(claim.inputRefsJson);
282
+ const prompt = createReadPrompt();
283
+ if (refs.protocolVersion !== 'whole-read-plan-request@1' || refs.packingVersion !== WHOLE_READ_PACKING_VERSION
284
+ || refs.promptVersion !== prompt.version || !refs.passId || !refs.laneId
285
+ || !Number.isSafeInteger(refs.planRevision) || refs.planRevision < 1
286
+ || !Number.isSafeInteger(refs.ordinal) || refs.ordinal < 0
287
+ || !Number.isSafeInteger(refs.inventoryRevision) || refs.inventoryRevision < 1
288
+ || !Number.isSafeInteger(refs.maxOutputTokens) || refs.maxOutputTokens < 1
289
+ || !Number.isSafeInteger(refs.outputTokensPerItem) || refs.outputTokensPerItem < 1
290
+ || !Array.isArray(refs.entries) || refs.entries.length === 0)
291
+ throw new TypeError('build_input_invalid');
292
+ const inventory = await tree(sourceHandle, signal);
293
+ const byId = entriesById(claim.buildId, inventory);
294
+ const plannedEntries = [];
295
+ for (const requested of refs.entries) {
296
+ const entry = byId.get(requested.entryId);
297
+ if (!entry || entry.pathDisplay !== requested.path || entry.blobId !== requested.blobId
298
+ || entry.mode === '160000')
299
+ throw new TypeError('build_input_invalid');
300
+ const decoded = decodeCanonicalBlob(await sourceHandle.readBlob(entry.blobId, signal));
301
+ if (decoded.status !== 'decoded')
302
+ throw new TypeError('canonical_source_invalid');
303
+ plannedEntries.push({ ...requested, inventoryRevision: refs.inventoryRevision, decoded });
304
+ }
305
+ const batches = [plannedEntries.filter((entry) => !entry.held),
306
+ ...plannedEntries.filter((entry) => entry.held).map((entry) => [entry])]
307
+ .filter((batch) => batch.length > 0);
308
+ const plans = batches.map((entries, index) => planUnits({
309
+ passId: index === 0 ? refs.passId : `${refs.passId}:held:${entries[0].entryId}`,
310
+ entries, prompt, numberLines: true, outputEnvelope: 'per_item',
311
+ maxOutputTokens: refs.maxOutputTokens, outputTokensPerItem: refs.outputTokensPerItem
312
+ }));
313
+ const plan = { packingVersion: WHOLE_READ_PACKING_VERSION,
314
+ units: plans.flatMap((item) => item.units), failures: plans.flatMap((item) => item.failures),
315
+ setAside: plans.flatMap((item) => item.setAside), remainingEnvelope: {
316
+ unitCount: plans.reduce((sum, item) => sum + item.remainingEnvelope.unitCount, 0),
317
+ promptCharacters: plans.reduce((sum, item) => sum + item.remainingEnvelope.promptCharacters, 0),
318
+ outputTokens: plans.reduce((sum, item) => sum + item.remainingEnvelope.outputTokens, 0),
319
+ } };
320
+ if (!sanitizeBuildMetadata({ plan }).ok)
321
+ throw new TypeError('unsafe_model_output');
322
+ const collectionId = `plan:${claim.attemptId}`;
323
+ const records = [{ collectionId, collectionKind: 'progress',
324
+ semanticId: refs.passId, recordKind: 'progress', payload: {
325
+ protocolVersion: 'whole-read-plan-result@1', passId: refs.passId, laneId: refs.laneId,
326
+ planRevision: refs.planRevision, ordinal: refs.ordinal, plan,
327
+ } }];
328
+ return sendCollection(claim, 'progress', collectionId, records, transport, { status: 'unavailable' });
329
+ }
330
+ function sourceLike(value, source) {
331
+ if (!value || typeof value !== 'object')
332
+ return false;
333
+ const cards = value.cards;
334
+ if (!Array.isArray(cards))
335
+ return false;
336
+ const prose = [];
337
+ for (const card of cards) {
338
+ if (!card || typeof card !== 'object')
339
+ continue;
340
+ const record = card;
341
+ if (typeof record.summary === 'string')
342
+ prose.push(record.summary);
343
+ if (Array.isArray(record.usesHint))
344
+ prose.push(...record.usesHint.filter((hint) => typeof hint === 'string'));
345
+ }
346
+ const withoutLinePrefixes = (text) => text.replace(/(^|\n)\s*\d+(?:\[fragment \d+\])?\| /g, '$1');
347
+ return prose.some((text) => text.length >= 24
348
+ && source.some((item) => item.includes(text) || item.includes(withoutLinePrefixes(text))));
349
+ }
350
+ function backendSource(backend) {
351
+ if (backend === 'api-key' || backend === 'anthropic' || backend === 'anthropic_api')
352
+ return 'anthropic_api';
353
+ if (backend === 'cli' || backend === 'claude_cli')
354
+ return 'claude_cli';
355
+ return 'codex';
356
+ }
357
+ function safeMetadata(metadata, requestedModel, usageAvailable, source) {
358
+ const actualModel = metadata?.model ?? requestedModel;
359
+ const finishReason = metadata?.finishReason ?? null;
360
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(actualModel)
361
+ || (finishReason !== null && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(finishReason))
362
+ || (metadata !== undefined && metadata.usageAvailable !== usageAvailable)) {
363
+ throw new WholeReadTransportError('capability_failure', source);
364
+ }
365
+ return { model: actualModel, finishReason, usageAvailable };
366
+ }
367
+ export function structuredWholeReadFailure(error) {
368
+ if (!(error instanceof WholeReadTransportError) || error.code === 'cancelled')
369
+ return null;
370
+ return {
371
+ code: error.code,
372
+ source: error.source,
373
+ ...(error.resetAt ? { resetAt: error.resetAt } : {}),
374
+ ...(error.retryAfterMs === undefined ? {} : { retryAfterMs: error.retryAfterMs }),
375
+ ...(error.category === undefined ? {} : { category: error.category }),
376
+ };
377
+ }
378
+ function structuredValidationFailure(claim, error) {
379
+ const message = error instanceof Error ? error.message : '';
380
+ const codes = new Set([
381
+ 'check_result_invalid', 'claim_check_input_invalid', 'claim_check_citation_invalid',
382
+ 'claim_check_result_invalid', 'relationship_result_invalid',
383
+ ]);
384
+ if (!codes.has(message))
385
+ return null;
386
+ return { code: message, source: backendSource(claim.backend) };
387
+ }
388
+ function preSendFailure(claim, error) {
389
+ const message = error instanceof Error ? error.message : '';
390
+ if (message === 'canonical_source_invalid')
391
+ return { code: message, source: 'companion' };
392
+ const phase = claim.kind === 'draft' ? 'draft' : claim.kind === 'check' ? 'check'
393
+ : claim.kind === 'group' ? 'grouping' : claim.kind === 'relationship' ? 'relationship' : null;
394
+ if (!phase)
395
+ return null;
396
+ if (message === 'build_reservation_required') {
397
+ return { code: `${phase}_attempt_invalid`, source: 'companion' };
398
+ }
399
+ if (message === 'build_input_invalid' || message === 'snapshot_unavailable') {
400
+ return { code: `${phase}_input_invalid`, source: 'companion' };
401
+ }
402
+ const code = error && typeof error === 'object' && 'code' in error
403
+ ? error.code : undefined;
404
+ if (phase === 'draft' && (code === 'draft_note_invalid' || code === 'draft_input_invalid')) {
405
+ return { code, source: 'companion' };
406
+ }
407
+ return null;
408
+ }
409
+ async function runRead(claim, dependencies) {
410
+ const reservation = claim.reservation;
411
+ if (!reservation || !claim.consentId || reservation.attemptId !== claim.attemptId
412
+ || reservation.consentId !== claim.consentId || reservation.unitKind !== claim.kind
413
+ || reservation.inputTokens !== claim.reservedInputTokens
414
+ || reservation.outputTokens !== claim.reservedOutputTokens) {
415
+ throw new TypeError('build_reservation_required');
416
+ }
417
+ const { source: sourceHandle, transport, signal } = required(dependencies);
418
+ if (!dependencies.backend || !claim.model)
419
+ throw new TypeError('build_backend_unavailable');
420
+ const model = claim.model;
421
+ assertInputDigest(claim);
422
+ const refs = parseJson(claim.inputRefsJson);
423
+ if (refs.protocolVersion !== 'whole-read-unit-plan@1'
424
+ || refs.packingVersion !== WHOLE_READ_PACKING_VERSION || refs.promptVersion !== READ_PROMPT_VERSION
425
+ || !Array.isArray(refs.items)
426
+ || refs.items.length === 0 || !Array.isArray(refs.contextRefs))
427
+ throw new TypeError('build_input_invalid');
428
+ const entries = await tree(sourceHandle, signal);
429
+ const byId = entriesById(claim.buildId, entries);
430
+ const legacyIds = refs.items.every((item) => {
431
+ const entry = byId.get(item.entryId);
432
+ return entry !== undefined && item.entryId === legacyEntryId(entry.pathBase64);
433
+ });
434
+ const byBlob = new Map();
435
+ for (const entry of entries)
436
+ byBlob.set(entry.blobId, [...(byBlob.get(entry.blobId) ?? []), entry]);
437
+ const validation = [];
438
+ const deliveredItems = [];
439
+ for (const item of refs.items) {
440
+ const entry = byId.get(item.entryId) ?? (byBlob.get(item.blobId)?.length === 1 ? byBlob.get(item.blobId)[0] : undefined);
441
+ if (!entry || entry.pathDisplay !== item.path || entry.blobId !== item.blobId
442
+ || entry.mode === '160000')
443
+ throw new TypeError('build_input_invalid');
444
+ const decoded = decodeCanonicalBlob(await sourceHandle.readBlob(item.blobId, signal));
445
+ if (decoded.status !== 'decoded')
446
+ throw new TypeError('canonical_source_invalid');
447
+ validation.push({ item, decoded });
448
+ const overlap = item.overlapSpans[0];
449
+ deliveredItems.push({ entryId: item.entryId, primarySpan: item.primarySpan,
450
+ primaryText: deliveredCanonicalSlice(decoded, item.primarySpan.startByte, item.primarySpan.endByte),
451
+ ...(overlap ? { overlapText: deliveredCanonicalSlice(decoded, overlap.startByte, overlap.endByte) } : {}),
452
+ });
453
+ }
454
+ const promptDefinition = createReadPrompt(refs.contexts ?? []);
455
+ const unit = { unitId: claim.unitId, items: refs.items, contextRefs: refs.contextRefs,
456
+ promptVersion: refs.promptVersion, promptHash: refs.promptHash,
457
+ promptCharacters: refs.promptCharacters, outputTokens: refs.outputTokens };
458
+ const prompt = assembleReadPrompt({ unit, prompt: promptDefinition, deliveredItems });
459
+ const reply = await dependencies.backend({ prompt, schema: readNotesSchema,
460
+ phase: 'read', model, maxTokens: claim.reservedOutputTokens, signal });
461
+ const metadata = safeMetadata(reply.metadata, model, reply.usage !== null, backendSource(claim.backend));
462
+ if (metadata.finishReason === 'max_tokens') {
463
+ throw new WholeReadTransportError('output_truncated', backendSource(claim.backend));
464
+ }
465
+ if (!sanitizeBuildMetadata(reply.result).ok || sourceLike(reply.result, deliveredItems.flatMap((item) => [item.primaryText, item.overlapText ?? ''])))
466
+ throw new TypeError('unsafe_model_output');
467
+ const accepted = validateReadNotes({
468
+ result: reply.result,
469
+ items: validation,
470
+ inventory: entries.map((entry) => ({ entryId: legacyIds
471
+ ? legacyEntryId(entry.pathBase64) : entryId(claim.buildId, entry.pathBase64), path: entry.pathDisplay })),
472
+ dependencies: refs.dependencies ?? [],
473
+ importEdges: refs.importEdges ?? [],
474
+ });
475
+ if (!sanitizeBuildMetadata(accepted).ok)
476
+ throw new TypeError('unsafe_model_output');
477
+ const collectionId = `notes:${claim.attemptId}`;
478
+ const records = accepted.notes.map((note) => ({
479
+ collectionId, collectionKind: 'notes', semanticId: note.noteId, recordKind: 'note',
480
+ payload: note,
481
+ }));
482
+ const usage = reply.usage
483
+ ? { status: 'measured', inputTokens: reply.usage.inputTokens, outputTokens: reply.usage.outputTokens }
484
+ : { status: 'unavailable' };
485
+ return sendCollection(claim, 'notes', collectionId, records, transport, usage, metadata);
486
+ }
487
+ function groupingPrompt(refs) {
488
+ const definition = createGroupingPrompt();
489
+ if (refs.promptVersion !== definition.version || refs.contextRefs.length
490
+ || refs.members.some((member, index) => member.item !== index + 1
491
+ || member.record.item !== member.item))
492
+ throw new TypeError('build_input_invalid');
493
+ const prompt = assembleGroupingPrompt(definition, refs.members.map((member) => member.record));
494
+ if (prompt.length !== refs.promptCharacters
495
+ || `sha256:${createHash('sha256').update(prompt).digest('hex')}` !== refs.promptHash) {
496
+ throw new TypeError('build_input_invalid');
497
+ }
498
+ return prompt;
499
+ }
500
+ function validateGrouping(refs, result, members = refs.members) {
501
+ const expectedIds = new Set(members.map((member) => member.identity));
502
+ return refs.stage === 'first_level'
503
+ ? validateGroupingResult({ stage: 'first_level', notes: refs.notes.filter((item) => expectedIds.has(item.noteId)),
504
+ members, result })
505
+ : validateGroupingResult({ stage: 'merge', groups: refs.groups.filter((item) => expectedIds.has(item.groupId)),
506
+ members, result });
507
+ }
508
+ function groupingCorrectionPrompt(refs, accepted) {
509
+ const missing = new Set(accepted.missingItems);
510
+ return `Some items were missing from a grouping answer. Put each of these items into one of the existing groups, or a new one.\n`
511
+ + `Existing group labels: ${JSON.stringify(accepted.groups.map((group) => group.label))}\n`
512
+ + `Return only {"groups":[{"label":"...","members":[1]}]}. Use the item numbers shown below.\n`
513
+ + `<missing-members>\n${JSON.stringify(refs.members.filter((member) => missing.has(member.item))
514
+ .map((member) => member.record))}\n</missing-members>`;
515
+ }
516
+ function mergeGrouping(refs, first, correction) {
517
+ const itemByIdentity = new Map(refs.members.map((member) => [member.identity, member.item]));
518
+ const groups = first.groups.map((group) => ({ label: group.label,
519
+ members: group.memberIds.map((id) => itemByIdentity.get(id)).filter((item) => item !== undefined) }));
520
+ for (const group of correction.groups) {
521
+ const members = group.memberIds.map((id) => itemByIdentity.get(id)).filter((item) => item !== undefined);
522
+ const existing = groups.find((candidate) => candidate.label === group.label);
523
+ if (existing)
524
+ existing.members.push(...members);
525
+ else
526
+ groups.push({ label: group.label, members });
527
+ }
528
+ return validateGrouping(refs, { groups });
529
+ }
530
+ async function runGrouping(claim, dependencies) {
531
+ const reservation = claim.reservation;
532
+ if (!reservation || !claim.consentId || reservation.attemptId !== claim.attemptId
533
+ || reservation.consentId !== claim.consentId || reservation.unitKind !== claim.kind
534
+ || reservation.inputTokens !== claim.reservedInputTokens
535
+ || reservation.outputTokens !== claim.reservedOutputTokens) {
536
+ throw new TypeError('build_reservation_required');
537
+ }
538
+ if (!dependencies.backend || !dependencies.transport || !dependencies.signal || !claim.model) {
539
+ throw new TypeError('build_backend_unavailable');
540
+ }
541
+ assertInputDigest(claim);
542
+ const refs = parseJson(claim.inputRefsJson);
543
+ if (refs.protocolVersion !== 'whole-read-grouping-unit@2' || refs.schemaVersion !== GROUPING_SCHEMA_VERSION
544
+ || (refs.stage !== 'first_level' && refs.stage !== 'merge') || !Array.isArray(refs.members)
545
+ || !Array.isArray(refs.notes) || !Array.isArray(refs.groups) || !Array.isArray(refs.contextRefs)
546
+ || !Number.isSafeInteger(refs.outputTokens) || refs.outputTokens < 1) {
547
+ throw new TypeError('build_input_invalid');
548
+ }
549
+ const reply = await dependencies.backend({ prompt: groupingPrompt(refs),
550
+ schema: groupingResultSchema, phase: 'grouping', model: claim.model,
551
+ maxTokens: claim.reservedOutputTokens, signal: dependencies.signal });
552
+ const metadata = safeMetadata(reply.metadata, claim.model, reply.usage !== null, backendSource(claim.backend));
553
+ if (metadata.finishReason === 'max_tokens') {
554
+ throw new WholeReadTransportError('output_truncated', backendSource(claim.backend));
555
+ }
556
+ let accepted = validateGrouping(refs, reply.result);
557
+ let correctionUsage = null;
558
+ let corrected = false;
559
+ if (accepted.missingItems.length && reply.usage
560
+ && reply.usage.outputTokens < claim.reservedOutputTokens) {
561
+ corrected = true;
562
+ const missing = new Set(accepted.missingItems);
563
+ const members = refs.members.filter((member) => missing.has(member.item));
564
+ const correction = await dependencies.backend({ prompt: groupingCorrectionPrompt(refs, accepted),
565
+ schema: groupingResultSchema, phase: 'grouping', model: claim.model,
566
+ maxTokens: claim.reservedOutputTokens - reply.usage.outputTokens, signal: dependencies.signal });
567
+ correctionUsage = correction.usage;
568
+ try {
569
+ accepted = mergeGrouping(refs, accepted, validateGrouping(refs, correction.result, members));
570
+ }
571
+ catch (error) {
572
+ if (!(error instanceof GroupingValidationError))
573
+ throw error;
574
+ }
575
+ }
576
+ const collectionId = `groups:${claim.attemptId}`;
577
+ const records = accepted.groups.map((group) => ({ collectionId,
578
+ collectionKind: 'groups', semanticId: group.groupId, recordKind: 'group',
579
+ payload: group }));
580
+ const usage = reply.usage && (!corrected || correctionUsage)
581
+ ? { status: 'measured', inputTokens: reply.usage.inputTokens + (correctionUsage?.inputTokens ?? 0),
582
+ outputTokens: reply.usage.outputTokens + (correctionUsage?.outputTokens ?? 0) }
583
+ : { status: 'unavailable' };
584
+ return sendCollection(claim, 'groups', collectionId, records, dependencies.transport, usage, metadata);
585
+ }
586
+ function companionGitObjectId(bytes, length) {
587
+ return createHash(length === 64 ? 'sha256' : 'sha1')
588
+ .update(`blob ${bytes.byteLength}\0`).update(bytes).digest('hex');
589
+ }
590
+ function draftRefs(claim) {
591
+ const refs = parseJson(claim.inputRefsJson);
592
+ if (refs.protocolVersion !== 'whole-read-drafting-unit@1'
593
+ || (refs.stage !== 'leaf' && refs.stage !== 'merge') || !refs.groupId || !refs.moduleId
594
+ || !refs.writeKey || !SHA.test(refs.snapshotSha) || typeof refs.groupLabel !== 'string'
595
+ || typeof refs.persist !== 'boolean' || !Array.isArray(refs.notes) || !Array.isArray(refs.claims)
596
+ || !Array.isArray(refs.items) || !Array.isArray(refs.contextRefs)
597
+ || refs.promptVersion !== (refs.stage === 'leaf' ? DRAFT_PROMPT_VERSION : MERGE_PROMPT_VERSION)
598
+ || !/^sha256:[0-9a-f]{64}$/.test(refs.promptHash)
599
+ || !Number.isSafeInteger(refs.promptCharacters) || refs.promptCharacters < 1
600
+ || !Number.isSafeInteger(refs.outputTokens) || refs.outputTokens < 1
601
+ || (refs.stage === 'leaf' && (refs.items.length === 0 || refs.notes.length === 0))
602
+ || (refs.stage === 'merge' && (refs.claims.length === 0 || refs.items.length !== 0))) {
603
+ throw new TypeError('build_input_invalid');
604
+ }
605
+ return refs;
606
+ }
607
+ function draftingMergePrompt(refs) {
608
+ const definition = { version: MERGE_PROMPT_VERSION, instructions: MERGE_PROMPT_INSTRUCTIONS,
609
+ schema: JSON.stringify(mergeDraftClaimsSchema), context: [] };
610
+ const prompt = assembleWholeReadPrompt(definition, [{ ref: 'claims', text: JSON.stringify(refs.claims) }], [], { numberLines: false });
611
+ if (refs.promptVersion !== definition.version || prompt.length !== refs.promptCharacters
612
+ || `sha256:${createHash('sha256').update(prompt).digest('hex')}` !== refs.promptHash) {
613
+ throw new TypeError('build_input_invalid');
614
+ }
615
+ return prompt;
616
+ }
617
+ function sourceLikeClaims(value, source) {
618
+ if (!value || typeof value !== 'object' || !Array.isArray(value.claims))
619
+ return false;
620
+ return value.claims.some((claim) => {
621
+ const claimText = claim?.text;
622
+ return typeof claimText === 'string' && claimText.length >= 24
623
+ && source.some((text) => text.includes(claimText));
624
+ });
625
+ }
626
+ async function runDrafting(claim, dependencies) {
627
+ const reservation = claim.reservation;
628
+ if (!reservation || !claim.consentId || reservation.attemptId !== claim.attemptId
629
+ || reservation.consentId !== claim.consentId || reservation.unitKind !== 'draft'
630
+ || reservation.inputTokens !== claim.reservedInputTokens
631
+ || reservation.outputTokens !== claim.reservedOutputTokens) {
632
+ throw new TypeError('build_reservation_required');
633
+ }
634
+ if (!dependencies.backend || !dependencies.transport || !dependencies.signal || !claim.model) {
635
+ throw new TypeError('build_backend_unavailable');
636
+ }
637
+ assertInputDigest(claim);
638
+ const refs = draftRefs(claim);
639
+ let prompt;
640
+ let sources = [];
641
+ let unit;
642
+ let deliveredText = [];
643
+ if (refs.stage === 'leaf') {
644
+ if (!dependencies.source || dependencies.source.commit !== refs.snapshotSha) {
645
+ throw new TypeError('snapshot_unavailable');
646
+ }
647
+ const entries = await tree(dependencies.source, dependencies.signal);
648
+ const byId = entriesById(claim.buildId, entries);
649
+ const notesByEntry = new Map();
650
+ for (const note of refs.notes)
651
+ notesByEntry.set(note.entryId, [...(notesByEntry.get(note.entryId) ?? []), note]);
652
+ const decoded = new Map();
653
+ const delivered = [];
654
+ for (const item of refs.items) {
655
+ const entry = byId.get(item.entryId);
656
+ if (!entry || entry.pathDisplay !== item.path || entry.blobId !== item.blobId || entry.mode === '160000'
657
+ || !notesByEntry.has(item.entryId))
658
+ throw new TypeError('build_input_invalid');
659
+ let canonical = decoded.get(item.entryId);
660
+ if (!canonical) {
661
+ const bytes = await dependencies.source.readBlob(item.blobId, dependencies.signal);
662
+ if (companionGitObjectId(bytes, item.blobId.length) !== item.blobId) {
663
+ throw new TypeError('canonical_source_invalid');
664
+ }
665
+ const value = decodeCanonicalBlob(bytes);
666
+ if (value.status !== 'decoded')
667
+ throw new TypeError('canonical_source_invalid');
668
+ canonical = { path: entry.pathDisplay, decoded: value, blobId: entry.blobId };
669
+ decoded.set(item.entryId, canonical);
670
+ }
671
+ const overlap = item.overlapSpans[0];
672
+ delivered.push({ item,
673
+ primaryText: deliveredCanonicalSlice(canonical.decoded, item.primarySpan.startByte, item.primarySpan.endByte),
674
+ ...(overlap ? { overlapText: deliveredCanonicalSlice(canonical.decoded, overlap.startByte, overlap.endByte) } : {}) });
675
+ }
676
+ sources = [...decoded].map(([id, value]) => ({ path: value.path,
677
+ entry: { entryId: id, path: value.path, blobId: value.blobId,
678
+ inventoryRevision: refs.items.find((item) => item.entryId === id).inventoryRevision,
679
+ decoded: value.decoded },
680
+ notes: notesByEntry.get(id) }));
681
+ if (sources.some((source) => source.notes.some((note) => note.inventoryRevision !== source.entry.inventoryRevision
682
+ || note.path !== source.path || note.blobId !== source.entry.blobId))) {
683
+ throw new TypeError('build_input_invalid');
684
+ }
685
+ const definition = createDraftPromptFromNotes(refs.groupLabel, refs.notes);
686
+ const contexts = definition.context.filter((context) => refs.contextRefs.includes(context.ref));
687
+ prompt = assembleWholeReadPrompt(definition, contexts, delivered, { numberLines: true });
688
+ if (refs.promptVersion !== definition.version || prompt.length !== refs.promptCharacters
689
+ || `sha256:${createHash('sha256').update(prompt).digest('hex')}` !== refs.promptHash) {
690
+ throw new TypeError('build_input_invalid');
691
+ }
692
+ unit = { unitId: claim.unitId, items: refs.items, contextRefs: refs.contextRefs,
693
+ promptVersion: refs.promptVersion, promptHash: refs.promptHash,
694
+ promptCharacters: refs.promptCharacters, outputTokens: refs.outputTokens };
695
+ deliveredText = delivered.flatMap((item) => [item.primaryText, item.overlapText ?? '']);
696
+ }
697
+ else
698
+ prompt = draftingMergePrompt(refs);
699
+ const reply = await dependencies.backend({ prompt,
700
+ schema: refs.stage === 'leaf' ? draftClaimsSchema : mergeDraftClaimsSchema,
701
+ phase: refs.stage === 'leaf' ? 'drafting_leaf' : 'drafting_merge',
702
+ model: claim.model,
703
+ maxTokens: claim.reservedOutputTokens,
704
+ signal: dependencies.signal });
705
+ const metadata = safeMetadata(reply.metadata, claim.model, reply.usage !== null, backendSource(claim.backend));
706
+ if (metadata.finishReason === 'max_tokens') {
707
+ throw new WholeReadTransportError('output_truncated', backendSource(claim.backend));
708
+ }
709
+ if (!sanitizeBuildMetadata(reply.result).ok
710
+ || (refs.stage === 'leaf' && sourceLikeClaims(reply.result, deliveredText))) {
711
+ throw new TypeError('unsafe_model_output');
712
+ }
713
+ const claims = refs.stage === 'leaf'
714
+ ? validateDraftClaims({ result: reply.result, unit: unit, sources })
715
+ : mergeDraftClaims({ claims: refs.claims, result: reply.result, complete: refs.persist });
716
+ if (!sanitizeBuildMetadata({ claims }).ok)
717
+ throw new TypeError('unsafe_model_output');
718
+ const collectionId = `claims:${claim.attemptId}`;
719
+ const records = claims.map((draft) => ({ collectionId, collectionKind: 'claims',
720
+ semanticId: draft.claimId, recordKind: 'claim', payload: draft }));
721
+ const usage = reply.usage
722
+ ? { status: 'measured', inputTokens: reply.usage.inputTokens, outputTokens: reply.usage.outputTokens }
723
+ : { status: 'unavailable' };
724
+ return sendCollection(claim, 'claims', collectionId, records, dependencies.transport, usage, metadata);
725
+ }
726
+ async function runCheck(claim, dependencies) {
727
+ const reservation = claim.reservation;
728
+ if (!reservation || !claim.consentId || reservation.attemptId !== claim.attemptId
729
+ || reservation.consentId !== claim.consentId || reservation.unitKind !== 'check'
730
+ || reservation.inputTokens !== claim.reservedInputTokens
731
+ || reservation.outputTokens !== claim.reservedOutputTokens) {
732
+ throw new TypeError('build_reservation_required');
733
+ }
734
+ const { source: sourceHandle, transport, signal } = required(dependencies);
735
+ if (!dependencies.backend || !claim.model)
736
+ throw new TypeError('build_backend_unavailable');
737
+ assertInputDigest(claim);
738
+ const refs = parseJson(claim.inputRefsJson);
739
+ if (refs.protocolVersion !== 'whole-read-check-unit@1' || refs.promptVersion !== CLAIM_CHECK_PROMPT_VERSION
740
+ || typeof refs.issuanceRef !== 'string' || !refs.issuanceRef.trim()
741
+ || !refs.subject || !['claim', 'note_use'].includes(refs.subject.kind)
742
+ || !Array.isArray(refs.entries) || refs.entries.length === 0 || !Array.isArray(refs.acceptedCoverage)
743
+ || !Number.isSafeInteger(refs.maxPromptCharacters) || refs.maxPromptCharacters < 1
744
+ || !Number.isSafeInteger(refs.outputTokens) || refs.outputTokens < 1) {
745
+ throw new TypeError('build_input_invalid');
746
+ }
747
+ const entries = await tree(sourceHandle, signal);
748
+ const byId = entriesById(claim.buildId, entries);
749
+ const checkEntries = [];
750
+ for (const expected of refs.entries) {
751
+ const item = byId.get(expected.entryId);
752
+ if (!item || item.pathDisplay !== expected.path || item.blobId !== expected.blobId
753
+ || item.mode === '160000' || expected.state !== 'read')
754
+ throw new TypeError('build_input_invalid');
755
+ const bytes = await sourceHandle.readBlob(item.blobId, signal);
756
+ if (companionGitObjectId(bytes, item.blobId.length) !== item.blobId) {
757
+ throw new TypeError('canonical_source_invalid');
758
+ }
759
+ checkEntries.push({ ...expected, bytes });
760
+ }
761
+ const prepared = prepareClaimCheck({ claim: refs.subject.claim, entries: checkEntries,
762
+ acceptedCoverage: refs.acceptedCoverage, acquisitionHost: 'companion',
763
+ maxPromptCharacters: refs.maxPromptCharacters });
764
+ let result;
765
+ let usage = { status: 'measured', inputTokens: 0, outputTokens: 0 };
766
+ let metadata;
767
+ if (prepared.status === 'not_established')
768
+ result = prepared.result;
769
+ else {
770
+ const reply = await dependencies.backend({ prompt: prepared.prompt,
771
+ schema: claimCheckVerdictSchema, phase: 'support_check', model: claim.model,
772
+ maxTokens: WHOLE_READ_PHASE_POLICY.support_check.maxOutputTokens, signal });
773
+ metadata = safeMetadata(reply.metadata, claim.model, reply.usage !== null, backendSource(claim.backend));
774
+ if (metadata.finishReason === 'max_tokens') {
775
+ throw new WholeReadTransportError('output_truncated', backendSource(claim.backend));
776
+ }
777
+ result = validateClaimCheck({ prepared, result: reply.result });
778
+ usage = reply.usage ? { status: 'measured', inputTokens: reply.usage.inputTokens,
779
+ outputTokens: reply.usage.outputTokens } : { status: 'unavailable' };
780
+ }
781
+ if (!sanitizeBuildMetadata(result).ok)
782
+ throw new TypeError('unsafe_model_output');
783
+ const collectionId = `checks:${claim.attemptId}`;
784
+ const payload = { subjectKind: refs.subject.kind, claimId: result.claimId,
785
+ verdict: result.verdict, reason: result.reason, evidence: result.evidence };
786
+ const records = [{ collectionId, collectionKind: 'progress',
787
+ semanticId: result.claimId, recordKind: 'progress', payload: payload }];
788
+ return sendCollection(claim, 'progress', collectionId, records, transport, usage, metadata);
789
+ }
790
+ async function runPrd(claim, dependencies) {
791
+ const reservation = claim.reservation;
792
+ if (!reservation || !claim.consentId || reservation.attemptId !== claim.attemptId
793
+ || reservation.consentId !== claim.consentId || reservation.unitKind !== 'prd'
794
+ || reservation.inputTokens !== claim.reservedInputTokens
795
+ || reservation.outputTokens !== claim.reservedOutputTokens) {
796
+ throw new TypeError('build_reservation_required');
797
+ }
798
+ if (!dependencies.backend || !dependencies.transport || !dependencies.signal || !claim.model) {
799
+ throw new TypeError('build_backend_unavailable');
800
+ }
801
+ assertInputDigest(claim);
802
+ const unit = parseJson(claim.inputRefsJson);
803
+ const response = await runPrdUnit(unit, claim.model, dependencies.signal, dependencies.backend);
804
+ const metadata = safeMetadata({ model: response.model ?? claim.model,
805
+ finishReason: response.finishReason ?? null, usageAvailable: response.usage !== null }, claim.model, response.usage !== null, backendSource(claim.backend));
806
+ if (metadata.finishReason === 'max_tokens') {
807
+ throw new WholeReadTransportError('output_truncated', backendSource(claim.backend));
808
+ }
809
+ const collectionId = `prd:${claim.attemptId}`;
810
+ const records = [{ collectionId, collectionKind: 'claims', semanticId: 'header',
811
+ recordKind: 'claim', payload: { recordType: 'header', phase: response.result.phase,
812
+ lanes: response.result.lanes,
813
+ unmatchedSectionIds: response.result.unmatchedSectionIds } }, ...response.result.claims.map((item) => ({
814
+ collectionId, collectionKind: 'claims', semanticId: item.claimId, recordKind: 'claim', payload: {
815
+ recordType: 'claim', claimId: item.claimId, laneKey: item.laneKey, text: item.text,
816
+ citations: item.citations, status: item.status,
817
+ statusSectionId: item.statusCitation?.sectionId ?? null,
818
+ statusStartChar: item.statusCitation?.startChar ?? null, statusEndChar: item.statusCitation?.endChar ?? null,
819
+ verdict: item.verdict,
820
+ },
821
+ }))];
822
+ const usage = response.usage
823
+ ? { status: 'measured', inputTokens: response.usage.inputTokens, outputTokens: response.usage.outputTokens }
824
+ : { status: 'unavailable' };
825
+ return sendCollection(claim, 'claims', collectionId, records, dependencies.transport, usage, metadata);
826
+ }
827
+ async function runRelationships(claim, dependencies) {
828
+ const reservation = claim.reservation;
829
+ if (!reservation || !claim.consentId || reservation.attemptId !== claim.attemptId
830
+ || reservation.consentId !== claim.consentId || reservation.unitKind !== 'relationship'
831
+ || reservation.inputTokens !== claim.reservedInputTokens
832
+ || reservation.outputTokens !== claim.reservedOutputTokens) {
833
+ throw new TypeError('build_reservation_required');
834
+ }
835
+ if (!dependencies.backend || !dependencies.transport || !dependencies.signal || !claim.model) {
836
+ throw new TypeError('build_backend_unavailable');
837
+ }
838
+ assertInputDigest(claim);
839
+ const refs = parseJson(claim.inputRefsJson);
840
+ if (refs.protocolVersion !== 'whole-read-relationship-unit@1'
841
+ || refs.promptVersion !== RELATIONSHIP_PROMPT_VERSION || !Array.isArray(refs.candidates)
842
+ || refs.candidates.length === 0 || !Array.isArray(refs.contexts)
843
+ || refs.candidates.some((candidate) => !candidate?.candidateId?.trim()
844
+ || !candidate.sourceModuleId?.trim() || !candidate.targetModuleId?.trim())
845
+ || new Set(refs.candidates.map((candidate) => candidate.candidateId)).size !== refs.candidates.length
846
+ || !Number.isSafeInteger(refs.promptCharacters) || refs.promptCharacters < 1
847
+ || !Number.isSafeInteger(refs.outputTokens) || refs.outputTokens < 1) {
848
+ throw new TypeError('build_input_invalid');
849
+ }
850
+ const definition = createRelationshipPrompt(refs.contexts);
851
+ const prompt = assembleWholeReadPrompt(definition, refs.contexts, [], { numberLines: false });
852
+ if (prompt.length !== refs.promptCharacters
853
+ || `sha256:${createHash('sha256').update(prompt).digest('hex')}` !== refs.promptHash) {
854
+ throw new TypeError('build_input_invalid');
855
+ }
856
+ const reply = await dependencies.backend({ prompt,
857
+ schema: relationshipTypingSchema, phase: 'relationships', model: claim.model,
858
+ maxTokens: WHOLE_READ_PHASE_POLICY.relationships.maxOutputTokens, signal: dependencies.signal });
859
+ const metadata = safeMetadata(reply.metadata, claim.model, reply.usage !== null, backendSource(claim.backend));
860
+ if (metadata.finishReason === 'max_tokens') {
861
+ throw new WholeReadTransportError('output_truncated', backendSource(claim.backend));
862
+ }
863
+ const relationships = validateRelationshipTypingResult(refs.candidates, reply.result);
864
+ if (!sanitizeBuildMetadata({ relationships }).ok)
865
+ throw new TypeError('unsafe_model_output');
866
+ const collectionId = `relationships:${claim.attemptId}`;
867
+ const records = relationships.map((relationship) => ({ collectionId,
868
+ collectionKind: 'progress', semanticId: relationship.candidateId, recordKind: 'progress',
869
+ payload: relationship }));
870
+ const usage = reply.usage
871
+ ? { status: 'measured', inputTokens: reply.usage.inputTokens,
872
+ outputTokens: reply.usage.outputTokens }
873
+ : { status: 'unavailable' };
874
+ return sendCollection(claim, 'progress', collectionId, records, dependencies.transport, usage, metadata);
875
+ }
876
+ function required(dependencies) {
877
+ if (!dependencies.source || !dependencies.transport || !dependencies.signal) {
878
+ throw new TypeError('whole_read_runner_invalid');
879
+ }
880
+ return { source: dependencies.source, transport: dependencies.transport, signal: dependencies.signal };
881
+ }
882
+ /** Closed dispatch: unknown build kinds fail here and can never reach the legacy generic runner. */
883
+ export async function dispatchWholeReadClaim(claim, dependencies) {
884
+ let requestSent = false;
885
+ let usageUnavailable = false;
886
+ const measuredUsage = { status: 'measured', inputTokens: 0, outputTokens: 0 };
887
+ const wrapped = dependencies.backend ? { ...dependencies,
888
+ backend: async (request) => {
889
+ requestSent = true;
890
+ try {
891
+ const reply = await dependencies.backend(request);
892
+ if (reply.usage) {
893
+ measuredUsage.inputTokens += reply.usage.inputTokens;
894
+ measuredUsage.outputTokens += reply.usage.outputTokens;
895
+ }
896
+ else
897
+ usageUnavailable = true;
898
+ return reply;
899
+ }
900
+ catch (error) {
901
+ usageUnavailable = true;
902
+ throw error;
903
+ }
904
+ } } : dependencies;
905
+ try {
906
+ if (claim.kind === 'inventory') {
907
+ return await (dependencies.inventory ? dependencies.inventory(claim) : runInventory(claim, dependencies));
908
+ }
909
+ if (claim.kind === 'plan') {
910
+ return await (dependencies.planning ? dependencies.planning(claim) : runPlanning(claim, dependencies));
911
+ }
912
+ if (claim.kind === 'read') {
913
+ return await (dependencies.read ? dependencies.read(claim) : runRead(claim, wrapped));
914
+ }
915
+ if (claim.kind === 'group') {
916
+ return await (dependencies.grouping ? dependencies.grouping(claim) : runGrouping(claim, wrapped));
917
+ }
918
+ if (claim.kind === 'draft') {
919
+ return await (dependencies.drafting ? dependencies.drafting(claim) : runDrafting(claim, wrapped));
920
+ }
921
+ if (claim.kind === 'check') {
922
+ return await (dependencies.checks ? dependencies.checks(claim) : runCheck(claim, wrapped));
923
+ }
924
+ if (claim.kind === 'prd') {
925
+ return await (dependencies.prd ? dependencies.prd(claim) : runPrd(claim, wrapped));
926
+ }
927
+ if (claim.kind === 'relationship') {
928
+ return await (dependencies.relationships
929
+ ? dependencies.relationships(claim) : runRelationships(claim, wrapped));
930
+ }
931
+ return { status: 'build_task_kind_unsupported' };
932
+ }
933
+ catch (error) {
934
+ const code = error && typeof error === 'object' && 'code' in error
935
+ ? error.code : undefined;
936
+ const validationFailure = claim.kind === 'draft' && typeof code === 'string'
937
+ && /^(?:draft|merge)_/.test(code)
938
+ ? { code, source: 'companion' } : null;
939
+ const failure = structuredWholeReadFailure(error) ?? structuredValidationFailure(claim, error) ?? validationFailure
940
+ ?? (!requestSent ? preSendFailure(claim, error) : null);
941
+ if (failure && dependencies.transport?.fail)
942
+ return dependencies.transport.fail(claim, failure, !requestSent ? measuredUsage : usageUnavailable ? undefined : measuredUsage);
943
+ throw error;
944
+ }
945
+ }