@tiangong-ai/cli 0.0.20 → 0.0.21

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 (45) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +148 -11
  3. package/dist/cli.js +1 -1
  4. package/dist/research/orchestration.js +166 -11
  5. package/dist/research/orchestration.js.map +1 -1
  6. package/dist/research/workspace/broker.js +407 -31
  7. package/dist/research/workspace/broker.js.map +1 -1
  8. package/dist/research/workspace/capabilities.js +65 -0
  9. package/dist/research/workspace/capabilities.js.map +1 -1
  10. package/dist/research/workspace/constants.d.ts +1 -1
  11. package/dist/research/workspace/constants.js +4 -0
  12. package/dist/research/workspace/constants.js.map +1 -1
  13. package/dist/research/workspace/evidence.d.ts +32 -0
  14. package/dist/research/workspace/evidence.js +235 -0
  15. package/dist/research/workspace/evidence.js.map +1 -0
  16. package/dist/research/workspace/executor.d.ts +11 -1
  17. package/dist/research/workspace/executor.js +678 -105
  18. package/dist/research/workspace/executor.js.map +1 -1
  19. package/dist/research/workspace/input-plan.d.ts +5 -0
  20. package/dist/research/workspace/input-plan.js +319 -0
  21. package/dist/research/workspace/input-plan.js.map +1 -0
  22. package/dist/research/workspace/journal.js +2 -1
  23. package/dist/research/workspace/journal.js.map +1 -1
  24. package/dist/research/workspace/preflight.d.ts +108 -0
  25. package/dist/research/workspace/preflight.js +261 -0
  26. package/dist/research/workspace/preflight.js.map +1 -0
  27. package/dist/research/workspace/projects.d.ts +5 -2
  28. package/dist/research/workspace/projects.js +290 -6
  29. package/dist/research/workspace/projects.js.map +1 -1
  30. package/dist/research/workspace/runtime.d.ts +5 -1
  31. package/dist/research/workspace/runtime.js +1314 -196
  32. package/dist/research/workspace/runtime.js.map +1 -1
  33. package/dist/research/workspace/sanitization.d.ts +5 -0
  34. package/dist/research/workspace/sanitization.js +72 -0
  35. package/dist/research/workspace/sanitization.js.map +1 -0
  36. package/dist/research/workspace/schemas.d.ts +17 -0
  37. package/dist/research/workspace/schemas.js +342 -0
  38. package/dist/research/workspace/schemas.js.map +1 -0
  39. package/dist/research/workspace/storage.js +4 -0
  40. package/dist/research/workspace/storage.js.map +1 -1
  41. package/dist/research/workspace/types.d.ts +160 -0
  42. package/dist/research/workspace/workspace.d.ts +14 -3
  43. package/dist/research/workspace/workspace.js +374 -12
  44. package/dist/research/workspace/workspace.js.map +1 -1
  45. package/package.json +2 -1
@@ -1,26 +1,29 @@
1
- import { cp, readFile, rm } from "node:fs/promises";
2
- import { basename, dirname, join, relative } from "node:path";
3
1
  import { randomUUID } from "node:crypto";
2
+ import { cp, lstat, readFile, rm } from "node:fs/promises";
3
+ import { basename, dirname, join, relative } from "node:path";
4
4
  import { CliError } from "../../errors.js";
5
5
  import { stageLockedCapabilities, verifyCapabilities } from "./capabilities.js";
6
6
  import { startCapabilityBroker } from "./broker.js";
7
+ import { loadProjectEvidenceReceipts, stageProjectEvidence } from "./evidence.js";
7
8
  import { executeAgent } from "./executor.js";
8
- import { appendJournalEvent, readJournal, verifyJournal } from "./journal.js";
9
+ import { renderInputLineContext } from "./input-plan.js";
10
+ import { appendJournalEvent, verifyJournal } from "./journal.js";
11
+ import { RESEARCH_AGENT_PROTOCOL_OVERHEAD_TOKENS, RESEARCH_BROKER_MAX_TURNS, RESEARCH_ESTIMATED_BYTES_PER_TOKEN, RESEARCH_MAX_REPAIR_SOURCE_BYTES, RESEARCH_REPAIR_MAX_TURNS, RESEARCH_STRUCTURED_OUTPUT_MAX_TURNS, reservedAgentPackageCost, } from "./preflight.js";
9
12
  import { listProjects, loadProject, nextReadyPackage, packageById, refreshProject, saveProject, } from "./projects.js";
10
- import { ensureDirectory, canonicalJson, fileRecord, isObject, pathExists, regularTreeFiles, resolveContained, sha256File, sha256Text, workspacePaths, writeJsonAtomic, writeTextAtomic, } from "./storage.js";
11
- import { loadWorkspaceConfig, withWorkspaceLock } from "./workspace.js";
13
+ import { configuredResearchSecrets, sanitizeResearchRecord, sanitizeResearchText, } from "./sanitization.js";
14
+ import { parseStructuredStageOutput, schemaForStage, StructuredOutputError } from "./schemas.js";
15
+ import { canonicalJson, ensureDirectory, fileRecord, isObject, pathExists, readJsonFile, regularTreeFiles, resolveContained, sha256File, sha256Text, workspacePaths, writeJsonAtomic, writeTextAtomic, } from "./storage.js";
16
+ import { loadWorkspaceConfig, verifyDoctorAttestation, withWorkspaceLock } from "./workspace.js";
12
17
  export async function runResearchWorkspace(root, options, packageExecutor = executeAgent) {
13
18
  validateRunOptions(options);
19
+ const requestId = randomUUID();
14
20
  if (options.dryRun)
15
- return dryRunResult(root);
21
+ return dryRunResult(root, requestId, options.projectId);
16
22
  return withWorkspaceLock(root, "research.run", async () => {
23
+ await verifyJournal(workspacePaths(root).journal);
17
24
  const config = await loadWorkspaceConfig(root);
18
- if (config.producer.agent === config.reviewer.agent) {
19
- throw new CliError("Research producer and reviewer must use different agent families.", {
20
- code: "RESEARCH_REVIEW_ROUTE_INVALID",
21
- exitCode: 3,
22
- });
23
- }
25
+ assertExecutionConfiguration(config);
26
+ let doctorAttestation = null;
24
27
  const capabilities = await verifyCapabilities(root);
25
28
  if (capabilities.status !== "verified") {
26
29
  throw new CliError("Research capabilities are not locked and verified.", {
@@ -29,10 +32,31 @@ export async function runResearchWorkspace(root, options, packageExecutor = exec
29
32
  details: capabilities,
30
33
  });
31
34
  }
35
+ if (config.mode === "production-research") {
36
+ const verification = await verifyDoctorAttestation(root);
37
+ if (verification.status !== "verified" || !verification.attestation) {
38
+ throw new CliError("Production research requires a current successful producer/reviewer doctor smoke.", {
39
+ code: "RESEARCH_DOCTOR_ATTESTATION_REQUIRED",
40
+ exitCode: 3,
41
+ details: { status: verification.status, errors: verification.errors },
42
+ });
43
+ }
44
+ doctorAttestation = verification.attestation;
45
+ const unconfirmed = (await projectsForRun(root, options.projectId)).filter((project) => config.budget.maxCostUsd > config.budget.confirmationCostUsd &&
46
+ !project.budgetConfirmedAt);
47
+ if (unconfirmed.length) {
48
+ throw new CliError("Production research budget has not been explicitly confirmed.", {
49
+ code: "RESEARCH_BUDGET_CONFIRMATION_REQUIRED",
50
+ exitCode: 3,
51
+ details: { projects: unconfirmed.map((project) => project.id) },
52
+ });
53
+ }
54
+ }
55
+ emitProgress(options, progressEvent("run.started", requestId, options.projectId ?? null, null, null));
32
56
  const executed = [];
33
57
  let cycles = 0;
34
58
  while (cycles < options.maxCycles) {
35
- const projects = await listProjects(root);
59
+ const projects = await projectsForRun(root, options.projectId);
36
60
  const selected = projects
37
61
  .map((project) => ({ project, workPackage: nextReadyPackage(project) }))
38
62
  .filter((item) => Boolean(item.workPackage) &&
@@ -42,78 +66,147 @@ export async function runResearchWorkspace(root, options, packageExecutor = exec
42
66
  if (!selected.length)
43
67
  break;
44
68
  cycles += 1;
45
- const results = await Promise.all(selected.map(({ project, workPackage }) => executeWorkPackage(root, project.id, workPackage.id, config, options.environment, packageExecutor)));
69
+ const results = await Promise.all(selected.map(({ project, workPackage }) => executeWorkPackage(root, project.id, workPackage.id, config, options, requestId, packageExecutor, doctorAttestation)));
46
70
  executed.push(...results);
47
71
  }
48
- return summarizeRun(root, cycles, executed, options.maxCycles);
72
+ const result = await summarizeRun(root, requestId, cycles, executed, options.maxCycles, options.projectId);
73
+ emitProgress(options, progressEvent("run.completed", requestId, options.projectId ?? null, null, null, {
74
+ status: result.status,
75
+ stopReason: result.stopReason,
76
+ }));
77
+ return result;
49
78
  });
50
79
  }
51
- async function executeWorkPackage(root, projectId, packageId, config, environment, packageExecutor) {
80
+ async function executeWorkPackage(root, projectId, packageId, config, options, requestId, packageExecutor, doctorAttestation) {
52
81
  const project = await loadProject(root, projectId);
53
82
  const workPackage = packageById(project, packageId);
54
83
  const now = new Date().toISOString();
55
84
  workPackage.status = "running";
56
85
  workPackage.attempts += 1;
57
86
  workPackage.startedAt = now;
87
+ workPackage.completedAt = null;
58
88
  workPackage.lastError = null;
89
+ workPackage.lastFailureKind = null;
90
+ workPackage.retryNotBefore = null;
59
91
  project.status = "running";
60
92
  project.updatedAt = now;
61
93
  await saveProject(root, project);
62
94
  await appendJournalEvent(workspacePaths(root).journal, "package.started", projectId, {
95
+ requestId,
63
96
  projectId,
64
97
  packageId,
65
98
  attempt: workPackage.attempts,
66
99
  });
100
+ emitProgress(options, progressEvent("package.started", requestId, projectId, packageId, remainingBudget(project, config), { attempt: workPackage.attempts }));
67
101
  const runId = randomUUID();
68
102
  const startedAt = new Date().toISOString();
69
103
  let capsuleRoot;
70
104
  let broker;
71
105
  let accountedResult;
106
+ let promotedOutputs = [];
107
+ let executor = "mechanical";
72
108
  try {
73
- if (workPackage.kind !== "verify")
74
- assertBudget(project, config);
75
109
  let result;
76
- let outputs;
77
- let executor;
78
110
  if (workPackage.kind === "verify") {
79
111
  result = await closeProjectMechanically(root, project, workPackage);
80
112
  accountedResult = result;
81
- outputs = await outputRecords(root, project, workPackage.expectedOutputs);
82
- executor = "mechanical";
113
+ promotedOutputs = await outputRecords(root, project, workPackage.expectedOutputs);
83
114
  }
84
115
  else {
116
+ const reservation = reservePackageBudget(project, workPackage, config);
85
117
  const capsule = await createCapsule(root, project, workPackage, runId);
86
118
  capsuleRoot = capsule.capsuleRoot;
119
+ if (capsule.reviewPacketRecord) {
120
+ await appendJournalEvent(workspacePaths(root).journal, "review.packet.persisted", projectId, {
121
+ requestId,
122
+ projectId,
123
+ packageId,
124
+ packetSha256: capsule.reviewPacketSha256,
125
+ packet: capsule.reviewPacketRecord,
126
+ });
127
+ }
128
+ const stageContextContent = await stageContextForPackage(capsule.projectRoot, workPackage, config);
87
129
  const route = workPackage.executor === "reviewer" ? config.reviewer : config.producer;
88
130
  executor = route.agent;
89
- broker = await startCapabilityBroker(root, project.id, capsule.projectRoot);
90
- result = await packageExecutor({
131
+ broker =
132
+ workPackage.stage === "discover"
133
+ ? await startCapabilityBroker(root, project.id, capsule.projectRoot)
134
+ : undefined;
135
+ const primaryBrokerUrl = broker?.url ?? null;
136
+ const inputOnlyProvenance = workPackage.stage === "discover" && primaryBrokerUrl === null;
137
+ const primaryRequest = agentRequest({
138
+ root,
139
+ project,
140
+ workPackage,
91
141
  route,
92
- prompt: packagePrompt(project, workPackage, capsule.inputManifest, capsule.stagedSkills, capsule.reviewPacketSha256),
93
- capsuleRoot: capsule.capsuleRoot,
94
- projectRoot: capsule.projectRoot,
95
- workspaceRoot: root,
96
- timeoutSeconds: remainingWallSeconds(project, config),
97
- environment,
98
- brokerUrl: broker?.url ?? null,
142
+ capsule,
143
+ config,
144
+ options,
145
+ requestId,
146
+ purpose: "primary",
147
+ prompt: packagePrompt(project, workPackage, capsule.inputManifest, capsule.stagedSkills, capsule.reviewPacketSha256, capsule.contextBundle, capsule.contextBundleContent, stageContextContent),
148
+ brokerUrl: primaryBrokerUrl,
149
+ inputOnlyProvenance,
150
+ maxOutputTokens: Math.min(config.budget.maxOutputTokens, reservation.tokens),
151
+ maxCostUsd: reservation.costUsd,
152
+ expectedRuntime: runtimeForRoute(doctorAttestation, route),
99
153
  });
154
+ assertPreCallTokenReservation(project, workPackage, config, primaryRequest, 0, true);
155
+ result = await withHeartbeat(packageExecutor(primaryRequest), options, requestId, project, workPackage, config);
100
156
  accountedResult = result;
101
- if (result.exitCode !== 0) {
102
- throw new Error(`executor exited ${result.exitCode}: ${bounded(result.stderr, 1000)}`);
157
+ assertExecutorSucceeded(result);
158
+ assertActualPackageBudget(project, workPackage, config, result, config.budget.maxOutputTokens);
159
+ try {
160
+ await materializeAndValidateStageOutput(root, project, capsule.projectRoot, workPackage, result.stdout, capsule.reviewPacketSha256);
161
+ }
162
+ catch (error) {
163
+ if (!(error instanceof StructuredOutputError))
164
+ throw error;
165
+ const repairTokens = availableRepairTokens(project, workPackage, config, result);
166
+ if (repairTokens < 1)
167
+ throw error;
168
+ const repairRequest = agentRequest({
169
+ root,
170
+ project,
171
+ workPackage,
172
+ route,
173
+ capsule,
174
+ config,
175
+ options,
176
+ requestId,
177
+ purpose: "repair",
178
+ prompt: repairPrompt(workPackage, result.stdout, error),
179
+ brokerUrl: null,
180
+ inputOnlyProvenance,
181
+ maxOutputTokens: repairTokens,
182
+ maxCostUsd: Math.max(0, reservation.costUsd - result.costUsd),
183
+ maxWallSeconds: Math.max(1, config.budget.packageMaxWallSeconds[workPackage.stage] -
184
+ result.wallSeconds),
185
+ expectedRuntime: runtimeForRoute(doctorAttestation, route),
186
+ });
187
+ assertPreCallTokenReservation(project, workPackage, config, repairRequest, result.tokens, false);
188
+ const repair = await withHeartbeat(packageExecutor(repairRequest), options, requestId, project, workPackage, config);
189
+ accountedResult = combineExecutionResults(result, repair);
190
+ assertExecutorSucceeded(repair);
191
+ assertActualPackageBudget(project, workPackage, config, accountedResult, config.budget.maxOutputTokens + config.budget.maxRepairTokens);
192
+ await materializeAndValidateStageOutput(root, project, capsule.projectRoot, workPackage, repair.stdout, capsule.reviewPacketSha256);
193
+ }
194
+ assertProjectedBudget(project, config, accountedResult);
195
+ promotedOutputs = await validateAndImportOutputs(root, project, workPackage, capsule.projectRoot, config, capsule.reviewPacketSha256);
196
+ if (workPackage.stage === "discover") {
197
+ await assertEvidenceCoverage(root, project);
103
198
  }
104
- assertProjectedBudget(project, config, result);
105
- outputs = await validateAndImportOutputs(root, project, workPackage, capsule.projectRoot, config, capsule.reviewPacketSha256);
106
199
  }
107
200
  const completedAt = new Date().toISOString();
108
- project.usage.tokens += result.tokens;
109
- project.usage.costUsd = roundMoney(project.usage.costUsd + result.costUsd);
110
- project.usage.wallSeconds += result.wallSeconds;
201
+ applyUsage(project, accountedResult);
111
202
  workPackage.status = "complete";
112
203
  workPackage.completedAt = completedAt;
113
204
  workPackage.lastError = null;
205
+ workPackage.lastFailureKind = null;
206
+ workPackage.retryNotBefore = null;
114
207
  refreshProject(project);
115
208
  await saveProject(root, project);
116
- const record = {
209
+ await writeRunRecord(root, {
117
210
  schemaVersion: 1,
118
211
  runId,
119
212
  projectId,
@@ -121,56 +214,105 @@ async function executeWorkPackage(root, projectId, packageId, config, environmen
121
214
  executor,
122
215
  startedAt,
123
216
  completedAt,
124
- exitCode: result.exitCode,
125
- tokens: result.tokens,
126
- costUsd: result.costUsd,
127
- wallSeconds: result.wallSeconds,
128
- outputs,
129
- stdoutSha256: sha256Text(result.stdout),
130
- stderrSha256: sha256Text(result.stderr),
131
- };
132
- await writeJsonAtomic(join(projectRoot(root, projectId), "runs", `${runId}.json`), record);
217
+ exitCode: accountedResult.exitCode,
218
+ tokens: accountedResult.tokens,
219
+ inputTokens: accountedResult.inputTokens,
220
+ cachedInputTokens: accountedResult.cachedInputTokens,
221
+ outputTokens: accountedResult.outputTokens,
222
+ costUsd: accountedResult.costUsd,
223
+ wallSeconds: accountedResult.wallSeconds,
224
+ outputs: promotedOutputs,
225
+ stdoutSha256: sha256Text(accountedResult.stdout),
226
+ stderrSha256: sha256Text(accountedResult.stderr),
227
+ failureKind: null,
228
+ failureDetails: null,
229
+ runtime: accountedResult.runtime,
230
+ telemetry: accountedResult.telemetry,
231
+ });
232
+ const usage = usageSlice(accountedResult);
133
233
  await appendJournalEvent(workspacePaths(root).journal, "package.completed", projectId, {
234
+ requestId,
134
235
  projectId,
135
236
  packageId,
136
237
  runId,
137
238
  executor,
138
- outputs,
139
- usage: { tokens: result.tokens, costUsd: result.costUsd, wallSeconds: result.wallSeconds },
239
+ outputs: promotedOutputs,
240
+ usage,
241
+ runtime: accountedResult.runtime,
140
242
  });
243
+ emitProgress(options, progressEvent("package.completed", requestId, projectId, packageId, remainingBudget(project, config), { outputs: promotedOutputs, usage }));
141
244
  return { projectId, packageId, status: "complete" };
142
245
  }
143
246
  catch (error) {
144
247
  const failedProject = await loadProject(root, projectId);
145
248
  const failedPackage = packageById(failedProject, packageId);
146
- const message = bounded(error instanceof Error ? error.message : String(error), 2000);
147
- if (accountedResult) {
148
- failedProject.usage.tokens += accountedResult.tokens;
149
- failedProject.usage.costUsd = roundMoney(failedProject.usage.costUsd + accountedResult.costUsd);
150
- failedProject.usage.wallSeconds += accountedResult.wallSeconds;
151
- }
249
+ const secrets = configuredResearchSecrets(options.environment);
250
+ const failureDetails = sanitizedFailureDetails(error, secrets);
251
+ const gapSummary = Array.isArray(failureDetails?.gaps)
252
+ ? failureDetails.gaps.filter((gap) => typeof gap === "string").join("; ")
253
+ : "";
254
+ const message = bounded(sanitizeResearchText(`${error instanceof Error ? error.message : String(error)}${gapSummary ? ` ${gapSummary}` : ""}`, secrets), 2000);
255
+ if (accountedResult)
256
+ applyUsage(failedProject, accountedResult);
257
+ const classification = classifyFailure(error);
152
258
  failedPackage.lastError = message;
259
+ failedPackage.lastFailureKind = classification.kind;
153
260
  failedPackage.completedAt = new Date().toISOString();
154
- const nonRetryable = error instanceof CliError && error.code === "RESEARCH_BUDGET_EXHAUSTED";
155
- failedPackage.status =
156
- !nonRetryable && failedPackage.attempts < failedPackage.maxAttempts ? "retry" : "failed";
261
+ const retryable = classification.retryable && failedPackage.attempts < failedPackage.maxAttempts;
262
+ failedPackage.status = retryable ? "retry" : "failed";
263
+ failedPackage.retryNotBefore = retryable
264
+ ? retryNotBefore(classification.retryAfterSeconds)
265
+ : null;
157
266
  refreshProject(failedProject);
158
267
  await saveProject(root, failedProject);
268
+ if (accountedResult) {
269
+ await writeRunRecord(root, {
270
+ schemaVersion: 1,
271
+ runId,
272
+ projectId,
273
+ packageId,
274
+ executor,
275
+ startedAt,
276
+ completedAt: failedPackage.completedAt,
277
+ exitCode: accountedResult.exitCode,
278
+ tokens: accountedResult.tokens,
279
+ inputTokens: accountedResult.inputTokens,
280
+ cachedInputTokens: accountedResult.cachedInputTokens,
281
+ outputTokens: accountedResult.outputTokens,
282
+ costUsd: accountedResult.costUsd,
283
+ wallSeconds: accountedResult.wallSeconds,
284
+ outputs: promotedOutputs,
285
+ stdoutSha256: sha256Text(accountedResult.stdout),
286
+ stderrSha256: sha256Text(accountedResult.stderr),
287
+ failureKind: classification.kind,
288
+ failureDetails,
289
+ runtime: accountedResult.runtime,
290
+ telemetry: accountedResult.telemetry,
291
+ });
292
+ }
293
+ const usage = accountedResult ? usageSlice(accountedResult) : zeroUsageSlice();
159
294
  await appendJournalEvent(workspacePaths(root).journal, "package.failed", projectId, {
295
+ requestId,
160
296
  projectId,
161
297
  packageId,
162
298
  runId,
163
299
  attempt: failedPackage.attempts,
164
- retryable: failedPackage.status === "retry",
300
+ retryable,
301
+ retryNotBefore: failedPackage.retryNotBefore,
302
+ failureKind: classification.kind,
165
303
  error: message,
166
- usage: accountedResult
167
- ? {
168
- tokens: accountedResult.tokens,
169
- costUsd: accountedResult.costUsd,
170
- wallSeconds: accountedResult.wallSeconds,
171
- }
172
- : { tokens: 0, costUsd: 0, wallSeconds: 0 },
304
+ details: failureDetails,
305
+ outputs: promotedOutputs,
306
+ usage,
173
307
  });
308
+ emitProgress(options, progressEvent("package.failed", requestId, projectId, packageId, remainingBudget(failedProject, config), {
309
+ retryable,
310
+ retryNotBefore: failedPackage.retryNotBefore,
311
+ failureKind: classification.kind,
312
+ error: message,
313
+ details: failureDetails,
314
+ usage,
315
+ }));
174
316
  return { projectId, packageId, status: failedPackage.status };
175
317
  }
176
318
  finally {
@@ -198,64 +340,418 @@ async function createCapsule(root, project, workPackage, runId) {
198
340
  const inputManifest = [];
199
341
  for (const input of project.inputs) {
200
342
  if ((await sha256File(input.path)) !== input.sha256) {
201
- throw new Error(`input drift detected: ${input.id}`);
343
+ throw new CliError(`Input drift detected: ${input.id}.`, {
344
+ code: "RESEARCH_INPUT_DRIFT",
345
+ exitCode: 3,
346
+ });
202
347
  }
203
348
  const logical = join("inputs", input.id, basename(input.path)).replaceAll("\\", "/");
204
- const destination = join(capsuleProject, logical);
205
- await ensureDirectory(dirname(destination));
206
- await cp(input.path, destination, { force: false });
207
- inputManifest.push({ id: input.id, role: input.role, path: logical, sha256: input.sha256 });
349
+ const hasBoundedContext = Boolean((input.contextPath || input.contextRanges?.length) &&
350
+ input.contextSha256 &&
351
+ input.contextBytes !== undefined);
352
+ const fullTextStaged = !hasBoundedContext || workPackage.stage === "review";
353
+ if (fullTextStaged) {
354
+ const destination = join(capsuleProject, logical);
355
+ await ensureDirectory(dirname(destination));
356
+ await cp(input.path, destination, { force: false });
357
+ }
358
+ let contextPath = logical;
359
+ let contextSha256 = input.sha256;
360
+ let contextBytes = input.bytes;
361
+ if (hasBoundedContext) {
362
+ const contextContent = input.contextRanges?.length
363
+ ? await renderInputLineContext(input.path, input.contextRanges)
364
+ : null;
365
+ const actualContextSha256 = contextContent
366
+ ? sha256Text(contextContent)
367
+ : await sha256File(input.contextPath);
368
+ if (actualContextSha256 !== input.contextSha256) {
369
+ throw new CliError(`Input context drift detected: ${input.id}.`, {
370
+ code: "RESEARCH_INPUT_DRIFT",
371
+ exitCode: 3,
372
+ });
373
+ }
374
+ contextPath = join("inputs", input.id, "context", input.contextPath ? basename(input.contextPath) : "selected-lines.txt").replaceAll("\\", "/");
375
+ const destination = join(capsuleProject, contextPath);
376
+ await ensureDirectory(dirname(destination));
377
+ if (contextContent === null) {
378
+ await cp(input.contextPath, destination, { force: false });
379
+ }
380
+ else {
381
+ await writeTextAtomic(destination, contextContent);
382
+ }
383
+ contextSha256 = input.contextSha256;
384
+ contextBytes = input.contextBytes;
385
+ }
386
+ inputManifest.push({
387
+ id: input.id,
388
+ role: input.role,
389
+ path: logical,
390
+ sha256: input.sha256,
391
+ bytes: input.bytes,
392
+ contextPath,
393
+ contextSha256,
394
+ contextBytes,
395
+ fullTextStaged,
396
+ });
208
397
  }
209
398
  await writeJsonAtomic(join(capsuleProject, "inputs", "manifest.json"), inputManifest);
399
+ const contextBundleContent = await buildInputContextBundle(capsuleProject, inputManifest);
400
+ const contextBundlePath = join(capsuleProject, "inputs", "context-bundle.txt");
401
+ await writeTextAtomic(contextBundlePath, contextBundleContent);
402
+ const contextBundle = await fileRecord(contextBundlePath, "inputs/context-bundle.txt");
403
+ const evidenceReceipts = await stageProjectEvidence(root, project.id, capsuleProject);
404
+ await writeJsonAtomic(join(capsuleProject, "inputs", "evidence-receipts.json"), evidenceReceipts.map(reviewSafeReceipt));
210
405
  await writeJsonAtomic(join(capsuleProject, "project.json"), {
211
406
  ...project,
212
407
  inputs: project.inputs.map((input, index) => ({
213
408
  ...input,
214
409
  path: inputManifest[index]?.path ?? "inputs/unavailable",
410
+ contextPath: inputManifest[index]?.contextPath ?? "inputs/unavailable",
215
411
  })),
216
412
  });
217
413
  const stagedSkills = await stageLockedCapabilities(root, join(capsuleProject, "skills"));
218
- const reviewPacketSha256 = workPackage.stage === "review"
219
- ? await writeReviewPacket(capsuleProject, project, inputManifest)
414
+ const reviewEvidenceContext = workPackage.stage === "review"
415
+ ? await writeReviewEvidenceContext(root, project.id, capsuleProject, contextBundleContent, evidenceReceipts)
416
+ : null;
417
+ const reviewPacket = reviewEvidenceContext
418
+ ? await writeReviewPacket(root, capsuleProject, project, inputManifest, evidenceReceipts, reviewEvidenceContext.persistent)
220
419
  : null;
221
420
  return {
222
421
  capsuleRoot,
223
422
  projectRoot: capsuleProject,
224
423
  inputManifest,
424
+ contextBundle,
425
+ contextBundleContent,
225
426
  stagedSkills,
226
- reviewPacketSha256,
427
+ reviewPacketSha256: reviewPacket?.sha256 ?? null,
428
+ reviewPacketRecord: reviewPacket?.record ?? null,
227
429
  };
228
430
  }
229
- async function writeReviewPacket(capsuleProject, project, inputManifest) {
431
+ async function buildInputContextBundle(capsuleProject, inputManifest) {
432
+ const sections = ["TIANGONG BOUNDED INPUT CONTEXT BUNDLE v1"];
433
+ for (const input of [...inputManifest].sort((left, right) => left.id.localeCompare(right.id))) {
434
+ const context = await readFile(resolveContained(capsuleProject, input.contextPath), "utf8");
435
+ sections.push([
436
+ `--- INPUT ${input.id} ---`,
437
+ `role: ${input.role}`,
438
+ `fullEvidenceLocator: ${input.path}`,
439
+ `fullEvidenceSha256: ${input.sha256}`,
440
+ `contextLocator: ${input.contextPath}`,
441
+ `contextSha256: ${input.contextSha256}`,
442
+ "--- BEGIN CONTEXT ---",
443
+ context.trimEnd(),
444
+ "--- END CONTEXT ---",
445
+ ].join("\n"));
446
+ }
447
+ return `${sections.join("\n\n")}\n`;
448
+ }
449
+ async function writeReviewPacket(root, capsuleProject, project, inputManifest, evidenceReceipts, reviewEvidenceContext) {
230
450
  const artifactPaths = ["outputs/evidence.json", "outputs/analysis.json", "outputs/report.md"];
451
+ const evidenceFiles = new Map();
452
+ for (const receipt of evidenceReceipts) {
453
+ for (const locator of [receipt.locator, receipt.contextLocator]) {
454
+ if (!evidenceFiles.has(locator)) {
455
+ evidenceFiles.set(locator, await fileRecord(resolveContained(capsuleProject, locator), locator));
456
+ }
457
+ }
458
+ }
459
+ const environment = await reviewEnvironmentPacket(root, project.id);
460
+ const inputFiles = new Map();
461
+ for (const input of inputManifest) {
462
+ for (const locator of [input.path, input.contextPath]) {
463
+ if (!inputFiles.has(locator)) {
464
+ inputFiles.set(locator, await fileRecord(resolveContained(capsuleProject, locator), locator));
465
+ }
466
+ }
467
+ }
468
+ await writeJsonAtomic(join(capsuleProject, "inputs", "runtime-fingerprint.json"), environment);
231
469
  const packet = {
232
470
  schemaVersion: 1,
233
471
  projectId: project.id,
234
472
  questionSha256: sha256Text(project.question),
473
+ evidenceRequirements: project.evidenceRequirements,
235
474
  inputs: inputManifest,
475
+ reviewEvidenceContext,
476
+ inputFiles: [...inputFiles.values()].sort((left, right) => left.path.localeCompare(right.path)),
477
+ evidenceReceipts: evidenceReceipts.map(reviewSafeReceipt),
478
+ evidenceFiles: [...evidenceFiles.values()].sort((left, right) => left.path.localeCompare(right.path)),
479
+ environment,
480
+ environmentFile: await fileRecord(join(capsuleProject, "inputs", "runtime-fingerprint.json"), "inputs/runtime-fingerprint.json"),
236
481
  artifacts: await Promise.all(artifactPaths.map((logicalPath) => fileRecord(resolveContained(capsuleProject, logicalPath), logicalPath))),
237
482
  };
238
483
  const packetSha256 = sha256Text(canonicalJson(packet));
239
- await writeJsonAtomic(join(capsuleProject, "inputs", "review-packet.json"), {
484
+ const completePacket = {
240
485
  ...packet,
241
486
  packetSha256,
242
- });
243
- return packetSha256;
487
+ };
488
+ await writeJsonAtomic(join(capsuleProject, "inputs", "review-packet.json"), completePacket);
489
+ const record = await persistReviewPacket(root, project.id, completePacket, packetSha256);
490
+ return { sha256: packetSha256, record };
491
+ }
492
+ async function writeReviewEvidenceContext(root, projectId, capsuleProject, inputContextBundle, evidenceReceipts) {
493
+ const sections = [
494
+ "TIANGONG REVIEW EVIDENCE CONTEXT v1",
495
+ "The following are exact, hash-verified bounded views. Full objects are bound in the review packet.",
496
+ inputContextBundle.trimEnd(),
497
+ ];
498
+ const seen = new Set();
499
+ for (const receipt of [...evidenceReceipts].sort((left, right) => left.attemptId.localeCompare(right.attemptId))) {
500
+ if (seen.has(receipt.contextLocator))
501
+ continue;
502
+ seen.add(receipt.contextLocator);
503
+ const metadata = reviewSafeReceipt(receipt);
504
+ const content = reviewableTextContentType(receipt.contentType)
505
+ ? await readFile(resolveContained(capsuleProject, receipt.contextLocator), "utf8")
506
+ : "[Binary bounded view omitted from model context; verify the bound file mechanically.]";
507
+ sections.push([
508
+ `--- BROKER RECEIPT ${receipt.attemptId} ---`,
509
+ `metadata: ${JSON.stringify(metadata)}`,
510
+ "--- BEGIN BOUNDED VIEW ---",
511
+ content.trimEnd(),
512
+ "--- END BOUNDED VIEW ---",
513
+ ].join("\n"));
514
+ }
515
+ const logicalPath = "inputs/review-evidence-context.txt";
516
+ const path = resolveContained(capsuleProject, logicalPath);
517
+ const content = `${sections.join("\n\n")}\n`;
518
+ await writeTextAtomic(path, content);
519
+ const capsule = await fileRecord(path, logicalPath);
520
+ const persistentLogicalPath = `review/contexts/${capsule.sha256}.txt`;
521
+ const persistentPath = resolveContained(projectRoot(root, projectId), persistentLogicalPath);
522
+ if (await pathExists(persistentPath)) {
523
+ const existing = await fileRecord(persistentPath, persistentLogicalPath);
524
+ if (existing.sha256 !== capsule.sha256 || existing.bytes !== capsule.bytes) {
525
+ throw new CliError("Content-addressed review evidence context drift detected.", {
526
+ code: "RESEARCH_REVIEW_CONTEXT_DRIFT",
527
+ exitCode: 3,
528
+ });
529
+ }
530
+ }
531
+ else {
532
+ await ensureDirectory(dirname(persistentPath));
533
+ await writeTextAtomic(persistentPath, content);
534
+ }
535
+ return {
536
+ capsule,
537
+ persistent: await fileRecord(persistentPath, persistentLogicalPath),
538
+ };
539
+ }
540
+ function reviewableTextContentType(contentType) {
541
+ return /^(?:text\/|application\/(?:[^;]+\+)?(?:json|xml|javascript|xhtml\+xml|csv))(?:;|$)/i.test(contentType);
542
+ }
543
+ async function persistReviewPacket(root, projectId, packet, packetSha256) {
544
+ const logicalPath = `review/packets/${packetSha256}.json`;
545
+ const path = resolveContained(projectRoot(root, projectId), logicalPath);
546
+ if (await pathExists(path)) {
547
+ const existing = await readJsonFile(path, "Research review packet");
548
+ verifyReviewPacketValue(existing, packetSha256);
549
+ if (canonicalJson(existing) !== canonicalJson(packet)) {
550
+ throw new CliError("Content-addressed review packet collision or drift detected.", {
551
+ code: "RESEARCH_REVIEW_PACKET_DRIFT",
552
+ exitCode: 3,
553
+ });
554
+ }
555
+ }
556
+ else {
557
+ await ensureDirectory(dirname(path));
558
+ await writeJsonAtomic(path, packet);
559
+ }
560
+ return fileRecord(path, logicalPath);
561
+ }
562
+ async function loadVerifiedReviewPacket(root, projectId, packetSha256) {
563
+ const logicalPath = `review/packets/${packetSha256}.json`;
564
+ const path = resolveContained(projectRoot(root, projectId), logicalPath);
565
+ const packet = await readJsonFile(path, "Research review packet");
566
+ verifyReviewPacketValue(packet, packetSha256);
567
+ const context = packet.reviewEvidenceContext;
568
+ if (!isObject(context) ||
569
+ typeof context.path !== "string" ||
570
+ typeof context.sha256 !== "string" ||
571
+ !/^[0-9a-f]{64}$/.test(context.sha256) ||
572
+ !Number.isInteger(context.bytes) ||
573
+ context.path !== `review/contexts/${context.sha256}.txt`) {
574
+ throw new CliError("Persistent review packet has an invalid evidence context binding.", {
575
+ code: "RESEARCH_REVIEW_CONTEXT_DRIFT",
576
+ exitCode: 3,
577
+ });
578
+ }
579
+ let actualContext;
580
+ try {
581
+ actualContext = await fileRecord(resolveContained(projectRoot(root, projectId), context.path), context.path);
582
+ }
583
+ catch {
584
+ throw new CliError("Persistent review evidence context is missing or invalid.", {
585
+ code: "RESEARCH_REVIEW_CONTEXT_DRIFT",
586
+ exitCode: 3,
587
+ });
588
+ }
589
+ if (actualContext.sha256 !== context.sha256 || actualContext.bytes !== context.bytes) {
590
+ throw new CliError("Persistent review evidence context failed hash verification.", {
591
+ code: "RESEARCH_REVIEW_CONTEXT_DRIFT",
592
+ exitCode: 3,
593
+ });
594
+ }
595
+ return fileRecord(path, logicalPath);
596
+ }
597
+ function verifyReviewPacketValue(packet, packetSha256) {
598
+ const { packetSha256: recordedSha256, ...body } = packet;
599
+ if (recordedSha256 !== packetSha256 || sha256Text(canonicalJson(body)) !== packetSha256) {
600
+ throw new CliError("Persistent review packet failed content-address verification.", {
601
+ code: "RESEARCH_REVIEW_PACKET_DRIFT",
602
+ exitCode: 3,
603
+ });
604
+ }
605
+ }
606
+ async function reviewEnvironmentPacket(root, projectId) {
607
+ const paths = workspacePaths(root);
608
+ const runtimeLock = await readJsonFile(paths.runtimeLock, "Research runtime lock");
609
+ const capabilityLock = (await pathExists(paths.capabilityLock))
610
+ ? await readJsonFile(paths.capabilityLock, "Capability lock")
611
+ : { capabilities: [] };
612
+ const capabilities = Array.isArray(capabilityLock.capabilities)
613
+ ? capabilityLock.capabilities.filter(isObject).map((record) => ({
614
+ id: record.id,
615
+ skillName: record.skillName,
616
+ treeSha256: record.treeSha256,
617
+ policySha256: record.policySha256,
618
+ permissions: record.permissions,
619
+ credentialIds: record.credentialIds,
620
+ }))
621
+ : [];
622
+ const runsPath = join(projectRoot(root, projectId), "runs");
623
+ const priorRuns = [];
624
+ if (await pathExists(runsPath)) {
625
+ for (const path of await regularTreeFiles(runsPath)) {
626
+ const record = JSON.parse(await readFile(path, "utf8"));
627
+ if (!isObject(record))
628
+ continue;
629
+ priorRuns.push({
630
+ runId: record.runId,
631
+ packageId: record.packageId,
632
+ executor: record.executor,
633
+ tokens: record.tokens,
634
+ inputTokens: record.inputTokens,
635
+ cachedInputTokens: record.cachedInputTokens,
636
+ outputTokens: record.outputTokens,
637
+ costUsd: record.costUsd,
638
+ outputs: record.outputs,
639
+ runtime: record.runtime,
640
+ telemetry: record.telemetry,
641
+ });
642
+ }
643
+ }
644
+ return {
645
+ schemaVersion: 1,
646
+ cli: {
647
+ packageName: runtimeLock.packageName,
648
+ packageVersion: runtimeLock.packageVersion,
649
+ protocolVersion: runtimeLock.protocolVersion,
650
+ },
651
+ capabilities,
652
+ priorRuns: priorRuns.sort((left, right) => String(left.packageId).localeCompare(String(right.packageId))),
653
+ };
654
+ }
655
+ function reviewSafeReceipt(receipt) {
656
+ return {
657
+ schemaVersion: receipt.schemaVersion,
658
+ attemptId: receipt.attemptId,
659
+ capabilityId: receipt.capabilityId,
660
+ status: receipt.status,
661
+ contentType: receipt.contentType,
662
+ bytes: receipt.bytes,
663
+ sha256: receipt.sha256,
664
+ sourceSha256: receipt.sourceSha256,
665
+ locator: receipt.locator,
666
+ contextLocator: receipt.contextLocator,
667
+ contextSha256: receipt.contextSha256,
668
+ contextBytes: receipt.contextBytes,
669
+ contextEstimatedTokens: receipt.contextEstimatedTokens,
670
+ contextItems: receipt.contextItems,
671
+ contextOffset: receipt.contextOffset ?? 0,
672
+ contextTotalItems: receipt.contextTotalItems ?? null,
673
+ contextNextOffset: receipt.contextNextOffset ?? null,
674
+ contextTruncated: receipt.contextTruncated,
675
+ retrievedAt: receipt.retrievedAt,
676
+ servedAt: receipt.servedAt,
677
+ cacheHit: receipt.cacheHit,
678
+ };
679
+ }
680
+ function agentRequest(input) {
681
+ const toolPolicy = input.purpose === "repair" ||
682
+ input.workPackage.stage === "analyze" ||
683
+ input.workPackage.stage === "synthesize" ||
684
+ input.workPackage.stage === "review" ||
685
+ (input.workPackage.stage === "discover" && input.brokerUrl === null)
686
+ ? "none"
687
+ : "workspace-read";
688
+ return {
689
+ route: input.route,
690
+ prompt: input.prompt,
691
+ outputSchema: schemaForStage(input.workPackage.stage, input.capsule.reviewPacketSha256, input.inputOnlyProvenance
692
+ ? { inputOnlyProvenanceIds: input.capsule.inputManifest.map((record) => record.id) }
693
+ : {}),
694
+ requestId: input.requestId,
695
+ purpose: input.purpose,
696
+ capsuleRoot: input.capsule.capsuleRoot,
697
+ projectRoot: input.capsule.projectRoot,
698
+ workspaceRoot: input.root,
699
+ timeoutSeconds: Math.min(remainingWallSeconds(input.project, input.config), input.maxWallSeconds ??
700
+ input.config.budget.packageMaxWallSeconds[input.workPackage.stage]),
701
+ maxTurns: input.purpose === "repair"
702
+ ? RESEARCH_REPAIR_MAX_TURNS
703
+ : toolPolicy === "none"
704
+ ? RESEARCH_STRUCTURED_OUTPUT_MAX_TURNS
705
+ : RESEARCH_BROKER_MAX_TURNS,
706
+ maxOutputTokens: input.maxOutputTokens,
707
+ maxCostUsd: input.maxCostUsd,
708
+ expectedRuntime: input.expectedRuntime,
709
+ toolPolicy,
710
+ environment: input.options.environment,
711
+ brokerUrl: input.brokerUrl,
712
+ };
713
+ }
714
+ function runtimeForRoute(attestation, route) {
715
+ if (!attestation)
716
+ return undefined;
717
+ const runtime = attestation.runtimes.find((candidate) => candidate.agent === route.agent && candidate.model === route.model);
718
+ if (!runtime) {
719
+ throw new CliError(`Doctor attestation does not contain the ${route.agent} route.`, {
720
+ code: "RESEARCH_DOCTOR_ATTESTATION_INVALID",
721
+ exitCode: 3,
722
+ });
723
+ }
724
+ return runtime;
725
+ }
726
+ async function materializeAndValidateStageOutput(root, project, capsuleProject, workPackage, raw, reviewPacketSha256) {
727
+ if (workPackage.stage === "close" || workPackage.expectedOutputs.length !== 1) {
728
+ throw new CliError("Agent package output declaration is unsupported.", {
729
+ code: "RESEARCH_PACKAGE_INVALID",
730
+ exitCode: 3,
731
+ });
732
+ }
733
+ const parsed = parseStructuredStageOutput(workPackage.stage, raw, reviewPacketSha256);
734
+ const destination = resolveContained(capsuleProject, workPackage.expectedOutputs[0]);
735
+ const fileContent = workPackage.stage === "discover"
736
+ ? `${JSON.stringify(normalizeEvidenceCoverage(project, parsed.value), null, 2)}\n`
737
+ : parsed.fileContent;
738
+ await writeTextAtomic(destination, fileContent);
739
+ await validateOutputShape(root, project, workPackage, destination, reviewPacketSha256);
244
740
  }
245
741
  async function validateAndImportOutputs(root, project, workPackage, capsuleProject, config, reviewPacketSha256) {
246
742
  const admitted = [];
247
743
  let totalBytes = 0;
248
744
  if (workPackage.expectedOutputs.length > config.budget.maxFilesPerPackage) {
249
- throw new Error("declared output count exceeds the package file budget");
745
+ throw deterministicError("Declared output count exceeds the package file budget.");
250
746
  }
251
747
  for (const logicalPath of workPackage.expectedOutputs) {
252
748
  const source = resolveContained(capsuleProject, logicalPath);
253
749
  const record = await fileRecord(source, logicalPath);
254
750
  totalBytes += record.bytes;
255
751
  if (totalBytes > config.budget.maxBytesPerPackage) {
256
- throw new Error("package outputs exceed the byte budget");
752
+ throw deterministicError("Package outputs exceed the byte budget.");
257
753
  }
258
- await validateOutputShape(root, project, logicalPath, source, reviewPacketSha256);
754
+ await validateOutputShape(root, project, workPackage, source, reviewPacketSha256);
259
755
  admitted.push({ logicalPath, content: await readFile(source, "utf8"), record });
260
756
  }
261
757
  for (const output of admitted) {
@@ -265,41 +761,27 @@ async function validateAndImportOutputs(root, project, workPackage, capsuleProje
265
761
  }
266
762
  return admitted.map((output) => output.record);
267
763
  }
268
- async function validateOutputShape(root, project, logicalPath, path, reviewPacketSha256) {
764
+ async function validateOutputShape(root, project, workPackage, path, reviewPacketSha256) {
269
765
  const content = await readFile(path, "utf8");
270
766
  if (!content.trim())
271
- throw new Error(`${logicalPath} is empty`);
272
- if (!logicalPath.endsWith(".json"))
767
+ throw deterministicError(`${workPackage.expectedOutputs[0]} is empty.`);
768
+ if (workPackage.stage === "synthesize")
273
769
  return;
274
- const value = JSON.parse(content);
275
- if (!isObject(value))
276
- throw new Error(`${logicalPath} must contain a JSON object`);
277
- if (logicalPath.endsWith("evidence.json")) {
278
- if (!Array.isArray(value.sources) ||
279
- value.sources.length === 0 ||
280
- !Array.isArray(value.limitations) ||
281
- value.limitations.some((item) => typeof item !== "string")) {
282
- throw new Error("evidence.json must contain sources and limitations arrays");
283
- }
770
+ const { value } = parseStructuredStageOutput(workPackage.stage, content, reviewPacketSha256);
771
+ if (workPackage.stage === "discover") {
284
772
  await validateEvidenceSources(root, project, value.sources);
285
773
  }
286
- if (logicalPath.endsWith("analysis.json")) {
287
- if (!Array.isArray(value.findings) || value.findings.length === 0) {
288
- throw new Error("analysis.json must contain a non-empty findings array");
289
- }
774
+ if (workPackage.stage === "analyze") {
290
775
  await validateFindings(path, value.findings);
291
776
  }
292
- if (logicalPath.endsWith("review.json")) {
293
- if ((value.decision !== "pass" && value.decision !== "revise") ||
294
- !Array.isArray(value.issues) ||
295
- typeof value.rationale !== "string" ||
296
- !value.rationale.trim() ||
297
- !reviewPacketSha256 ||
298
- value.packetSha256 !== reviewPacketSha256) {
299
- throw new Error("review.json must bind the review packet, decision, issues, and rationale");
777
+ if (workPackage.stage === "review") {
778
+ if (value.decision !== "pass") {
779
+ throw new CliError("Independent review requested revision.", {
780
+ code: "RESEARCH_REVIEW_REVISION_REQUIRED",
781
+ exitCode: 3,
782
+ details: { issues: value.issues },
783
+ });
300
784
  }
301
- if (value.decision !== "pass")
302
- throw new Error("independent review requested revision");
303
785
  }
304
786
  }
305
787
  async function validateEvidenceSources(root, project, sources) {
@@ -307,47 +789,65 @@ async function validateEvidenceSources(root, project, sources) {
307
789
  input.id,
308
790
  join("inputs", input.id, basename(input.path)).replaceAll("\\", "/"),
309
791
  ]));
310
- const brokerLocators = new Map();
311
- for (const event of await readJournal(workspacePaths(root).journal)) {
312
- if (event.scope !== project.id || event.type !== "capability.fetch.completed")
313
- continue;
314
- const attemptId = event.payload.attemptId;
315
- const locator = event.payload.path;
316
- if (typeof attemptId === "string" && typeof locator === "string") {
317
- brokerLocators.set(attemptId, locator);
318
- }
319
- }
792
+ const receipts = await loadProjectEvidenceReceipts(root, project.id);
793
+ const brokerLocators = new Map(receipts.map((receipt) => [receipt.attemptId, receipt.locator]));
320
794
  const sourceIds = new Set();
321
795
  for (const source of sources) {
322
- if (!isObject(source) ||
323
- typeof source.id !== "string" ||
324
- !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(source.id) ||
325
- sourceIds.has(source.id) ||
326
- typeof source.title !== "string" ||
327
- !source.title.trim() ||
328
- typeof source.locator !== "string" ||
329
- !source.locator.trim() ||
330
- typeof source.relevance !== "string" ||
331
- !source.relevance.trim() ||
332
- !isObject(source.provenance) ||
333
- (source.provenance.kind !== "input" && source.provenance.kind !== "broker") ||
334
- typeof source.provenance.id !== "string") {
335
- throw new Error("evidence.json contains an invalid or duplicate source");
796
+ if (!isObject(source) || !isObject(source.provenance)) {
797
+ throw new StructuredOutputError("discover output contains an invalid evidence source.");
798
+ }
799
+ const id = source.id;
800
+ if (typeof id !== "string" || sourceIds.has(id)) {
801
+ throw new StructuredOutputError("discover output contains a duplicate source ID.", {
802
+ validation: [`source ID must be unique: ${String(id)}`],
803
+ });
336
804
  }
337
805
  const expectedLocator = source.provenance.kind === "input"
338
- ? inputLocators.get(source.provenance.id)
339
- : brokerLocators.get(source.provenance.id);
806
+ ? inputLocators.get(String(source.provenance.id))
807
+ : source.provenance.kind === "broker"
808
+ ? brokerLocators.get(String(source.provenance.id))
809
+ : undefined;
340
810
  if (!expectedLocator || expectedLocator !== source.locator) {
341
- throw new Error(`evidence source ${source.id} has invalid provenance`);
811
+ throw new StructuredOutputError(`discover output contains invalid provenance for evidence source ${String(id)}.`, {
812
+ validation: [
813
+ "provenance.id must be an exact immutable input ID or broker receipt attemptId",
814
+ "locator must exactly match the locator bound to that provenance record",
815
+ ],
816
+ actual: {
817
+ kind: source.provenance.kind,
818
+ id: source.provenance.id,
819
+ locator: source.locator,
820
+ },
821
+ allowedInputs: [...inputLocators].map(([inputId, locator]) => ({
822
+ kind: "input",
823
+ id: inputId,
824
+ locator,
825
+ })),
826
+ allowedBrokerReceipts: [...brokerLocators].map(([attemptId, locator]) => ({
827
+ kind: "broker",
828
+ id: attemptId,
829
+ locator,
830
+ })),
831
+ });
832
+ }
833
+ if (typeof source.url === "string")
834
+ assertPublicEvidenceUrl(source.url, id);
835
+ if (typeof source.retrievedAt !== "string" ||
836
+ !Number.isFinite(Date.parse(source.retrievedAt))) {
837
+ throw new StructuredOutputError(`discover output contains an invalid retrieval date for evidence source ${String(id)}.`);
342
838
  }
343
- sourceIds.add(source.id);
839
+ if (source.publicationDate !== null &&
840
+ publicationDateInterval(source.publicationDate) === null) {
841
+ throw new StructuredOutputError(`discover output contains an invalid publication date for evidence source ${String(id)}.`);
842
+ }
843
+ sourceIds.add(id);
344
844
  }
345
845
  }
346
846
  async function validateFindings(path, findings) {
347
847
  const evidencePath = join(dirname(path), "evidence.json");
348
848
  const evidence = JSON.parse(await readFile(evidencePath, "utf8"));
349
849
  if (!isObject(evidence) || !Array.isArray(evidence.sources)) {
350
- throw new Error("analysis requires admitted evidence.json");
850
+ throw deterministicError("Analysis requires admitted evidence.json.");
351
851
  }
352
852
  const sourceIds = new Set(evidence.sources
353
853
  .filter((source) => isObject(source))
@@ -357,21 +857,165 @@ async function validateFindings(path, findings) {
357
857
  for (const finding of findings) {
358
858
  if (!isObject(finding) ||
359
859
  typeof finding.id !== "string" ||
360
- !finding.id.trim() ||
361
860
  findingIds.has(finding.id) ||
362
- typeof finding.statement !== "string" ||
363
- !finding.statement.trim() ||
364
861
  !Array.isArray(finding.evidence) ||
365
- finding.evidence.length === 0 ||
366
- finding.evidence.some((id) => typeof id !== "string" || !sourceIds.has(id)) ||
367
- typeof finding.uncertainty !== "string" ||
368
- !finding.uncertainty.trim()) {
369
- throw new Error("analysis.json contains an invalid or untraceable finding");
862
+ finding.evidence.some((id) => typeof id !== "string" || !sourceIds.has(id))) {
863
+ throw new StructuredOutputError("analyze output contains an invalid or untraceable finding.", {
864
+ validation: [
865
+ "finding IDs must be unique and evidence IDs must reference admitted sources",
866
+ ],
867
+ admittedEvidenceIds: [...sourceIds].sort(),
868
+ });
370
869
  }
371
870
  findingIds.add(finding.id);
372
871
  }
373
872
  }
873
+ function normalizeEvidenceCoverage(project, value) {
874
+ const inputIds = new Set(project.inputs.map((input) => input.id));
875
+ const sources = (value.sources ?? []).map((source) => {
876
+ const provenance = isObject(source.provenance) ? source.provenance : {};
877
+ return provenance.kind === "input" && inputIds.has(String(provenance.id))
878
+ ? { ...source, fullTextAvailable: true }
879
+ : source;
880
+ });
881
+ const declared = isObject(value.coverage) ? value.coverage : {};
882
+ const computed = computeEvidenceCoverage(project, sources, declared);
883
+ const declaredGaps = Array.isArray(declared.gaps)
884
+ ? declared.gaps.filter((gap) => typeof gap === "string")
885
+ : [];
886
+ return {
887
+ ...value,
888
+ sources,
889
+ coverage: {
890
+ dimensions: computed.dimensions,
891
+ sourceTypes: computed.sourceTypes,
892
+ fullTextSources: computed.fullTextSources,
893
+ datedSources: computed.datedSources,
894
+ publicationDateRange: computed.publicationDateRange,
895
+ decision: computed.decision,
896
+ gaps: [...new Set([...declaredGaps, ...computed.mechanicalGaps])],
897
+ },
898
+ };
899
+ }
900
+ function computeEvidenceCoverage(project, sources, declared) {
901
+ const gaps = [];
902
+ if (sources.length < project.evidenceRequirements.minSources) {
903
+ gaps.push(`requires ${project.evidenceRequirements.minSources} source(s), found ${sources.length}`);
904
+ }
905
+ const fullTextSources = sources.filter((source) => source.fullTextAvailable === true).length;
906
+ if (fullTextSources < project.evidenceRequirements.minFullTextSources) {
907
+ gaps.push(`requires ${project.evidenceRequirements.minFullTextSources} full-text source(s), found ${fullTextSources}`);
908
+ }
909
+ const publicationIntervals = sources.flatMap((source) => {
910
+ const interval = publicationDateInterval(source.publicationDate);
911
+ return interval ? [interval] : [];
912
+ });
913
+ const requiredFrom = project.evidenceRequirements.publicationDateFrom;
914
+ const requiredTo = project.evidenceRequirements.publicationDateTo;
915
+ const inRangeDatedSources = publicationIntervals.filter((interval) => (requiredFrom === null || interval.latest >= requiredFrom) &&
916
+ (requiredTo === null || interval.earliest <= requiredTo)).length;
917
+ if (inRangeDatedSources < project.evidenceRequirements.minDatedSources) {
918
+ gaps.push(`requires ${project.evidenceRequirements.minDatedSources} dated source(s) within the publication boundary, found ${inRangeDatedSources}`);
919
+ }
920
+ const publicationDateRange = {
921
+ earliest: publicationIntervals.length
922
+ ? publicationIntervals.map((interval) => interval.earliest).sort()[0]
923
+ : null,
924
+ latest: publicationIntervals.length
925
+ ? publicationIntervals
926
+ .map((interval) => interval.latest)
927
+ .sort()
928
+ .at(-1)
929
+ : null,
930
+ };
931
+ const sourceTypes = [...new Set(sources.map((source) => String(source.sourceType)))].sort();
932
+ for (const sourceType of project.evidenceRequirements.sourceTypes) {
933
+ if (!sourceTypes.includes(sourceType))
934
+ gaps.push(`missing required source type: ${sourceType}`);
935
+ }
936
+ const declaredDimensions = Array.isArray(declared.dimensions)
937
+ ? declared.dimensions.filter(isObject)
938
+ : [];
939
+ const dimensions = project.evidenceRequirements.dimensions.map((dimension) => {
940
+ const sourceIds = sources
941
+ .filter((source) => Array.isArray(source.coverageDimensions) && source.coverageDimensions.includes(dimension))
942
+ .map((source) => String(source.id))
943
+ .sort();
944
+ const entry = declaredDimensions.find((item) => item.id === dimension);
945
+ const declaredStatus = entry?.status;
946
+ const status = sourceIds.length
947
+ ? declaredStatus === "covered"
948
+ ? "covered"
949
+ : "partial"
950
+ : "missing";
951
+ if (!sourceIds.length)
952
+ gaps.push(`missing evidence dimension: ${dimension}`);
953
+ return { id: dimension, status, sourceIds };
954
+ });
955
+ return {
956
+ dimensions,
957
+ sourceTypes,
958
+ fullTextSources,
959
+ datedSources: publicationIntervals.length,
960
+ publicationDateRange,
961
+ decision: gaps.length ? "insufficient" : "pass",
962
+ mechanicalGaps: gaps,
963
+ };
964
+ }
965
+ async function assertEvidenceCoverage(root, project) {
966
+ const path = resolveContained(projectRoot(root, project.id), "outputs/evidence.json");
967
+ const value = JSON.parse(await readFile(path, "utf8"));
968
+ const sources = value.sources;
969
+ const declared = value.coverage;
970
+ const computed = computeEvidenceCoverage(project, sources, declared);
971
+ const gaps = [...computed.mechanicalGaps];
972
+ if (canonicalJson(declared.dimensions) !== canonicalJson(computed.dimensions) ||
973
+ canonicalJson(declared.sourceTypes) !== canonicalJson(computed.sourceTypes) ||
974
+ declared.fullTextSources !== computed.fullTextSources ||
975
+ declared.datedSources !== computed.datedSources ||
976
+ canonicalJson(declared.publicationDateRange) !== canonicalJson(computed.publicationDateRange)) {
977
+ gaps.push("coverage summary does not match admitted sources");
978
+ }
979
+ if (declared.decision !== computed.decision) {
980
+ gaps.push(`coverage decision must be ${computed.decision}`);
981
+ }
982
+ if (gaps.length) {
983
+ throw new CliError("Evidence coverage is insufficient; downstream packages were not started.", {
984
+ code: "RESEARCH_EVIDENCE_INSUFFICIENT",
985
+ exitCode: 3,
986
+ details: { gaps },
987
+ });
988
+ }
989
+ }
990
+ function publicationDateInterval(value) {
991
+ if (typeof value !== "string")
992
+ return null;
993
+ const match = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/.exec(value);
994
+ if (!match)
995
+ return null;
996
+ const year = Number(match[1]);
997
+ const month = match[2] ? Number(match[2]) : null;
998
+ const day = match[3] ? Number(match[3]) : null;
999
+ if (year < 1 || year > 9999 || (month !== null && (month < 1 || month > 12)))
1000
+ return null;
1001
+ if (day !== null) {
1002
+ const exact = `${match[1]}-${match[2]}-${match[3]}`;
1003
+ const timestamp = Date.parse(`${exact}T00:00:00.000Z`);
1004
+ if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString().slice(0, 10) !== exact) {
1005
+ return null;
1006
+ }
1007
+ return { earliest: exact, latest: exact };
1008
+ }
1009
+ if (month !== null) {
1010
+ const monthText = String(month).padStart(2, "0");
1011
+ const earliest = `${match[1]}-${monthText}-01`;
1012
+ const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
1013
+ return { earliest, latest: `${match[1]}-${monthText}-${String(lastDay).padStart(2, "0")}` };
1014
+ }
1015
+ return { earliest: `${match[1]}-01-01`, latest: `${match[1]}-12-31` };
1016
+ }
374
1017
  async function closeProjectMechanically(root, project, workPackage) {
1018
+ await assertEvidenceCoverage(root, project);
375
1019
  const required = [
376
1020
  "outputs/evidence.json",
377
1021
  "outputs/analysis.json",
@@ -381,8 +1025,14 @@ async function closeProjectMechanically(root, project, workPackage) {
381
1025
  const artifacts = await outputRecords(root, project, required);
382
1026
  const review = JSON.parse(await readFile(resolveContained(projectRoot(root, project.id), "outputs/review.json"), "utf8"));
383
1027
  if (!isObject(review) || review.decision !== "pass") {
384
- throw new Error("project cannot close without a passing independent review");
1028
+ throw deterministicError("Project cannot close without a passing independent review.");
1029
+ }
1030
+ await verifyProjectInputBindings(project);
1031
+ if (typeof review.packetSha256 !== "string" || !/^[0-9a-f]{64}$/.test(review.packetSha256)) {
1032
+ throw deterministicError("Project review does not bind a valid review packet hash.");
385
1033
  }
1034
+ const reviewPacket = await loadVerifiedReviewPacket(root, project.id, review.packetSha256);
1035
+ const evidenceReceipts = await loadProjectEvidenceReceipts(root, project.id);
386
1036
  const journal = await verifyJournal(workspacePaths(root).journal);
387
1037
  const closure = {
388
1038
  schemaVersion: 1,
@@ -390,40 +1040,503 @@ async function closeProjectMechanically(root, project, workPackage) {
390
1040
  status: "complete",
391
1041
  closedAt: new Date().toISOString(),
392
1042
  questionSha256: sha256Text(project.question),
1043
+ evidenceRequirements: project.evidenceRequirements,
1044
+ inputs: project.inputs.map((input) => ({
1045
+ id: input.id,
1046
+ role: input.role,
1047
+ sha256: input.sha256,
1048
+ bytes: input.bytes,
1049
+ contextSha256: input.contextSha256,
1050
+ contextBytes: input.contextBytes,
1051
+ contextRanges: input.contextRanges ?? null,
1052
+ })),
1053
+ reviewPacket: { ...reviewPacket, packetSha256: review.packetSha256 },
1054
+ evidenceObjects: evidenceReceipts.map((receipt) => ({
1055
+ attemptId: receipt.attemptId,
1056
+ locator: receipt.locator,
1057
+ sha256: receipt.sha256,
1058
+ bytes: receipt.bytes,
1059
+ })),
393
1060
  artifacts,
394
1061
  journalHead: journal.head,
395
1062
  };
396
1063
  const closurePath = resolveContained(projectRoot(root, project.id), workPackage.expectedOutputs[0]);
397
1064
  await writeJsonAtomic(closurePath, closure);
398
- return { exitCode: 0, stdout: "", stderr: "", tokens: 0, costUsd: 0, wallSeconds: 0 };
1065
+ return zeroExecutionResult();
1066
+ }
1067
+ async function verifyProjectInputBindings(project) {
1068
+ for (const input of project.inputs) {
1069
+ const info = await lstat(input.path).catch(() => undefined);
1070
+ if (!info?.isFile() ||
1071
+ info.isSymbolicLink() ||
1072
+ info.size !== input.bytes ||
1073
+ (await sha256File(input.path)) !== input.sha256) {
1074
+ throw new CliError(`Registered input failed closure verification: ${input.id}.`, {
1075
+ code: "RESEARCH_INPUT_DRIFT",
1076
+ exitCode: 3,
1077
+ });
1078
+ }
1079
+ if (!input.contextSha256 || input.contextBytes === undefined)
1080
+ continue;
1081
+ let contextValid = false;
1082
+ if (input.contextRanges?.length) {
1083
+ const context = await renderInputLineContext(input.path, input.contextRanges);
1084
+ contextValid =
1085
+ Buffer.byteLength(context, "utf8") === input.contextBytes &&
1086
+ sha256Text(context) === input.contextSha256;
1087
+ }
1088
+ else if (input.contextPath) {
1089
+ const contextInfo = await lstat(input.contextPath).catch(() => undefined);
1090
+ contextValid = Boolean(contextInfo?.isFile() &&
1091
+ !contextInfo.isSymbolicLink() &&
1092
+ contextInfo.size === input.contextBytes &&
1093
+ (await sha256File(input.contextPath)) === input.contextSha256);
1094
+ }
1095
+ if (!contextValid) {
1096
+ throw new CliError(`Registered input context failed closure verification: ${input.id}.`, {
1097
+ code: "RESEARCH_INPUT_DRIFT",
1098
+ exitCode: 3,
1099
+ });
1100
+ }
1101
+ }
399
1102
  }
400
1103
  async function outputRecords(root, project, logicalPaths) {
401
1104
  return Promise.all(logicalPaths.map((logicalPath) => fileRecord(resolveContained(projectRoot(root, project.id), logicalPath), logicalPath)));
402
1105
  }
403
- function packagePrompt(project, workPackage, inputs, stagedSkills, reviewPacketSha256) {
1106
+ async function stageContextForPackage(capsuleProject, workPackage, config) {
1107
+ const logicalPaths = workPackage.stage === "analyze"
1108
+ ? ["outputs/evidence.json"]
1109
+ : workPackage.stage === "synthesize"
1110
+ ? ["outputs/evidence.json", "outputs/analysis.json"]
1111
+ : workPackage.stage === "review"
1112
+ ? [
1113
+ "inputs/review-packet.json",
1114
+ "inputs/review-evidence-context.txt",
1115
+ "outputs/evidence.json",
1116
+ "outputs/analysis.json",
1117
+ "outputs/report.md",
1118
+ ]
1119
+ : [];
1120
+ const sections = [];
1121
+ for (const logicalPath of logicalPaths) {
1122
+ const content = await readFile(resolveContained(capsuleProject, logicalPath), "utf8");
1123
+ sections.push(`### ${logicalPath}\n${content.trimEnd()}`);
1124
+ }
1125
+ const bundled = sections.join("\n\n");
1126
+ const estimatedTokens = Math.ceil(Buffer.byteLength(bundled, "utf8") / 4);
1127
+ if (estimatedTokens > config.budget.maxInputContextTokens) {
1128
+ throw new CliError(`Admitted stage context exceeds the configured input context limit for ${workPackage.id}.`, {
1129
+ code: "RESEARCH_INPUT_CONTEXT_BUDGET_EXCEEDED",
1130
+ exitCode: 3,
1131
+ details: {
1132
+ packageId: workPackage.id,
1133
+ estimatedTokens,
1134
+ maxInputContextTokens: config.budget.maxInputContextTokens,
1135
+ },
1136
+ });
1137
+ }
1138
+ return bundled;
1139
+ }
1140
+ function packagePrompt(project, workPackage, inputs, stagedSkills, reviewPacketSha256, contextBundle, contextBundleContent, stageContextContent) {
404
1141
  const stageInstructions = {
405
- discover: 'Create outputs/evidence.json with non-empty sources and a limitations array. Every source needs id, title, locator, relevance, and provenance {kind: "input"|"broker", id}. Input provenance IDs and locators must match the declared input manifest. Broker provenance must match a receipt returned by fetch_candidate_source. Do not invent unavailable evidence.',
406
- analyze: "Read outputs/evidence.json and create outputs/analysis.json with non-empty findings. Each finding needs id, statement, evidence source IDs, and uncertainty.",
407
- synthesize: "Read the admitted evidence and findings, then create outputs/report.md. Separate supported conclusions, uncertainty, limitations, and next actions.",
408
- review: `Independently inspect inputs/review-packet.json and all packet artifacts. Create outputs/review.json with packetSha256 ${reviewPacketSha256 ?? "unavailable"}, decision "pass" or "revise", an issues array, and a non-empty rationale. Use "pass" only when every material claim is traceable to admitted evidence.`,
1142
+ discover: "Return the evidence object defined by the supplied JSON Schema. Cite only declared inputs or broker receipts. Each source.id is your concise evidence label; it must not be reused as provenance.id. For declared inputs, provenance must use the exact id and path shown in the declared input manifest, with locator=path. Every declared input binds an exact registered full source, so fullTextAvailable=true even when fullTextStaged=false; that flag means the producer receives only its bounded context while independent review binds both the verified full-file hash and the exact bounded review view. For broker evidence, provenance.id must be the exact receipt attemptId and locator must be the receipt locator (not contextLocator); use contextLocator only to inspect the bounded view. Include source type, retrieval metadata, an excerpt or JSON Pointer when available, quality, applicability, coverage dimensions, limitations, and an honest coverage assessment. A partial dimension is usable but incomplete; missing means no admitted source covers it. coverage.gaps records qualitative limitations and does not alone force an insufficient decision. The CLI mechanically derives local-input full-text availability, sourceTypes, counts, date range, sourceIds, and the pass/insufficient decision from admitted sources and declared minimums. Never place credentials or sensitive URL parameters in any field.",
1143
+ analyze: "Use only the complete embedded admitted evidence below and return the schema-defined analysis object. Every finding must cite admitted evidence source IDs and state uncertainty and applicability.",
1144
+ synthesize: "Use only the complete embedded admitted evidence and findings below. Return the schema-defined object whose reportMarkdown separates supported conclusions, uncertainty, limitations, and next actions.",
1145
+ review: `Independently inspect the complete embedded review packet, artifacts, and exact bounded evidence views. The CLI has already verified every bound full evidence object's size and SHA-256 and persistently stored the review packet; do not claim to have read beyond the embedded views. Return the schema-defined review bound to packetSha256 ${reviewPacketSha256 ?? "unavailable"}. Use pass only when every material claim is traceable within the admitted evidence and clearly scoped to its limitations.`,
409
1146
  close: "No agent action is allowed for mechanical closure.",
410
1147
  };
411
- return [
1148
+ const prompt = [
412
1149
  "Operate only inside this isolated research capsule.",
413
1150
  `Project: ${project.id}`,
414
1151
  `Question: ${project.question}`,
415
1152
  `Stage: ${workPackage.stage}`,
1153
+ `Evidence requirements: ${JSON.stringify(project.evidenceRequirements)}`,
416
1154
  `Declared inputs: ${JSON.stringify(inputs)}`,
1155
+ `Bounded input context bundle: ${JSON.stringify(contextBundle)}`,
417
1156
  `Staged capability directories: ${JSON.stringify(stagedSkills.map((path) => `skills/${basename(path)}`))}`,
1157
+ workPackage.stage === "discover"
1158
+ ? "Keep broker inspection within the package budget and use bounded views."
1159
+ : "Use only the complete embedded stage context; no tools or additional source reads are allowed.",
418
1160
  stageInstructions[workPackage.stage],
419
- `Write only the declared stage outputs: ${workPackage.expectedOutputs.join(", ")}.`,
420
- "Do not edit project.json, the input manifest, prior outputs, or staged capability files.",
1161
+ "Do not write stage output files directly. Your final response must be only the JSON object required by the supplied output schema; the CLI will validate and atomically materialize it.",
1162
+ "Do not edit project.json, input manifests, prior outputs, evidence objects, or staged capability files.",
1163
+ ];
1164
+ if (workPackage.stage === "discover") {
1165
+ prompt.push(`Exact local-input provenance mappings: ${JSON.stringify(inputs.map((input) => ({ kind: "input", id: input.id, locator: input.path })))}`, "The complete authorized local-input context is embedded below. Use it directly and do not re-read individual local input files. Full evidence files are intentionally withheld from producer packages when fullTextStaged=false.", contextBundleContent);
1166
+ }
1167
+ if (stageContextContent) {
1168
+ prompt.push("The complete admitted stage context is embedded below. Use it directly and do not re-read output files.", stageContextContent);
1169
+ }
1170
+ return prompt.join("\n\n");
1171
+ }
1172
+ function repairPrompt(workPackage, raw, error) {
1173
+ return [
1174
+ "This is an isolated, low-cost formatting repair. Do not perform research, fetch sources, or add facts.",
1175
+ `Stage: ${workPackage.stage}`,
1176
+ `Validation failure: ${sanitizeResearchText(error.message)}`,
1177
+ `Validation detail: ${JSON.stringify(sanitizeResearchRecord(isObject(error.details) ? error.details : {}))}`,
1178
+ "Return only a corrected JSON object satisfying the supplied schema while preserving the source content below.",
1179
+ `Invalid output:\n${bounded(sanitizeResearchText(raw), 32_000)}`,
421
1180
  ].join("\n\n");
422
1181
  }
423
- async function dryRunResult(root) {
424
- const projects = await listProjects(root);
1182
+ function assertPreCallTokenReservation(project, workPackage, config, request, alreadyUsedTokens, reserveRepair) {
1183
+ const schemaBytes = Buffer.byteLength(JSON.stringify(request.outputSchema), "utf8");
1184
+ const promptBytes = Buffer.byteLength(request.prompt, "utf8");
1185
+ const protocolOverhead = RESEARCH_AGENT_PROTOCOL_OVERHEAD_TOKENS[request.route.agent];
1186
+ const callInputTokensPerTurn = protocolOverhead + Math.ceil((schemaBytes + promptBytes) / RESEARCH_ESTIMATED_BYTES_PER_TOKEN);
1187
+ const callInputTokens = callInputTokensPerTurn * request.maxTurns;
1188
+ const repairTokens = reserveRepair
1189
+ ? (protocolOverhead +
1190
+ Math.ceil((schemaBytes + RESEARCH_MAX_REPAIR_SOURCE_BYTES + 2_048) /
1191
+ RESEARCH_ESTIMATED_BYTES_PER_TOKEN)) *
1192
+ RESEARCH_REPAIR_MAX_TURNS +
1193
+ config.budget.maxRepairTokens
1194
+ : 0;
1195
+ const reservation = {
1196
+ alreadyUsedTokens,
1197
+ maxTurns: request.maxTurns,
1198
+ estimatedCallInputTokensPerTurn: callInputTokensPerTurn,
1199
+ estimatedCallInputTokens: callInputTokens,
1200
+ outputTokens: request.maxOutputTokens,
1201
+ potentialRepairTokens: repairTokens,
1202
+ totalTokens: alreadyUsedTokens + callInputTokens + request.maxOutputTokens + repairTokens,
1203
+ };
1204
+ const packageMaxTokens = config.budget.packageMaxTokens[workPackage.stage];
1205
+ const projectRemainingTokens = Math.max(0, config.budget.maxTokens - project.usage.tokens);
1206
+ if (reservation.totalTokens > packageMaxTokens ||
1207
+ reservation.totalTokens > projectRemainingTokens) {
1208
+ throw new CliError(`Pre-call input/output reservation does not fit package ${workPackage.id}.`, {
1209
+ code: "RESEARCH_BUDGET_RESERVATION_FAILED",
1210
+ exitCode: 3,
1211
+ details: {
1212
+ packageId: workPackage.id,
1213
+ packageMaxTokens,
1214
+ projectRemainingTokens,
1215
+ reservation,
1216
+ },
1217
+ });
1218
+ }
1219
+ }
1220
+ function reservePackageBudget(project, workPackage, config) {
1221
+ if (workPackage.stage === "close")
1222
+ return { tokens: 0, costUsd: 0 };
1223
+ const route = workPackage.executor === "reviewer" ? config.reviewer : config.producer;
1224
+ const tokens = config.budget.packageMaxTokens[workPackage.stage];
1225
+ const costUsd = roundMoney(reservedAgentPackageCost(route, tokens, config));
1226
+ const wallSeconds = config.budget.packageMaxWallSeconds[workPackage.stage];
1227
+ const remaining = remainingBudget(project, config);
1228
+ if (remaining.tokens < tokens ||
1229
+ remaining.costUsd < costUsd ||
1230
+ remaining.wallSeconds < wallSeconds) {
1231
+ throw new CliError(`Remaining budget cannot reserve package ${workPackage.id} for project ${project.id}.`, {
1232
+ code: "RESEARCH_BUDGET_RESERVATION_FAILED",
1233
+ exitCode: 3,
1234
+ details: {
1235
+ remaining,
1236
+ reservation: { tokens, costUsd, wallSeconds },
1237
+ packageId: workPackage.id,
1238
+ },
1239
+ });
1240
+ }
1241
+ return { tokens, costUsd };
1242
+ }
1243
+ function assertActualPackageBudget(project, workPackage, config, result, maxOutputTokens) {
1244
+ if (workPackage.stage !== "close" &&
1245
+ result.tokens > config.budget.packageMaxTokens[workPackage.stage]) {
1246
+ throw new CliError(`Executor exceeded the package token limit for ${workPackage.id}.`, {
1247
+ code: "RESEARCH_PACKAGE_BUDGET_EXCEEDED",
1248
+ exitCode: 3,
1249
+ details: {
1250
+ projectId: project.id,
1251
+ packageId: workPackage.id,
1252
+ actualTokens: result.tokens,
1253
+ maxTokens: config.budget.packageMaxTokens[workPackage.stage],
1254
+ },
1255
+ });
1256
+ }
1257
+ if (result.outputTokens > maxOutputTokens) {
1258
+ throw new CliError(`Executor exceeded the output token limit for ${workPackage.id}.`, {
1259
+ code: "RESEARCH_PACKAGE_OUTPUT_BUDGET_EXCEEDED",
1260
+ exitCode: 3,
1261
+ details: {
1262
+ projectId: project.id,
1263
+ packageId: workPackage.id,
1264
+ actualOutputTokens: result.outputTokens,
1265
+ maxOutputTokens,
1266
+ },
1267
+ });
1268
+ }
1269
+ if (workPackage.stage !== "close" &&
1270
+ result.wallSeconds > config.budget.packageMaxWallSeconds[workPackage.stage]) {
1271
+ throw new CliError(`Executor exceeded the package wall-time limit for ${workPackage.id}.`, {
1272
+ code: "RESEARCH_PACKAGE_WALL_BUDGET_EXCEEDED",
1273
+ exitCode: 3,
1274
+ details: {
1275
+ projectId: project.id,
1276
+ packageId: workPackage.id,
1277
+ actualWallSeconds: result.wallSeconds,
1278
+ maxWallSeconds: config.budget.packageMaxWallSeconds[workPackage.stage],
1279
+ },
1280
+ });
1281
+ }
1282
+ }
1283
+ function availableRepairTokens(project, workPackage, config, primary) {
1284
+ if (workPackage.stage === "close")
1285
+ return 0;
1286
+ if (primary.wallSeconds >= config.budget.packageMaxWallSeconds[workPackage.stage]) {
1287
+ return 0;
1288
+ }
1289
+ return Math.max(0, Math.min(config.budget.maxRepairTokens, config.budget.packageMaxTokens[workPackage.stage] - primary.tokens, config.budget.maxTokens - project.usage.tokens - primary.tokens));
1290
+ }
1291
+ function assertProjectedBudget(project, config, result) {
1292
+ const projected = {
1293
+ tokens: project.usage.tokens + result.tokens,
1294
+ costUsd: project.usage.costUsd + result.costUsd,
1295
+ wallSeconds: project.usage.wallSeconds + result.wallSeconds,
1296
+ };
1297
+ if (projected.tokens > config.budget.maxTokens ||
1298
+ projected.costUsd > config.budget.maxCostUsd ||
1299
+ projected.wallSeconds > config.budget.maxWallSeconds) {
1300
+ throw new CliError(`Research execution exceeded a hard budget for project ${project.id}.`, {
1301
+ code: "RESEARCH_BUDGET_EXHAUSTED",
1302
+ exitCode: 3,
1303
+ details: { projected, budget: config.budget },
1304
+ });
1305
+ }
1306
+ }
1307
+ function remainingBudget(project, config) {
1308
+ return {
1309
+ tokens: Math.max(0, config.budget.maxTokens - project.usage.tokens),
1310
+ costUsd: Math.max(0, roundMoney(config.budget.maxCostUsd - project.usage.costUsd)),
1311
+ wallSeconds: Math.max(0, config.budget.maxWallSeconds - project.usage.wallSeconds),
1312
+ };
1313
+ }
1314
+ function remainingWallSeconds(project, config) {
1315
+ return Math.max(1, Math.floor(config.budget.maxWallSeconds - project.usage.wallSeconds));
1316
+ }
1317
+ function assertExecutionConfiguration(config) {
1318
+ if (config.producer.agent === config.reviewer.agent) {
1319
+ throw new CliError("Research producer and reviewer must use different agent families.", {
1320
+ code: "RESEARCH_REVIEW_ROUTE_INVALID",
1321
+ exitCode: 3,
1322
+ });
1323
+ }
1324
+ if (config.mode === "production-research" && (!config.producer.model || !config.reviewer.model)) {
1325
+ throw new CliError("Production research requires explicit producer and reviewer models.", {
1326
+ code: "RESEARCH_MODEL_REQUIRED",
1327
+ exitCode: 3,
1328
+ });
1329
+ }
1330
+ if (config.mode === "production-research" &&
1331
+ (!config.producer.pricing || !config.reviewer.pricing)) {
1332
+ throw new CliError("Production research requires explicit producer and reviewer pricing.", {
1333
+ code: "RESEARCH_PRICING_REQUIRED",
1334
+ exitCode: 3,
1335
+ });
1336
+ }
1337
+ }
1338
+ function assertExecutorSucceeded(result) {
1339
+ if (result.exitCode === 0)
1340
+ return;
1341
+ const diagnostic = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n");
1342
+ throw new CliError(`Executor exited ${result.exitCode}: ${bounded(diagnostic || "no diagnostic output", 1000)}`, {
1343
+ code: "RESEARCH_EXECUTOR_FAILED",
1344
+ exitCode: 3,
1345
+ details: { exitCode: result.exitCode },
1346
+ });
1347
+ }
1348
+ function sanitizedFailureDetails(error, secrets) {
1349
+ if (!(error instanceof CliError) || !isObject(error.details))
1350
+ return null;
1351
+ const sanitized = sanitizeResearchRecord(error.details, secrets);
1352
+ const encoded = JSON.stringify(sanitized);
1353
+ if (encoded.length <= 16_000)
1354
+ return sanitized;
1355
+ return {
1356
+ truncated: true,
1357
+ sha256: sha256Text(encoded),
1358
+ preview: bounded(encoded, 12_000),
1359
+ };
1360
+ }
1361
+ function classifyFailure(error) {
1362
+ if (error instanceof StructuredOutputError) {
1363
+ return { kind: "structured-output", retryable: false, retryAfterSeconds: null };
1364
+ }
1365
+ if (error instanceof CliError) {
1366
+ if (error.code.includes("BUDGET")) {
1367
+ return { kind: "budget", retryable: false, retryAfterSeconds: null };
1368
+ }
1369
+ if (error.code.includes("CONFIG") ||
1370
+ error.code.includes("INVALID") ||
1371
+ error.code.includes("DRIFT") ||
1372
+ error.code.includes("UNAVAILABLE") ||
1373
+ error.code === "RESEARCH_EVIDENCE_INSUFFICIENT" ||
1374
+ error.code === "RESEARCH_REVIEW_REVISION_REQUIRED") {
1375
+ return { kind: "configuration", retryable: false, retryAfterSeconds: null };
1376
+ }
1377
+ if (error.code === "RESEARCH_BROKER_HTTP_ERROR" && isObject(error.details)) {
1378
+ const status = error.details.status;
1379
+ const retryAfter = numericOrNull(error.details.retryAfterSeconds);
1380
+ if (status === 429) {
1381
+ return { kind: "rate-limit", retryable: true, retryAfterSeconds: retryAfter };
1382
+ }
1383
+ if (typeof status === "number" && status >= 500) {
1384
+ return { kind: "server", retryable: true, retryAfterSeconds: null };
1385
+ }
1386
+ return { kind: "deterministic", retryable: false, retryAfterSeconds: null };
1387
+ }
1388
+ }
1389
+ const message = error instanceof Error ? error.message : String(error);
1390
+ if (/error_max_budget|budget_exhausted|reached maximum budget|max(?:imum)? budget usd|error_max_turns|reached maximum (?:number of )?turns|max_turns/i.test(message)) {
1391
+ return { kind: "budget", retryable: false, retryAfterSeconds: null };
1392
+ }
1393
+ if (/\b(401|403|authentication|unauthorized|forbidden|login)\b/i.test(message)) {
1394
+ return { kind: "authentication", retryable: false, retryAfterSeconds: null };
1395
+ }
1396
+ if (/\b429\b|rate.?limit/i.test(message)) {
1397
+ const retryAfter = /retry-after(?:seconds)?["':=\s]+(\d+)/i.exec(message)?.[1];
1398
+ return {
1399
+ kind: "rate-limit",
1400
+ retryable: true,
1401
+ retryAfterSeconds: retryAfter ? Number(retryAfter) : 60,
1402
+ };
1403
+ }
1404
+ if (/\b5\d\d\b|server error|service unavailable/i.test(message)) {
1405
+ return { kind: "server", retryable: true, retryAfterSeconds: null };
1406
+ }
1407
+ if (/timeout|timed out|ECONNRESET|ECONNREFUSED|EAI_AGAIN|temporary failure/i.test(message)) {
1408
+ return { kind: "transient", retryable: true, retryAfterSeconds: null };
1409
+ }
1410
+ return { kind: "deterministic", retryable: false, retryAfterSeconds: null };
1411
+ }
1412
+ function retryNotBefore(retryAfterSeconds) {
1413
+ if (retryAfterSeconds === null)
1414
+ return null;
1415
+ return new Date(Date.now() + Math.max(1, retryAfterSeconds) * 1000).toISOString();
1416
+ }
1417
+ function combineExecutionResults(primary, repair) {
1418
+ return {
1419
+ exitCode: repair.exitCode,
1420
+ stdout: `${primary.stdout}\n${repair.stdout}`,
1421
+ stderr: `${primary.stderr}\n${repair.stderr}`.trim(),
1422
+ tokens: primary.tokens + repair.tokens,
1423
+ inputTokens: primary.inputTokens + repair.inputTokens,
1424
+ cachedInputTokens: primary.cachedInputTokens + repair.cachedInputTokens,
1425
+ outputTokens: primary.outputTokens + repair.outputTokens,
1426
+ costUsd: roundMoney(primary.costUsd + repair.costUsd),
1427
+ wallSeconds: primary.wallSeconds + repair.wallSeconds,
1428
+ model: repair.model ?? primary.model,
1429
+ runtime: repair.runtime ?? primary.runtime,
1430
+ telemetry: mergeTelemetry(primary.telemetry, repair.telemetry),
1431
+ };
1432
+ }
1433
+ function mergeTelemetry(primary, repair) {
1434
+ if (!primary)
1435
+ return repair;
1436
+ if (!repair)
1437
+ return primary;
1438
+ return {
1439
+ eventCounts: mergeCounts(primary.eventCounts, repair.eventCounts),
1440
+ itemCounts: mergeCounts(primary.itemCounts, repair.itemCounts),
1441
+ toolCalls: primary.toolCalls + repair.toolCalls,
1442
+ providerTurns: primary.providerTurns === null && repair.providerTurns === null
1443
+ ? null
1444
+ : (primary.providerTurns ?? 0) + (repair.providerTurns ?? 0),
1445
+ reasoningOutputTokens: primary.reasoningOutputTokens + repair.reasoningOutputTokens,
1446
+ providerErrors: [...new Set([...primary.providerErrors, ...repair.providerErrors])].slice(0, 10),
1447
+ };
1448
+ }
1449
+ function mergeCounts(left, right) {
1450
+ const result = { ...left };
1451
+ for (const [key, value] of Object.entries(right))
1452
+ result[key] = (result[key] ?? 0) + value;
1453
+ return result;
1454
+ }
1455
+ function applyUsage(project, result) {
1456
+ project.usage.tokens += result.tokens;
1457
+ project.usage.inputTokens += result.inputTokens;
1458
+ project.usage.cachedInputTokens += result.cachedInputTokens;
1459
+ project.usage.outputTokens += result.outputTokens;
1460
+ project.usage.costUsd = roundMoney(project.usage.costUsd + result.costUsd);
1461
+ project.usage.wallSeconds += result.wallSeconds;
1462
+ }
1463
+ function usageSlice(result) {
1464
+ return {
1465
+ tokens: result.tokens,
1466
+ inputTokens: result.inputTokens,
1467
+ cachedInputTokens: result.cachedInputTokens,
1468
+ outputTokens: result.outputTokens,
1469
+ costUsd: result.costUsd,
1470
+ wallSeconds: result.wallSeconds,
1471
+ telemetry: result.telemetry ?? null,
1472
+ };
1473
+ }
1474
+ function zeroUsageSlice() {
1475
+ return {
1476
+ tokens: 0,
1477
+ inputTokens: 0,
1478
+ cachedInputTokens: 0,
1479
+ outputTokens: 0,
1480
+ costUsd: 0,
1481
+ wallSeconds: 0,
1482
+ };
1483
+ }
1484
+ function zeroExecutionResult() {
1485
+ return {
1486
+ exitCode: 0,
1487
+ stdout: "",
1488
+ stderr: "",
1489
+ tokens: 0,
1490
+ inputTokens: 0,
1491
+ cachedInputTokens: 0,
1492
+ outputTokens: 0,
1493
+ costUsd: 0,
1494
+ wallSeconds: 0,
1495
+ model: null,
1496
+ runtime: null,
1497
+ };
1498
+ }
1499
+ async function writeRunRecord(root, record) {
1500
+ await writeJsonAtomic(join(projectRoot(root, record.projectId), "runs", `${record.runId}.json`), sanitizeResearchRecord(record));
1501
+ }
1502
+ async function withHeartbeat(operation, options, requestId, project, workPackage, config) {
1503
+ const timer = setInterval(() => {
1504
+ emitProgress(options, progressEvent("package.heartbeat", requestId, project.id, workPackage.id, remainingBudget(project, config), { attempt: workPackage.attempts }));
1505
+ }, 30_000);
1506
+ timer.unref();
1507
+ try {
1508
+ return await operation;
1509
+ }
1510
+ finally {
1511
+ clearInterval(timer);
1512
+ }
1513
+ }
1514
+ function emitProgress(options, event) {
1515
+ try {
1516
+ options.onProgress?.(sanitizeResearchRecord(event, configuredResearchSecrets(options.environment)));
1517
+ }
1518
+ catch {
1519
+ // Progress reporting must not alter research execution.
1520
+ }
1521
+ }
1522
+ function progressEvent(type, requestId, projectId, packageId, remaining, detail) {
1523
+ return {
1524
+ schemaVersion: 1,
1525
+ type,
1526
+ timestamp: new Date().toISOString(),
1527
+ requestId,
1528
+ projectId,
1529
+ packageId,
1530
+ remainingBudget: remaining,
1531
+ ...(detail ? { detail } : {}),
1532
+ };
1533
+ }
1534
+ async function dryRunResult(root, requestId, projectId) {
1535
+ const projects = await projectsForRun(root, projectId);
425
1536
  return {
426
1537
  workspace: root,
1538
+ requestId,
1539
+ projectId: projectId ?? null,
427
1540
  status: "dry-run",
428
1541
  stopReason: "dry-run",
429
1542
  cycles: 0,
@@ -436,60 +1549,62 @@ async function dryRunResult(root) {
436
1549
  })),
437
1550
  };
438
1551
  }
439
- async function summarizeRun(root, cycles, executed, maxCycles) {
440
- const projects = await listProjects(root);
1552
+ async function summarizeRun(root, requestId, cycles, executed, maxCycles, projectId) {
1553
+ const projects = await projectsForRun(root, projectId);
441
1554
  const summaries = projects.map((project) => ({
442
1555
  id: project.id,
443
1556
  status: refreshProject(project).status,
444
1557
  readyPackage: nextReadyPackage(project)?.id ?? null,
445
1558
  usage: project.usage,
446
1559
  }));
1560
+ const unfinished = summaries.filter((project) => project.status !== "complete");
1561
+ const hasReadyPackage = summaries.some((project) => project.readyPackage !== null);
447
1562
  const status = summaries.length > 0 && summaries.every((project) => project.status === "complete")
448
1563
  ? "complete"
449
- : summaries.some((project) => project.status === "blocked")
1564
+ : unfinished.length > 0 && unfinished.every((project) => project.status === "blocked")
450
1565
  ? "blocked"
451
1566
  : "ready";
452
1567
  const stopReason = summaries.length === 0
453
1568
  ? "no-projects"
454
1569
  : status === "complete"
455
1570
  ? "all-projects-complete"
456
- : status === "blocked"
457
- ? "project-blocked"
458
- : cycles >= maxCycles
459
- ? "cycle-limit"
1571
+ : hasReadyPackage && cycles >= maxCycles
1572
+ ? "cycle-limit"
1573
+ : status === "blocked"
1574
+ ? "project-blocked"
460
1575
  : "no-ready-work";
461
- return { workspace: root, status, stopReason, cycles, executed, projects: summaries };
462
- }
463
- function assertBudget(project, config) {
464
- const exceeded = project.usage.tokens >= config.budget.maxTokens ||
465
- project.usage.costUsd >= config.budget.maxCostUsd ||
466
- project.usage.wallSeconds >= config.budget.maxWallSeconds;
467
- if (exceeded) {
468
- throw new CliError(`Research budget is exhausted for project ${project.id}.`, {
469
- code: "RESEARCH_BUDGET_EXHAUSTED",
470
- exitCode: 3,
471
- details: { usage: project.usage, budget: config.budget },
472
- });
473
- }
474
- }
475
- function assertProjectedBudget(project, config, result) {
476
- const projected = {
477
- tokens: project.usage.tokens + result.tokens,
478
- costUsd: project.usage.costUsd + result.costUsd,
479
- wallSeconds: project.usage.wallSeconds + result.wallSeconds,
1576
+ return {
1577
+ workspace: root,
1578
+ requestId,
1579
+ projectId: projectId ?? null,
1580
+ status,
1581
+ stopReason,
1582
+ cycles,
1583
+ executed,
1584
+ projects: summaries,
480
1585
  };
481
- if (projected.tokens > config.budget.maxTokens ||
482
- projected.costUsd > config.budget.maxCostUsd ||
483
- projected.wallSeconds > config.budget.maxWallSeconds) {
484
- throw new CliError(`Research execution exceeded a hard budget for project ${project.id}.`, {
485
- code: "RESEARCH_BUDGET_EXHAUSTED",
486
- exitCode: 3,
487
- details: { projected, budget: config.budget },
488
- });
1586
+ }
1587
+ async function projectsForRun(root, projectId) {
1588
+ return projectId ? [await loadProject(root, projectId)] : listProjects(root);
1589
+ }
1590
+ function assertPublicEvidenceUrl(value, sourceId) {
1591
+ let url;
1592
+ try {
1593
+ url = new URL(value);
1594
+ }
1595
+ catch {
1596
+ throw deterministicError(`Evidence source ${sourceId} contains an invalid URL.`);
1597
+ }
1598
+ if (url.username || url.password) {
1599
+ throw deterministicError(`Evidence source ${sourceId} URL contains credentials.`);
1600
+ }
1601
+ const sensitive = /^(access_token|api[_-]?key|apikey|auth|authorization|code|cookie|key|password|secret|session|sig|signature|token)$/i;
1602
+ if ([...url.searchParams.keys()].some((key) => sensitive.test(key))) {
1603
+ throw deterministicError(`Evidence source ${sourceId} URL contains sensitive parameters.`);
489
1604
  }
490
1605
  }
491
- function remainingWallSeconds(project, config) {
492
- return Math.max(1, Math.floor(config.budget.maxWallSeconds - project.usage.wallSeconds));
1606
+ function deterministicError(message) {
1607
+ return new CliError(message, { code: "RESEARCH_OUTPUT_INVALID", exitCode: 3 });
493
1608
  }
494
1609
  function projectRoot(root, projectId) {
495
1610
  return join(workspacePaths(root).projects, projectId);
@@ -510,10 +1625,13 @@ function validateRunOptions(options) {
510
1625
  });
511
1626
  }
512
1627
  }
1628
+ function bounded(value, length) {
1629
+ return value.length <= length ? value : `${value.slice(0, length)}…`;
1630
+ }
513
1631
  function roundMoney(value) {
514
1632
  return Math.round(value * 1_000_000) / 1_000_000;
515
1633
  }
516
- function bounded(value, length) {
517
- return value.length <= length ? value : `${value.slice(0, length)}…`;
1634
+ function numericOrNull(value) {
1635
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
518
1636
  }
519
1637
  //# sourceMappingURL=runtime.js.map