@tiangong-ai/cli 0.0.31 → 0.0.33

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 (64) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +140 -98
  3. package/dist/research/orchestration.js +519 -21
  4. package/dist/research/orchestration.js.map +1 -1
  5. package/dist/research/workspace/acquisition.d.ts +71 -0
  6. package/dist/research/workspace/acquisition.js +593 -0
  7. package/dist/research/workspace/acquisition.js.map +1 -0
  8. package/dist/research/workspace/artifacts.d.ts +43 -0
  9. package/dist/research/workspace/artifacts.js +544 -0
  10. package/dist/research/workspace/artifacts.js.map +1 -0
  11. package/dist/research/workspace/broker.d.ts +5 -0
  12. package/dist/research/workspace/broker.js +318 -43
  13. package/dist/research/workspace/broker.js.map +1 -1
  14. package/dist/research/workspace/companion-readiness.d.ts +11 -0
  15. package/dist/research/workspace/companion-readiness.js +75 -0
  16. package/dist/research/workspace/companion-readiness.js.map +1 -0
  17. package/dist/research/workspace/discovery-planning.d.ts +25 -0
  18. package/dist/research/workspace/discovery-planning.js +106 -0
  19. package/dist/research/workspace/discovery-planning.js.map +1 -0
  20. package/dist/research/workspace/discovery-status.d.ts +46 -0
  21. package/dist/research/workspace/discovery-status.js +183 -0
  22. package/dist/research/workspace/discovery-status.js.map +1 -0
  23. package/dist/research/workspace/discovery.d.ts +25 -0
  24. package/dist/research/workspace/discovery.js +268 -0
  25. package/dist/research/workspace/discovery.js.map +1 -0
  26. package/dist/research/workspace/downloads.d.ts +76 -0
  27. package/dist/research/workspace/downloads.js +274 -0
  28. package/dist/research/workspace/downloads.js.map +1 -0
  29. package/dist/research/workspace/evidence-ledger.d.ts +52 -0
  30. package/dist/research/workspace/evidence-ledger.js +487 -0
  31. package/dist/research/workspace/evidence-ledger.js.map +1 -0
  32. package/dist/research/workspace/evidence.d.ts +1 -0
  33. package/dist/research/workspace/evidence.js +2 -0
  34. package/dist/research/workspace/evidence.js.map +1 -1
  35. package/dist/research/workspace/input-plan.js +4 -0
  36. package/dist/research/workspace/input-plan.js.map +1 -1
  37. package/dist/research/workspace/native-activity.d.ts +65 -0
  38. package/dist/research/workspace/native-activity.js +153 -0
  39. package/dist/research/workspace/native-activity.js.map +1 -0
  40. package/dist/research/workspace/preflight.d.ts +18 -0
  41. package/dist/research/workspace/preflight.js +47 -20
  42. package/dist/research/workspace/preflight.js.map +1 -1
  43. package/dist/research/workspace/projects.d.ts +3 -1
  44. package/dist/research/workspace/projects.js +357 -5
  45. package/dist/research/workspace/projects.js.map +1 -1
  46. package/dist/research/workspace/runtime.d.ts +171 -3
  47. package/dist/research/workspace/runtime.js +1582 -77
  48. package/dist/research/workspace/runtime.js.map +1 -1
  49. package/dist/research/workspace/sanitization.js +28 -6
  50. package/dist/research/workspace/sanitization.js.map +1 -1
  51. package/dist/research/workspace/schemas.d.ts +3 -0
  52. package/dist/research/workspace/schemas.js +206 -33
  53. package/dist/research/workspace/schemas.js.map +1 -1
  54. package/dist/research/workspace/setup-catalog.js +2 -2
  55. package/dist/research/workspace/setup-wizard.js +45 -17
  56. package/dist/research/workspace/setup-wizard.js.map +1 -1
  57. package/dist/research/workspace/setup.d.ts +27 -5
  58. package/dist/research/workspace/setup.js +400 -85
  59. package/dist/research/workspace/setup.js.map +1 -1
  60. package/dist/research/workspace/types.d.ts +38 -3
  61. package/dist/research/workspace/workspace.d.ts +2 -0
  62. package/dist/research/workspace/workspace.js +128 -26
  63. package/dist/research/workspace/workspace.js.map +1 -1
  64. package/package.json +1 -1
@@ -1,21 +1,64 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { cp, lstat, readFile, rm } from "node:fs/promises";
3
- import { basename, dirname, join, relative } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { CliError } from "../../errors.js";
5
5
  import { loadCapabilityDeclarations, stageLockedCapabilities, verifyCapabilities, } from "./capabilities.js";
6
6
  import { startCapabilityBroker } from "./broker.js";
7
+ import { commitAcquisitionAssessments, freezeEvidenceSnapshot, loadCurrentEvidenceSnapshot, loadImmutableEvidenceSnapshotChain, materializeAcquisitionAudit, parseMaterializedAcquisitionAudit, } from "./acquisition.js";
8
+ import { stageEvidenceArtifacts } from "./artifacts.js";
9
+ import { commitDiscoveryDecisions, materializeDiscoveryEvidence } from "./discovery.js";
10
+ import { inspectDiscoveryProgress } from "./discovery-status.js";
11
+ import { downloadBindingRecordSchema } from "./downloads.js";
7
12
  import { loadProjectEvidenceReceipts, stageProjectEvidence } from "./evidence.js";
13
+ import { appendEvidenceLedgerEvent, evidenceLedgerPath, listEvidenceCandidates, registerProjectInputCandidates, } from "./evidence-ledger.js";
8
14
  import { executeAgent } from "./executor.js";
9
15
  import { requiredDiscoveryCapabilityIds } from "./external-skills.js";
10
16
  import { renderInputLineContext } from "./input-plan.js";
11
17
  import { appendJournalEvent, readJournal, verifyJournal } from "./journal.js";
18
+ import { nativeActivityRecordSchema } from "./native-activity.js";
12
19
  import { calculateAgentCallTokenReservation, RESEARCH_BROKER_MAX_TURNS, RESEARCH_ESTIMATED_BYTES_PER_TOKEN, RESEARCH_MAX_REPAIR_SOURCE_BYTES, RESEARCH_REPAIR_MAX_TURNS, researchStructuredOutputMaxTurns, reservedAgentPackageCost, } from "./preflight.js";
13
20
  import { listProjects, loadProject, nextReadyPackage, packageById, refreshProject, saveProject, } from "./projects.js";
14
21
  import { configuredResearchSecrets, sanitizeResearchRecord, sanitizeResearchText, } from "./sanitization.js";
15
- import { parseStructuredStageOutput, schemaForStage, StructuredOutputError } from "./schemas.js";
22
+ import { parseEvidenceRecord, schemaForDiscoveryAssessmentBatch, parseStructuredStageOutput, schemaForStage, StructuredOutputError, } from "./schemas.js";
16
23
  import { canonicalJson, ensureDirectory, fileRecord, isObject, pathExists, readJsonFile, regularTreeFiles, resolveContained, sha256File, sha256Text, workspacePaths, writeJsonAtomic, writeTextAtomic, } from "./storage.js";
17
24
  import { loadWorkspaceConfig, verifyDoctorAttestation, withWorkspaceLock } from "./workspace.js";
25
+ export const researchHandoffRecordSchema = {
26
+ $id: "https://schemas.tiangong.ai/research/handoff-request-v1.json",
27
+ type: "object",
28
+ additionalProperties: false,
29
+ required: ["schemaVersion", "state", "reasonCode", "summary", "requestedActions", "evidenceGaps"],
30
+ properties: {
31
+ schemaVersion: { type: "integer", const: 1 },
32
+ state: {
33
+ type: "string",
34
+ enum: ["user-action-required", "external-response-required"],
35
+ },
36
+ reasonCode: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" },
37
+ summary: { type: "string", minLength: 8, maxLength: 1_000 },
38
+ requestedActions: {
39
+ type: "array",
40
+ minItems: 1,
41
+ maxItems: 10,
42
+ uniqueItems: true,
43
+ items: { type: "string", minLength: 1, maxLength: 500 },
44
+ },
45
+ evidenceGaps: {
46
+ type: "array",
47
+ minItems: 1,
48
+ maxItems: 50,
49
+ uniqueItems: true,
50
+ items: { type: "string", minLength: 1, maxLength: 500 },
51
+ },
52
+ },
53
+ };
18
54
  export async function runResearchWorkspace(root, options, packageExecutor = executeAgent) {
55
+ return runResearchWorkspaceInternal(root, options, packageExecutor, false);
56
+ }
57
+ /** @internal Test-only seam for exercising deterministic package admission with fake agents. */
58
+ export async function runResearchWorkspaceWithInjectedProducerForTesting(root, options, packageExecutor) {
59
+ return runResearchWorkspaceInternal(root, options, packageExecutor, true);
60
+ }
61
+ async function runResearchWorkspaceInternal(root, options, packageExecutor, allowInjectedProducerForTesting) {
19
62
  validateRunOptions(options);
20
63
  const requestId = randomUUID();
21
64
  if (options.dryRun)
@@ -36,7 +79,7 @@ export async function runResearchWorkspace(root, options, packageExecutor = exec
36
79
  if (config.mode === "production-research") {
37
80
  const verification = await verifyDoctorAttestation(root);
38
81
  if (verification.status !== "verified" || !verification.attestation) {
39
- throw new CliError("Production research requires a current successful producer/reviewer doctor smoke.", {
82
+ throw new CliError("Production research requires a current successful independent-reviewer doctor smoke.", {
40
83
  code: "RESEARCH_DOCTOR_ATTESTATION_REQUIRED",
41
84
  exitCode: 3,
42
85
  details: { status: verification.status, errors: verification.errors },
@@ -61,6 +104,7 @@ export async function runResearchWorkspace(root, options, packageExecutor = exec
61
104
  const selected = projects
62
105
  .map((project) => ({ project, workPackage: nextReadyPackage(project) }))
63
106
  .filter((item) => Boolean(item.workPackage) &&
107
+ (allowInjectedProducerForTesting || item.workPackage?.executor !== "producer") &&
64
108
  item.project.status !== "blocked" &&
65
109
  item.project.status !== "complete")
66
110
  .slice(0, options.maxParallel);
@@ -78,6 +122,807 @@ export async function runResearchWorkspace(root, options, packageExecutor = exec
78
122
  return result;
79
123
  });
80
124
  }
125
+ export async function inspectNativeResearchStage(root, project) {
126
+ const path = nativeStageSessionPath(root, project.id);
127
+ if (!(await pathExists(path))) {
128
+ return {
129
+ status: "none",
130
+ sessionId: null,
131
+ stage: null,
132
+ preparedAt: null,
133
+ reasonCode: null,
134
+ recommendedAction: null,
135
+ };
136
+ }
137
+ try {
138
+ const session = await readNativeStageSession(root, project.id);
139
+ const workPackage = packageById(project, session.packet.packageId);
140
+ const actualBinding = await nativeStageBinding(root, project, workPackage);
141
+ const config = await loadWorkspaceConfig(root);
142
+ const elapsedSeconds = Math.max(0, (Date.now() - Date.parse(session.packet.preparedAt)) / 1_000);
143
+ const reasonCode = workPackage.status !== "running"
144
+ ? "package-not-running"
145
+ : actualBinding !== session.packet.bindingSha256
146
+ ? "binding-drift"
147
+ : elapsedSeconds > config.budget.packageMaxWallSeconds[session.packet.stage]
148
+ ? "wall-time-expired"
149
+ : null;
150
+ return {
151
+ status: reasonCode ? "stale" : "active",
152
+ sessionId: session.packet.sessionId,
153
+ stage: session.packet.stage,
154
+ preparedAt: session.packet.preparedAt,
155
+ reasonCode,
156
+ recommendedAction: reasonCode
157
+ ? `tiangong-ai research project stage abort ${project.id} --session ${session.packet.sessionId} --workspace ${root}`
158
+ : `Resume the current native ${session.packet.stage} stage and submit with the packet command.`,
159
+ };
160
+ }
161
+ catch (error) {
162
+ return {
163
+ status: "invalid",
164
+ sessionId: null,
165
+ stage: null,
166
+ preparedAt: null,
167
+ reasonCode: error instanceof CliError ? error.code : "RESEARCH_NATIVE_STAGE_SESSION_INVALID",
168
+ recommendedAction: "Inspect the invalid native session and use the explicit abort/retry recovery path; do not delete control files manually.",
169
+ };
170
+ }
171
+ }
172
+ export async function prepareNativeResearchStage(input) {
173
+ return withWorkspaceLock(input.root, "research.native-stage.prepare", async () => {
174
+ await verifyJournal(workspacePaths(input.root).journal);
175
+ const config = await loadWorkspaceConfig(input.root);
176
+ assertExecutionConfiguration(config);
177
+ if (config.producer.agent !== input.hostAgent) {
178
+ throw new CliError(`This workspace requires the current native ${config.producer.agent} host, not ${input.hostAgent}.`, { code: "RESEARCH_NATIVE_HOST_MISMATCH", exitCode: 3 });
179
+ }
180
+ const capabilityVerification = await verifyCapabilities(input.root);
181
+ if (capabilityVerification.status !== "verified") {
182
+ throw new CliError("Native research requires verified capability locks.", {
183
+ code: "RESEARCH_CAPABILITY_DRIFT",
184
+ exitCode: 3,
185
+ details: capabilityVerification,
186
+ });
187
+ }
188
+ if (config.mode === "production-research") {
189
+ const attestation = await verifyDoctorAttestation(input.root);
190
+ if (attestation.status !== "verified") {
191
+ throw new CliError("Native research requires a current independent-reviewer attestation.", {
192
+ code: "RESEARCH_DOCTOR_ATTESTATION_REQUIRED",
193
+ exitCode: 3,
194
+ details: { status: attestation.status, errors: attestation.errors },
195
+ });
196
+ }
197
+ }
198
+ const project = await loadProject(input.root, input.projectId);
199
+ if (input.stage === "discover") {
200
+ await registerProjectInputCandidates(input.root, project.id, project.inputs);
201
+ }
202
+ if (input.stage === "analyze" || input.stage === "synthesize") {
203
+ await loadCurrentEvidenceSnapshot(input.root, project.id);
204
+ }
205
+ if (config.mode === "production-research" &&
206
+ config.budget.maxCostUsd > config.budget.confirmationCostUsd &&
207
+ !project.budgetConfirmedAt) {
208
+ throw new CliError("Production research budget has not been explicitly confirmed.", {
209
+ code: "RESEARCH_BUDGET_CONFIRMATION_REQUIRED",
210
+ exitCode: 3,
211
+ });
212
+ }
213
+ const activePath = nativeStageSessionPath(input.root, project.id);
214
+ if (await pathExists(activePath)) {
215
+ const active = await readNativeStageSession(input.root, project.id);
216
+ if (active.packet.stage === input.stage &&
217
+ active.packet.hostAgent === input.hostAgent &&
218
+ project.packages.find((item) => item.id === active.packet.packageId)?.status === "running") {
219
+ await assertNativeStageBinding(input.root, project, active.packet);
220
+ return active.packet;
221
+ }
222
+ throw new CliError("Another native stage session is already active for this project.", {
223
+ code: "RESEARCH_NATIVE_STAGE_ACTIVE",
224
+ exitCode: 3,
225
+ details: { sessionId: active.packet.sessionId, stage: active.packet.stage },
226
+ });
227
+ }
228
+ const workPackage = nextReadyPackage(project);
229
+ if (!workPackage || workPackage.executor !== "producer" || workPackage.stage !== input.stage) {
230
+ throw new CliError(`Stage ${input.stage} is not the next native producer package.`, {
231
+ code: "RESEARCH_NATIVE_STAGE_NOT_READY",
232
+ exitCode: 3,
233
+ details: { readyPackage: workPackage?.id ?? null },
234
+ });
235
+ }
236
+ if (workPackage.attempts >= workPackage.maxAttempts) {
237
+ throw new CliError("Native producer package exhausted its reviewed attempt limit.", {
238
+ code: "RESEARCH_PACKAGE_ATTEMPTS_EXHAUSTED",
239
+ exitCode: 3,
240
+ });
241
+ }
242
+ const discovery = input.stage === "discover"
243
+ ? await inspectDiscoveryProgress(input.root, project, config)
244
+ : null;
245
+ const reservedPackageTokens = discovery?.plan.reservedDiscoverTokens ?? config.budget.packageMaxTokens[input.stage];
246
+ const stageOutputTokens = discovery?.plan.outputTokenLimit ?? config.budget.maxOutputTokens;
247
+ const reservation = reservePackageBudget(project, workPackage, config, reservedPackageTokens);
248
+ const sessionId = randomUUID();
249
+ let capsule = null;
250
+ let preparedStatePersisted = false;
251
+ try {
252
+ capsule = await createCapsule(input.root, project, workPackage, sessionId, config);
253
+ const stageContextContent = await stageContextForPackage(capsule.projectRoot, project, workPackage, config);
254
+ const declarations = await loadCapabilityDeclarations(input.root);
255
+ const hasBrokeredEvidence = declarations.capabilities.some((capability) => capability.permissions.includes("brokered-network"));
256
+ const basePrompt = packagePrompt(project, workPackage, capsule.inputManifest, capsule.stagedSkills, capsule.capabilityDocumentation, null, capsule.contextBundle, capsule.contextBundleContent, stageContextContent, discovery, await listEvidenceCandidates(input.root, project.id));
257
+ const prompt = [
258
+ "Perform this producer stage in the current interactive host session. Do not launch codex exec, claude -p, or any other nested reasoning agent.",
259
+ input.stage === "discover" && hasBrokeredEvidence
260
+ ? "Use native Web/Browser broadly for discovery when useful, but record every native search/navigation with recordActivity and register its candidates. Before admitting any native lead, formalize the same URL or DOI through fetchEvidence so it receives an immutable broker receipt. Assess candidates in bounded batches with recordAssessment as they arrive; the final output is only a small coverage closeout. Native results without broker/input provenance are discovery leads, never evidence."
261
+ : input.stage === "acquire"
262
+ ? "Acquire the provisionally admitted sources with the installed external acquisition/document Skills or an explicitly selected user-authorized browser. Capture the exact browser/adapter Download object, save it to the planned unique staging path, and call bindDownload before registerArtifact. Record browser/download/file-inspection activity with recordActivity. Failed or cancelled downloads cannot create bindings or artifacts. Never scan a download directory or infer success from file existence."
263
+ : "Do not acquire additional evidence in this stage.",
264
+ basePrompt,
265
+ "Save only the final schema-conforming JSON object to a new regular file, then submit it with the packet's submit command. The CLI remains the sole authority for validation and atomic promotion.",
266
+ ].join("\n\n");
267
+ const preparedAt = new Date().toISOString();
268
+ const bindingSha256 = await nativeStageBinding(input.root, project, workPackage);
269
+ const packetCore = {
270
+ schemaVersion: 1,
271
+ kind: "tiangong-native-research-stage",
272
+ sessionId,
273
+ projectId: project.id,
274
+ packageId: workPackage.id,
275
+ stage: input.stage,
276
+ hostAgent: input.hostAgent,
277
+ expectedModel: config.producer.model,
278
+ preparedAt,
279
+ bindingSha256,
280
+ prompt,
281
+ outputSchema: schemaForStage(input.stage, null, input.stage === "discover" && !hasBrokeredEvidence
282
+ ? { inputOnlyProvenanceIds: capsule.inputManifest.map((record) => record.id) }
283
+ : {}),
284
+ discovery,
285
+ limits: {
286
+ maxOutputBytes: config.budget.maxBytesPerPackage,
287
+ maxOutputTokens: stageOutputTokens,
288
+ reservedPackageTokens,
289
+ reservedMaxCostUsd: reservation.costUsd,
290
+ maxWallSeconds: config.budget.packageMaxWallSeconds[input.stage],
291
+ },
292
+ commands: {
293
+ requestHandoff: {
294
+ argv: [
295
+ "tiangong-ai",
296
+ "research",
297
+ "project",
298
+ "handoff",
299
+ "request",
300
+ project.id,
301
+ "--record",
302
+ "<absolute-handoff-record.json>",
303
+ "--workspace",
304
+ input.root,
305
+ "--json",
306
+ ],
307
+ recordSchema: structuredClone(researchHandoffRecordSchema),
308
+ },
309
+ recordActivity: input.stage === "discover" || input.stage === "acquire"
310
+ ? {
311
+ argv: [
312
+ "tiangong-ai",
313
+ "research",
314
+ "project",
315
+ "evidence",
316
+ "activity",
317
+ "record",
318
+ project.id,
319
+ "--record",
320
+ "<absolute-activity.json>",
321
+ "--workspace",
322
+ input.root,
323
+ "--json",
324
+ ],
325
+ recordSchema: structuredClone(nativeActivityRecordSchema),
326
+ }
327
+ : null,
328
+ fetchEvidence: input.stage === "discover" && hasBrokeredEvidence
329
+ ? {
330
+ argv: [
331
+ "tiangong-ai",
332
+ "research",
333
+ "project",
334
+ "evidence",
335
+ "fetch",
336
+ project.id,
337
+ "--request",
338
+ "<absolute-request.json>",
339
+ "--workspace",
340
+ input.root,
341
+ "--json",
342
+ ],
343
+ requestSchema: {
344
+ type: "object",
345
+ additionalProperties: false,
346
+ required: ["capability_id", "url"],
347
+ properties: {
348
+ capability_id: { type: "string" },
349
+ credential_id: { type: "string" },
350
+ url: { type: "string", format: "uri" },
351
+ request_body: { type: "object" },
352
+ json_pointer: { type: "string" },
353
+ item_offset: { type: "integer", minimum: 0 },
354
+ max_items: { type: "integer", minimum: 1 },
355
+ cache_mode: { enum: ["prefer", "bypass"] },
356
+ },
357
+ },
358
+ }
359
+ : null,
360
+ registerArtifact: input.stage === "acquire"
361
+ ? {
362
+ argv: [
363
+ "tiangong-ai",
364
+ "research",
365
+ "project",
366
+ "evidence",
367
+ "artifact",
368
+ "register",
369
+ project.id,
370
+ "--candidate",
371
+ "<candidate-id>",
372
+ "--path",
373
+ "<absolute-file>",
374
+ "--download-binding",
375
+ "<binding-id-for-network-file>",
376
+ "--media-type",
377
+ "<media-type>",
378
+ "--workspace",
379
+ input.root,
380
+ "--json",
381
+ ],
382
+ supportedMediaTypes: [
383
+ "application/pdf",
384
+ "application/json",
385
+ "text/plain",
386
+ "text/markdown",
387
+ "text/csv",
388
+ "text/html",
389
+ "application/zip",
390
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
391
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
392
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
393
+ ],
394
+ optionalMetadataFields: [
395
+ "download-binding",
396
+ "derived-from-artifact",
397
+ "source-url",
398
+ "license",
399
+ "license-url",
400
+ "host-type",
401
+ "article-version",
402
+ ],
403
+ }
404
+ : null,
405
+ bindDownload: input.stage === "acquire"
406
+ ? {
407
+ argv: [
408
+ "tiangong-ai",
409
+ "research",
410
+ "project",
411
+ "evidence",
412
+ "download",
413
+ "bind",
414
+ project.id,
415
+ "--candidate",
416
+ "<candidate-id>",
417
+ "--record",
418
+ "<absolute-download-record.json>",
419
+ "--workspace",
420
+ input.root,
421
+ "--json",
422
+ ],
423
+ recordSchema: structuredClone(downloadBindingRecordSchema),
424
+ }
425
+ : null,
426
+ recordAssessment: input.stage === "discover"
427
+ ? {
428
+ argv: [
429
+ "tiangong-ai",
430
+ "research",
431
+ "project",
432
+ "evidence",
433
+ "assessment",
434
+ "record",
435
+ project.id,
436
+ "--record",
437
+ "<absolute-assessment-batch.json>",
438
+ "--workspace",
439
+ input.root,
440
+ "--json",
441
+ ],
442
+ recordSchema: schemaForDiscoveryAssessmentBatch(),
443
+ }
444
+ : null,
445
+ registerCandidate: input.stage === "discover"
446
+ ? {
447
+ argv: [
448
+ "tiangong-ai",
449
+ "research",
450
+ "project",
451
+ "evidence",
452
+ "candidate",
453
+ "register",
454
+ project.id,
455
+ "--record",
456
+ "<absolute-candidate.json>",
457
+ "--workspace",
458
+ input.root,
459
+ "--json",
460
+ ],
461
+ recordSchema: {
462
+ type: "object",
463
+ additionalProperties: false,
464
+ required: ["title"],
465
+ anyOf: [{ required: ["url"] }, { required: ["doi"] }],
466
+ properties: {
467
+ title: { type: "string" },
468
+ url: { type: "string", format: "uri" },
469
+ doi: { type: "string" },
470
+ publicationDate: { type: "string" },
471
+ excerpt: { type: "string" },
472
+ },
473
+ },
474
+ }
475
+ : null,
476
+ submit: {
477
+ argv: [
478
+ "tiangong-ai",
479
+ "research",
480
+ "project",
481
+ "stage",
482
+ "submit",
483
+ project.id,
484
+ "--session",
485
+ sessionId,
486
+ "--output",
487
+ "<absolute-output.json>",
488
+ ...(config.producer.model ? ["--confirm-model", config.producer.model] : []),
489
+ "--workspace",
490
+ input.root,
491
+ "--json",
492
+ ],
493
+ },
494
+ abort: {
495
+ argv: [
496
+ "tiangong-ai",
497
+ "research",
498
+ "project",
499
+ "stage",
500
+ "abort",
501
+ project.id,
502
+ "--session",
503
+ sessionId,
504
+ "--workspace",
505
+ input.root,
506
+ "--json",
507
+ ],
508
+ },
509
+ },
510
+ rules: [
511
+ "Current native host performs producer reasoning; the CLI does not spawn a producer.",
512
+ "Native Web/Browser activity is visible in the evidence ledger but becomes admissible only after broker/input formalization and strict assessment.",
513
+ "When the next material step requires user authorization or an external response, request a durable handoff and stop; do not keep searching low-yield substitutes.",
514
+ "Only broker receipts or registered immutable inputs may support discover output.",
515
+ "Only exact, structurally validated, content-addressed artifacts may support full-text acquisition claims.",
516
+ "Do not place credentials, cookies, authorization data, or sensitive URL parameters in request/output files.",
517
+ "A file's existence is not success; submit performs schema, provenance, budget, hash, and atomic-commit checks.",
518
+ ],
519
+ };
520
+ const packet = {
521
+ ...packetCore,
522
+ packetSha256: sha256Text(canonicalJson(packetCore)),
523
+ };
524
+ const sessionCore = {
525
+ schemaVersion: 1,
526
+ kind: "tiangong-native-research-stage-session",
527
+ packet,
528
+ capsuleRoot: capsule.capsuleRoot,
529
+ capsuleProject: capsule.projectRoot,
530
+ };
531
+ const session = {
532
+ ...sessionCore,
533
+ sessionSha256: sha256Text(canonicalJson(sessionCore)),
534
+ };
535
+ const now = new Date().toISOString();
536
+ workPackage.status = "running";
537
+ workPackage.attempts += 1;
538
+ workPackage.startedAt = now;
539
+ workPackage.completedAt = null;
540
+ workPackage.lastError = null;
541
+ workPackage.lastFailureKind = null;
542
+ workPackage.retryNotBefore = null;
543
+ refreshProject(project);
544
+ await ensureDirectory(dirname(activePath));
545
+ await writeJsonAtomic(activePath, session);
546
+ await saveProject(input.root, project);
547
+ preparedStatePersisted = true;
548
+ await appendJournalEvent(workspacePaths(input.root).journal, "native.stage.prepared", project.id, {
549
+ sessionId,
550
+ packetSha256: packet.packetSha256,
551
+ bindingSha256,
552
+ projectId: project.id,
553
+ packageId: workPackage.id,
554
+ stage: input.stage,
555
+ hostAgent: input.hostAgent,
556
+ expectedModel: config.producer.model,
557
+ accountingMode: "reserved-native-host",
558
+ });
559
+ return packet;
560
+ }
561
+ catch (error) {
562
+ if (!preparedStatePersisted) {
563
+ await rm(activePath, { force: true });
564
+ if (capsule)
565
+ await rm(capsule.capsuleRoot, { recursive: true, force: true });
566
+ }
567
+ throw error;
568
+ }
569
+ });
570
+ }
571
+ export async function submitNativeResearchStage(input) {
572
+ return withWorkspaceLock(input.root, "research.native-stage.submit", async () => {
573
+ const session = await readNativeStageSession(input.root, input.projectId);
574
+ if (session.packet.sessionId !== input.sessionId) {
575
+ throw new CliError("Native stage session ID does not match the active session.", {
576
+ code: "RESEARCH_NATIVE_STAGE_SESSION_MISMATCH",
577
+ exitCode: 3,
578
+ });
579
+ }
580
+ const outputPath = requireNativeOutputPath(input.outputPath);
581
+ const outputInfo = await lstat(outputPath).catch(() => undefined);
582
+ if (!outputInfo?.isFile() || outputInfo.isSymbolicLink()) {
583
+ throw new CliError("Native stage output must be an existing regular non-symlink file.", {
584
+ code: "RESEARCH_NATIVE_STAGE_OUTPUT_INVALID",
585
+ exitCode: 2,
586
+ });
587
+ }
588
+ if (outputInfo.size > session.packet.limits.maxOutputBytes) {
589
+ throw new CliError("Native stage output exceeds the reviewed byte limit.", {
590
+ code: "RESEARCH_NATIVE_STAGE_OUTPUT_INVALID",
591
+ exitCode: 3,
592
+ });
593
+ }
594
+ const raw = await readFile(outputPath, "utf8");
595
+ const config = await loadWorkspaceConfig(input.root);
596
+ assertExecutionConfiguration(config);
597
+ if (input.confirmedModel !== session.packet.expectedModel) {
598
+ throw new CliError("The confirmed native model does not match the reviewed route.", {
599
+ code: "RESEARCH_NATIVE_MODEL_MISMATCH",
600
+ exitCode: 3,
601
+ details: { expectedModel: session.packet.expectedModel },
602
+ });
603
+ }
604
+ const project = await loadProject(input.root, input.projectId);
605
+ if (project.handoff.state !== "agent-actionable") {
606
+ throw new CliError("Native stage is paused for an unresolved project handoff.", {
607
+ code: "RESEARCH_PROJECT_HANDOFF_REQUIRED",
608
+ exitCode: 3,
609
+ details: { state: project.handoff.state, reasonCode: project.handoff.reasonCode },
610
+ });
611
+ }
612
+ await assertNoUnresolvedNativeChallenge(input.root, project.id);
613
+ const workPackage = packageById(project, session.packet.packageId);
614
+ if (workPackage.status !== "running" || workPackage.executor !== "producer") {
615
+ throw new CliError("The bound native producer package is no longer running.", {
616
+ code: "RESEARCH_NATIVE_STAGE_SESSION_MISMATCH",
617
+ exitCode: 3,
618
+ });
619
+ }
620
+ await assertNativeStageBinding(input.root, project, session.packet);
621
+ try {
622
+ await materializeAndValidateStageOutput(input.root, project, session.capsuleProject, workPackage, raw, null);
623
+ const elapsed = Math.max(0.001, (Date.now() - Date.parse(session.packet.preparedAt)) / 1_000);
624
+ const outputTokens = Math.ceil(Buffer.byteLength(raw, "utf8") / RESEARCH_ESTIMATED_BYTES_PER_TOKEN);
625
+ const reservedTokens = session.packet.limits.reservedPackageTokens;
626
+ const result = {
627
+ exitCode: 0,
628
+ stdout: raw,
629
+ stderr: "",
630
+ tokens: reservedTokens,
631
+ inputTokens: Math.max(0, reservedTokens - outputTokens),
632
+ cachedInputTokens: 0,
633
+ outputTokens,
634
+ costUsd: roundMoney(reservedAgentPackageCost(config.producer, reservedTokens, config)),
635
+ wallSeconds: elapsed,
636
+ model: config.producer.model,
637
+ runtime: null,
638
+ };
639
+ assertActualPackageBudget(project, workPackage, config, result, session.packet.limits.maxOutputTokens);
640
+ assertProjectedBudget(project, config, result);
641
+ if (workPackage.stage === "discover") {
642
+ await assertDiscoveryCoverage(input.root, project, resolveContained(session.capsuleProject, "outputs/evidence.json"));
643
+ }
644
+ const outputs = await validateAndImportOutputs(input.root, project, workPackage, session.capsuleProject, config, null);
645
+ if (workPackage.stage === "discover") {
646
+ await commitDiscoveryDecisions(input.root, project.id, parseStructuredStageOutput("discover", raw).value);
647
+ }
648
+ if (workPackage.stage === "acquire") {
649
+ const audit = parseMaterializedAcquisitionAudit(JSON.parse(await readFile(join(projectRoot(input.root, project.id), "outputs", "acquisition.json"), "utf8")));
650
+ await commitAcquisitionAssessments(input.root, project.id, audit);
651
+ await freezeEvidenceSnapshot(input.root, project);
652
+ outputs.push(await fileRecord(join(projectRoot(input.root, project.id), "outputs", "evidence-snapshot.json"), "outputs/evidence-snapshot.json"));
653
+ }
654
+ await commitStageEvidenceBindings(input.root, project, workPackage);
655
+ applyUsage(project, result);
656
+ workPackage.status = "complete";
657
+ workPackage.completedAt = new Date().toISOString();
658
+ workPackage.lastError = null;
659
+ workPackage.lastFailureKind = null;
660
+ workPackage.retryNotBefore = null;
661
+ refreshProject(project);
662
+ await saveProject(input.root, project);
663
+ await writeRunRecord(input.root, {
664
+ schemaVersion: 1,
665
+ runId: session.packet.sessionId,
666
+ projectId: project.id,
667
+ packageId: workPackage.id,
668
+ executor: config.producer.agent,
669
+ startedAt: session.packet.preparedAt,
670
+ completedAt: workPackage.completedAt,
671
+ exitCode: 0,
672
+ tokens: result.tokens,
673
+ inputTokens: result.inputTokens,
674
+ cachedInputTokens: result.cachedInputTokens,
675
+ outputTokens: result.outputTokens,
676
+ costUsd: result.costUsd,
677
+ wallSeconds: result.wallSeconds,
678
+ outputs,
679
+ stdoutSha256: sha256Text(raw),
680
+ stderrSha256: sha256Text(""),
681
+ failureKind: null,
682
+ failureDetails: null,
683
+ runtime: null,
684
+ accountingMode: "reserved-native-host",
685
+ });
686
+ const usage = { ...usageSlice(result), accountingMode: "reserved-native-host" };
687
+ await appendJournalEvent(workspacePaths(input.root).journal, "native.stage.completed", project.id, {
688
+ sessionId: session.packet.sessionId,
689
+ packetSha256: session.packet.packetSha256,
690
+ projectId: project.id,
691
+ packageId: workPackage.id,
692
+ stage: workPackage.stage,
693
+ outputs,
694
+ usage,
695
+ });
696
+ await rm(nativeStageSessionPath(input.root, project.id), { force: true });
697
+ await rm(session.capsuleRoot, { recursive: true, force: true });
698
+ return {
699
+ projectId: project.id,
700
+ packageId: workPackage.id,
701
+ stage: workPackage.stage,
702
+ status: "complete",
703
+ outputs,
704
+ usage,
705
+ };
706
+ }
707
+ catch (error) {
708
+ await appendJournalEvent(workspacePaths(input.root).journal, "native.stage.submit.rejected", input.projectId, {
709
+ sessionId: session.packet.sessionId,
710
+ packetSha256: session.packet.packetSha256,
711
+ error: bounded(sanitizeResearchText(error instanceof Error ? error.message : String(error)), 1_000),
712
+ });
713
+ throw error;
714
+ }
715
+ });
716
+ }
717
+ async function assertNoUnresolvedNativeChallenge(root, projectId) {
718
+ const events = await readJournal(evidenceLedgerPath(root, projectId));
719
+ const lastChallengeIndex = events.findLastIndex((event) => event.type === "activity.recorded" &&
720
+ event.payload.status === "blocked" &&
721
+ event.payload.challenge !== "none");
722
+ if (lastChallengeIndex < 0)
723
+ return;
724
+ const lastResolutionIndex = events.findLastIndex((event) => event.type === "handoff.resolved");
725
+ if (lastResolutionIndex < lastChallengeIndex) {
726
+ throw new CliError("A login, MFA, CAPTCHA, paywall, security, or authorization challenge requires a durable user handoff before stage submission.", {
727
+ code: "RESEARCH_PROJECT_HANDOFF_REQUIRED",
728
+ exitCode: 3,
729
+ details: { challenge: events[lastChallengeIndex]?.payload.challenge ?? "unknown" },
730
+ });
731
+ }
732
+ }
733
+ export async function abortNativeResearchStage(input) {
734
+ return withWorkspaceLock(input.root, "research.native-stage.abort", async () => {
735
+ const session = await readNativeStageSession(input.root, input.projectId);
736
+ if (session.packet.sessionId !== input.sessionId) {
737
+ throw new CliError("Native stage session ID does not match the active session.", {
738
+ code: "RESEARCH_NATIVE_STAGE_SESSION_MISMATCH",
739
+ exitCode: 3,
740
+ });
741
+ }
742
+ const project = await loadProject(input.root, input.projectId);
743
+ const workPackage = packageById(project, session.packet.packageId);
744
+ if (workPackage.status !== "running") {
745
+ throw new CliError("The native stage package is not running.", {
746
+ code: "RESEARCH_NATIVE_STAGE_SESSION_MISMATCH",
747
+ exitCode: 3,
748
+ });
749
+ }
750
+ workPackage.status = workPackage.attempts < workPackage.maxAttempts ? "retry" : "failed";
751
+ workPackage.completedAt = new Date().toISOString();
752
+ workPackage.lastError = "Native stage was explicitly aborted before submission.";
753
+ workPackage.lastFailureKind = "deterministic";
754
+ workPackage.retryNotBefore = null;
755
+ refreshProject(project);
756
+ await saveProject(input.root, project);
757
+ await appendJournalEvent(workspacePaths(input.root).journal, "native.stage.aborted", project.id, {
758
+ sessionId: session.packet.sessionId,
759
+ packetSha256: session.packet.packetSha256,
760
+ projectId: project.id,
761
+ packageId: workPackage.id,
762
+ stage: workPackage.stage,
763
+ });
764
+ await rm(nativeStageSessionPath(input.root, project.id), { force: true });
765
+ await rm(session.capsuleRoot, { recursive: true, force: true });
766
+ return {
767
+ projectId: project.id,
768
+ packageId: workPackage.id,
769
+ status: project.status === "blocked" ? "blocked" : "ready",
770
+ };
771
+ });
772
+ }
773
+ export async function requestResearchHandoff(input) {
774
+ return withWorkspaceLock(input.root, "research.handoff.request", async () => {
775
+ const value = parseHandoffRequest(input.value);
776
+ const project = refreshProject(await loadProject(input.root, input.projectId));
777
+ if (project.lineage.supersededBy ||
778
+ ["complete", "stale", "archived", "abandoned"].includes(project.status)) {
779
+ throw new CliError("Historical or closed projects require an immutable addendum, not an in-place handoff.", { code: "RESEARCH_PROJECT_HANDOFF_INVALID", exitCode: 3 });
780
+ }
781
+ if (project.handoff.state !== "agent-actionable") {
782
+ throw new CliError("This project already has an unresolved handoff.", {
783
+ code: "RESEARCH_PROJECT_HANDOFF_REQUIRED",
784
+ exitCode: 3,
785
+ details: { state: project.handoff.state },
786
+ });
787
+ }
788
+ const activePath = nativeStageSessionPath(input.root, input.projectId);
789
+ const session = (await pathExists(activePath))
790
+ ? await readNativeStageSession(input.root, input.projectId)
791
+ : null;
792
+ if (session) {
793
+ const workPackage = packageById(project, session.packet.packageId);
794
+ if (workPackage.status !== "running") {
795
+ throw new CliError("Active native session is not bound to a running package.", {
796
+ code: "RESEARCH_NATIVE_STAGE_SESSION_MISMATCH",
797
+ exitCode: 3,
798
+ });
799
+ }
800
+ workPackage.status = "ready";
801
+ workPackage.attempts = Math.max(0, workPackage.attempts - 1);
802
+ workPackage.startedAt = null;
803
+ workPackage.completedAt = null;
804
+ workPackage.lastError = null;
805
+ workPackage.lastFailureKind = null;
806
+ workPackage.retryNotBefore = null;
807
+ }
808
+ const requestedAt = new Date().toISOString();
809
+ project.handoff = {
810
+ state: value.state,
811
+ reasonCode: value.reasonCode,
812
+ summary: value.summary,
813
+ requestedActions: value.requestedActions,
814
+ evidenceGaps: value.evidenceGaps,
815
+ requestedAt,
816
+ resolvedAt: null,
817
+ resolutionNote: null,
818
+ };
819
+ refreshProject(project);
820
+ await saveProject(input.root, project);
821
+ const eventPayload = {
822
+ state: value.state,
823
+ reasonCode: value.reasonCode,
824
+ summary: value.summary,
825
+ requestedActions: value.requestedActions,
826
+ evidenceGaps: value.evidenceGaps,
827
+ requestedAt,
828
+ interruptedSessionId: session?.packet.sessionId ?? null,
829
+ interruptedPackageId: session?.packet.packageId ?? null,
830
+ };
831
+ await appendEvidenceLedgerEvent(input.root, project.id, "handoff.requested", eventPayload);
832
+ await appendJournalEvent(workspacePaths(input.root).journal, "project.handoff.requested", project.id, { projectId: project.id, ...eventPayload });
833
+ if (session) {
834
+ await rm(activePath, { force: true });
835
+ await rm(session.capsuleRoot, { recursive: true, force: true });
836
+ }
837
+ return {
838
+ projectId: project.id,
839
+ status: project.status === "waiting-external" ? "waiting-external" : "waiting-user",
840
+ handoff: project.handoff,
841
+ resolveCommand: `tiangong-ai research project handoff resolve ${project.id} --note <resolution-note> --workspace ${input.root}`,
842
+ };
843
+ });
844
+ }
845
+ export async function resolveResearchHandoff(input) {
846
+ return withWorkspaceLock(input.root, "research.handoff.resolve", async () => {
847
+ const project = await loadProject(input.root, input.projectId);
848
+ if (project.handoff.state === "agent-actionable" || !project.handoff.requestedAt) {
849
+ throw new CliError("Project has no unresolved handoff.", {
850
+ code: "RESEARCH_PROJECT_HANDOFF_INVALID",
851
+ exitCode: 2,
852
+ });
853
+ }
854
+ const note = sanitizeResearchText(input.note, configuredResearchSecrets(process.env)).trim();
855
+ if (note.length < 8 || note.length > 1_000) {
856
+ throw new CliError("Handoff resolution note must contain 8-1000 safe characters.", {
857
+ code: "RESEARCH_PROJECT_HANDOFF_INVALID",
858
+ exitCode: 2,
859
+ });
860
+ }
861
+ const previousState = project.handoff.state;
862
+ const resolvedAt = new Date().toISOString();
863
+ project.handoff = {
864
+ ...project.handoff,
865
+ state: "agent-actionable",
866
+ resolvedAt,
867
+ resolutionNote: note,
868
+ };
869
+ refreshProject(project);
870
+ await saveProject(input.root, project);
871
+ const eventPayload = {
872
+ previousState,
873
+ reasonCode: project.handoff.reasonCode,
874
+ requestedAt: project.handoff.requestedAt,
875
+ resolvedAt,
876
+ resolutionNote: note,
877
+ };
878
+ await appendEvidenceLedgerEvent(input.root, project.id, "handoff.resolved", eventPayload);
879
+ await appendJournalEvent(workspacePaths(input.root).journal, "project.handoff.resolved", project.id, { projectId: project.id, ...eventPayload });
880
+ return { projectId: project.id, status: project.status, handoff: project.handoff };
881
+ });
882
+ }
883
+ function parseHandoffRequest(value) {
884
+ const sanitized = sanitizeResearchRecord(value, configuredResearchSecrets(process.env));
885
+ const allowed = new Set([
886
+ "schemaVersion",
887
+ "state",
888
+ "reasonCode",
889
+ "summary",
890
+ "requestedActions",
891
+ "evidenceGaps",
892
+ ]);
893
+ const requestedActions = Array.isArray(sanitized.requestedActions)
894
+ ? sanitized.requestedActions
895
+ : [];
896
+ const evidenceGaps = Array.isArray(sanitized.evidenceGaps) ? sanitized.evidenceGaps : [];
897
+ if (Object.keys(sanitized).some((key) => !allowed.has(key)) ||
898
+ sanitized.schemaVersion !== 1 ||
899
+ !["user-action-required", "external-response-required"].includes(String(sanitized.state)) ||
900
+ typeof sanitized.reasonCode !== "string" ||
901
+ !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(sanitized.reasonCode) ||
902
+ typeof sanitized.summary !== "string" ||
903
+ sanitized.summary.trim().length < 8 ||
904
+ sanitized.summary.length > 1_000 ||
905
+ requestedActions.length < 1 ||
906
+ requestedActions.length > 10 ||
907
+ requestedActions.some((item) => typeof item !== "string" || item.length < 1 || item.length > 500) ||
908
+ new Set(requestedActions).size !== requestedActions.length ||
909
+ evidenceGaps.length < 1 ||
910
+ evidenceGaps.length > 50 ||
911
+ evidenceGaps.some((item) => typeof item !== "string" || item.length < 1 || item.length > 500) ||
912
+ new Set(evidenceGaps).size !== evidenceGaps.length) {
913
+ throw new CliError("Handoff request failed validation.", {
914
+ code: "RESEARCH_PROJECT_HANDOFF_INVALID",
915
+ exitCode: 2,
916
+ });
917
+ }
918
+ return {
919
+ state: sanitized.state,
920
+ reasonCode: sanitized.reasonCode,
921
+ summary: sanitized.summary.trim(),
922
+ requestedActions: requestedActions,
923
+ evidenceGaps: evidenceGaps,
924
+ };
925
+ }
81
926
  async function executeWorkPackage(root, projectId, packageId, config, options, requestId, packageExecutor, doctorAttestation) {
82
927
  const project = await loadProject(root, projectId);
83
928
  const workPackage = packageById(project, packageId);
@@ -114,7 +959,14 @@ async function executeWorkPackage(root, projectId, packageId, config, options, r
114
959
  promotedOutputs = await outputRecords(root, project, workPackage.expectedOutputs);
115
960
  }
116
961
  else {
117
- const reservation = reservePackageBudget(project, workPackage, config);
962
+ if (["analyze", "synthesize", "review"].includes(workPackage.stage)) {
963
+ await loadCurrentEvidenceSnapshot(root, project.id);
964
+ }
965
+ const discovery = workPackage.stage === "discover"
966
+ ? await inspectDiscoveryProgress(root, project, config)
967
+ : null;
968
+ const stageOutputTokens = discovery?.plan.outputTokenLimit ?? config.budget.maxOutputTokens;
969
+ const reservation = reservePackageBudget(project, workPackage, config, discovery?.plan.reservedDiscoverTokens);
118
970
  const capsule = await createCapsule(root, project, workPackage, runId, config);
119
971
  capsuleRoot = capsule.capsuleRoot;
120
972
  if (capsule.reviewPacketRecord) {
@@ -126,7 +978,7 @@ async function executeWorkPackage(root, projectId, packageId, config, options, r
126
978
  packet: capsule.reviewPacketRecord,
127
979
  });
128
980
  }
129
- const stageContextContent = await stageContextForPackage(capsule.projectRoot, workPackage, config);
981
+ const stageContextContent = await stageContextForPackage(capsule.projectRoot, project, workPackage, config);
130
982
  const route = workPackage.executor === "reviewer" ? config.reviewer : config.producer;
131
983
  executor = route.agent;
132
984
  broker =
@@ -145,10 +997,11 @@ async function executeWorkPackage(root, projectId, packageId, config, options, r
145
997
  options,
146
998
  requestId,
147
999
  purpose: "primary",
148
- prompt: packagePrompt(project, workPackage, capsule.inputManifest, capsule.stagedSkills, capsule.capabilityDocumentation, capsule.reviewPacketSha256, capsule.contextBundle, capsule.contextBundleContent, stageContextContent, config.budget.maxBrokerCalls),
1000
+ prompt: packagePrompt(project, workPackage, capsule.inputManifest, capsule.stagedSkills, capsule.capabilityDocumentation, capsule.reviewPacketSha256, capsule.contextBundle, capsule.contextBundleContent, stageContextContent, discovery, await listEvidenceCandidates(root, project.id)),
149
1001
  brokerUrl: primaryBrokerUrl,
150
1002
  inputOnlyProvenance,
151
- maxOutputTokens: Math.min(config.budget.maxOutputTokens, reservation.tokens),
1003
+ maxOutputTokens: Math.min(stageOutputTokens, reservation.tokens),
1004
+ ...(discovery ? { brokerCallBudget: discovery.plan.maxCalls } : {}),
152
1005
  maxCostUsd: reservation.costUsd,
153
1006
  expectedRuntime: runtimeForRoute(doctorAttestation, route),
154
1007
  });
@@ -156,7 +1009,8 @@ async function executeWorkPackage(root, projectId, packageId, config, options, r
156
1009
  result = await withHeartbeat(packageExecutor(primaryRequest), options, requestId, project, workPackage, config);
157
1010
  accountedResult = result;
158
1011
  assertExecutorSucceeded(result);
159
- assertActualPackageBudget(project, workPackage, config, result, config.budget.maxOutputTokens);
1012
+ assertActualPackageBudget(project, workPackage, config, result, stageOutputTokens);
1013
+ let acceptedRaw = result.stdout;
160
1014
  try {
161
1015
  await materializeAndValidateStageOutput(root, project, capsule.projectRoot, workPackage, result.stdout, capsule.reviewPacketSha256);
162
1016
  }
@@ -189,14 +1043,23 @@ async function executeWorkPackage(root, projectId, packageId, config, options, r
189
1043
  const repair = await withHeartbeat(packageExecutor(repairRequest), options, requestId, project, workPackage, config);
190
1044
  accountedResult = combineExecutionResults(result, repair);
191
1045
  assertExecutorSucceeded(repair);
192
- assertActualPackageBudget(project, workPackage, config, accountedResult, config.budget.maxOutputTokens + config.budget.maxRepairTokens);
1046
+ assertActualPackageBudget(project, workPackage, config, accountedResult, stageOutputTokens + config.budget.maxRepairTokens);
1047
+ acceptedRaw = repair.stdout;
193
1048
  await materializeAndValidateStageOutput(root, project, capsule.projectRoot, workPackage, repair.stdout, capsule.reviewPacketSha256);
194
1049
  }
195
1050
  assertProjectedBudget(project, config, accountedResult);
196
1051
  promotedOutputs = await validateAndImportOutputs(root, project, workPackage, capsule.projectRoot, config, capsule.reviewPacketSha256);
197
1052
  if (workPackage.stage === "discover") {
198
- await assertEvidenceCoverage(root, project);
1053
+ await assertDiscoveryCoverage(root, project);
1054
+ await commitDiscoveryDecisions(root, project.id, parseStructuredStageOutput("discover", acceptedRaw).value);
1055
+ }
1056
+ if (workPackage.stage === "acquire") {
1057
+ const audit = parseMaterializedAcquisitionAudit(JSON.parse(await readFile(join(projectRoot(root, project.id), "outputs", "acquisition.json"), "utf8")));
1058
+ await commitAcquisitionAssessments(root, project.id, audit);
1059
+ await freezeEvidenceSnapshot(root, project);
1060
+ promotedOutputs.push(await fileRecord(join(projectRoot(root, project.id), "outputs", "evidence-snapshot.json"), "outputs/evidence-snapshot.json"));
199
1061
  }
1062
+ await commitStageEvidenceBindings(root, project, workPackage);
200
1063
  }
201
1064
  const completedAt = new Date().toISOString();
202
1065
  applyUsage(project, accountedResult);
@@ -402,6 +1265,16 @@ async function createCapsule(root, project, workPackage, runId, config) {
402
1265
  await writeTextAtomic(contextBundlePath, contextBundleContent);
403
1266
  const contextBundle = await fileRecord(contextBundlePath, "inputs/context-bundle.txt");
404
1267
  const evidenceReceipts = await stageProjectEvidence(root, project.id, capsuleProject);
1268
+ const frozenSnapshot = ["analyze", "synthesize", "review"].includes(workPackage.stage)
1269
+ ? await loadCurrentEvidenceSnapshot(root, project.id)
1270
+ : null;
1271
+ const evidenceArtifacts = frozenSnapshot
1272
+ ? await stageEvidenceArtifacts(root, project.id, capsuleProject, new Set(frozenSnapshot.artifacts.map((artifact) => artifact.artifactId)))
1273
+ : [];
1274
+ if ((workPackage.stage === "analyze" || workPackage.stage === "synthesize") &&
1275
+ evidenceArtifacts.length) {
1276
+ await writeProducerArtifactContext(capsuleProject, evidenceArtifacts, Math.floor((config.budget.maxInputContextTokens * RESEARCH_ESTIMATED_BYTES_PER_TOKEN) / 2));
1277
+ }
405
1278
  await writeJsonAtomic(join(capsuleProject, "inputs", "evidence-receipts.json"), evidenceReceipts.map(reviewSafeReceipt));
406
1279
  await writeJsonAtomic(join(capsuleProject, "project.json"), {
407
1280
  ...project,
@@ -414,10 +1287,10 @@ async function createCapsule(root, project, workPackage, runId, config) {
414
1287
  const stagedSkills = await stageLockedCapabilities(root, join(capsuleProject, "skills"));
415
1288
  const capabilityDocumentation = await buildCapabilityDocumentation(capsuleProject, stagedSkills, config.budget.maxInputContextTokens * RESEARCH_ESTIMATED_BYTES_PER_TOKEN);
416
1289
  const reviewEvidenceContext = workPackage.stage === "review"
417
- ? await writeReviewEvidenceContext(root, project.id, capsuleProject, contextBundleContent, evidenceReceipts, config.budget.maxInputContextTokens * RESEARCH_ESTIMATED_BYTES_PER_TOKEN)
1290
+ ? await writeReviewEvidenceContext(root, project.id, capsuleProject, contextBundleContent, evidenceReceipts, evidenceArtifacts, config.budget.maxInputContextTokens * RESEARCH_ESTIMATED_BYTES_PER_TOKEN)
418
1291
  : null;
419
1292
  const reviewPacket = reviewEvidenceContext
420
- ? await writeReviewPacket(root, capsuleProject, project, inputManifest, evidenceReceipts, reviewEvidenceContext.persistent)
1293
+ ? await writeReviewPacket(root, capsuleProject, project, inputManifest, evidenceReceipts, evidenceArtifacts, reviewEvidenceContext.persistent)
421
1294
  : null;
422
1295
  return {
423
1296
  capsuleRoot,
@@ -475,8 +1348,21 @@ async function buildInputContextBundle(capsuleProject, inputManifest) {
475
1348
  }
476
1349
  return `${sections.join("\n\n")}\n`;
477
1350
  }
478
- async function writeReviewPacket(root, capsuleProject, project, inputManifest, evidenceReceipts, reviewEvidenceContext) {
479
- const artifactPaths = ["outputs/evidence.json", "outputs/analysis.json", "outputs/report.md"];
1351
+ async function writeReviewPacket(root, capsuleProject, project, inputManifest, evidenceReceipts, evidenceArtifacts, reviewEvidenceContext) {
1352
+ const snapshot = await loadCurrentEvidenceSnapshot(root, project.id);
1353
+ const immutableSnapshots = await loadImmutableEvidenceSnapshotChain(root, project.id, snapshot.snapshotSha256);
1354
+ const snapshotChain = await Promise.all(immutableSnapshots.map((immutableSnapshot) => {
1355
+ const sha256 = immutableSnapshot.snapshotSha256;
1356
+ const logicalPath = `evidence/snapshots/${sha256}.json`;
1357
+ return fileRecord(resolveContained(projectRoot(root, project.id), logicalPath), logicalPath);
1358
+ }));
1359
+ const artifactPaths = [
1360
+ "outputs/evidence.json",
1361
+ "outputs/acquisition.json",
1362
+ "outputs/evidence-snapshot.json",
1363
+ "outputs/analysis.json",
1364
+ "outputs/report.md",
1365
+ ];
480
1366
  const evidenceFiles = new Map();
481
1367
  for (const receipt of evidenceReceipts) {
482
1368
  for (const locator of [receipt.locator, receipt.contextLocator]) {
@@ -485,6 +1371,11 @@ async function writeReviewPacket(root, capsuleProject, project, inputManifest, e
485
1371
  }
486
1372
  }
487
1373
  }
1374
+ for (const artifact of evidenceArtifacts) {
1375
+ if (!evidenceFiles.has(artifact.locator)) {
1376
+ evidenceFiles.set(artifact.locator, await fileRecord(resolveContained(capsuleProject, artifact.locator), artifact.locator));
1377
+ }
1378
+ }
488
1379
  const environment = await reviewEnvironmentPacket(root, project.id);
489
1380
  const inputFiles = new Map();
490
1381
  for (const input of inputManifest) {
@@ -500,10 +1391,18 @@ async function writeReviewPacket(root, capsuleProject, project, inputManifest, e
500
1391
  projectId: project.id,
501
1392
  questionSha256: sha256Text(project.question),
502
1393
  evidenceRequirements: project.evidenceRequirements,
1394
+ evidenceSnapshot: {
1395
+ snapshotId: snapshot.snapshotId,
1396
+ snapshotSha256: snapshot.snapshotSha256,
1397
+ parentSnapshotId: snapshot.parentSnapshotId,
1398
+ parentSnapshotSha256: snapshot.parentSnapshotSha256,
1399
+ },
1400
+ snapshotChain,
503
1401
  inputs: inputManifest,
504
1402
  reviewEvidenceContext,
505
1403
  inputFiles: [...inputFiles.values()].sort((left, right) => left.path.localeCompare(right.path)),
506
1404
  evidenceReceipts: evidenceReceipts.map(reviewSafeReceipt),
1405
+ evidenceArtifacts,
507
1406
  evidenceFiles: [...evidenceFiles.values()].sort((left, right) => left.path.localeCompare(right.path)),
508
1407
  environment,
509
1408
  environmentFile: await fileRecord(join(capsuleProject, "inputs", "runtime-fingerprint.json"), "inputs/runtime-fingerprint.json"),
@@ -518,27 +1417,42 @@ async function writeReviewPacket(root, capsuleProject, project, inputManifest, e
518
1417
  const record = await persistReviewPacket(root, project.id, completePacket, packetSha256);
519
1418
  return { sha256: packetSha256, record };
520
1419
  }
521
- async function writeReviewEvidenceContext(root, projectId, capsuleProject, inputContextBundle, evidenceReceipts, maxBytes) {
1420
+ async function writeReviewEvidenceContext(root, projectId, capsuleProject, inputContextBundle, evidenceReceipts, evidenceArtifacts, maxBytes) {
522
1421
  const header = [
523
1422
  "TIANGONG REVIEW EVIDENCE CONTEXT v1",
524
1423
  "The following are deterministic excerpts from hash-verified bounded views. Full objects and original bounded contexts remain bound in the review packet.",
525
1424
  ].join("\n");
1425
+ const brokerReferences = await loadBrokerReviewReferences(capsuleProject);
526
1426
  const views = [
527
1427
  {
528
1428
  prefix: "--- LOCAL INPUT CONTEXT BUNDLE ---\n--- BEGIN BOUNDED REVIEW EXCERPT ---\n",
529
- content: inputContextBundle.trimEnd(),
1429
+ content: sanitizeResearchText(inputContextBundle.trimEnd()),
530
1430
  suffix: "\n--- END BOUNDED REVIEW EXCERPT ---",
1431
+ active: true,
531
1432
  },
532
1433
  ];
533
- const seen = new Set();
534
- for (const receipt of [...evidenceReceipts].sort((left, right) => left.attemptId.localeCompare(right.attemptId))) {
535
- if (seen.has(receipt.contextLocator))
536
- continue;
537
- seen.add(receipt.contextLocator);
1434
+ const seenUnreferencedContexts = new Set();
1435
+ const orderedReceipts = [...evidenceReceipts].sort((left, right) => {
1436
+ const leftReferenced = (brokerReferences.get(left.attemptId)?.length ?? 0) > 0;
1437
+ const rightReferenced = (brokerReferences.get(right.attemptId)?.length ?? 0) > 0;
1438
+ if (leftReferenced !== rightReferenced)
1439
+ return leftReferenced ? -1 : 1;
1440
+ return left.attemptId.localeCompare(right.attemptId);
1441
+ });
1442
+ for (const receipt of orderedReceipts) {
538
1443
  const metadata = reviewSafeReceipt(receipt);
539
- const content = reviewableTextContentType(receipt.contentType)
540
- ? await readFile(resolveContained(capsuleProject, receipt.contextLocator), "utf8")
541
- : "[Binary bounded view omitted from model context; verify the bound file mechanically.]";
1444
+ const references = brokerReferences.get(receipt.attemptId) ?? [];
1445
+ // Every admitted receipt gets its own exact projection even when another
1446
+ // request produced identical bounded bytes. Only uncited duplicate views
1447
+ // may be collapsed; otherwise random receipt UUID ordering can hide the
1448
+ // one receipt actually cited by evidence.json.
1449
+ if (references.length === 0 && seenUnreferencedContexts.has(receipt.contextLocator))
1450
+ continue;
1451
+ if (references.length === 0)
1452
+ seenUnreferencedContexts.add(receipt.contextLocator);
1453
+ const content = references.length
1454
+ ? await citedBrokerReviewContent(capsuleProject, receipt, references)
1455
+ : "[No admitted evidence source cites this receipt; its raw object and bounded context remain hash-bound in the review packet.]";
542
1456
  views.push({
543
1457
  prefix: [
544
1458
  `--- BROKER RECEIPT ${receipt.attemptId} ---`,
@@ -548,9 +1462,40 @@ async function writeReviewEvidenceContext(root, projectId, capsuleProject, input
548
1462
  ].join("\n"),
549
1463
  content: content.trimEnd(),
550
1464
  suffix: "\n--- END BOUNDED REVIEW EXCERPT ---",
1465
+ active: references.length > 0,
551
1466
  });
552
1467
  }
553
- const fixedContent = [header, ...views.map((view) => `${view.prefix}${view.suffix}`)].join("\n\n");
1468
+ for (const artifact of [...evidenceArtifacts].sort((left, right) => left.artifactId.localeCompare(right.artifactId))) {
1469
+ const textArtifact = reviewableTextContentType(artifact.mediaType);
1470
+ const content = textArtifact
1471
+ ? sanitizeResearchText((await readFile(resolveContained(capsuleProject, artifact.locator), "utf8")).trimEnd())
1472
+ : "[Binary artifact omitted from model context; the complete structurally validated file remains hash-bound in the persistent review packet.]";
1473
+ const safeMetadata = {
1474
+ artifactId: artifact.artifactId,
1475
+ candidateId: artifact.candidateId,
1476
+ sha256: artifact.sha256,
1477
+ bytes: artifact.bytes,
1478
+ mediaType: artifact.mediaType,
1479
+ originalFilename: artifact.originalFilename,
1480
+ locator: artifact.locator,
1481
+ validation: artifact.validation,
1482
+ };
1483
+ views.push({
1484
+ prefix: [
1485
+ `--- FROZEN EVIDENCE ARTIFACT ${artifact.artifactId} ---`,
1486
+ `metadata: ${JSON.stringify(safeMetadata)}`,
1487
+ "--- BEGIN BOUNDED REVIEW EXCERPT ---",
1488
+ "",
1489
+ ].join("\n"),
1490
+ content,
1491
+ suffix: "\n--- END BOUNDED REVIEW EXCERPT ---",
1492
+ active: textArtifact,
1493
+ });
1494
+ }
1495
+ const fixedContent = [
1496
+ header,
1497
+ ...views.map((view) => `${view.prefix}${view.active ? "" : view.content}${view.suffix}`),
1498
+ ].join("\n\n");
554
1499
  const fixedBytes = Buffer.byteLength(`${fixedContent}\n`, "utf8");
555
1500
  if (fixedBytes > maxBytes) {
556
1501
  throw new CliError("Review evidence metadata exceeds the configured context budget.", {
@@ -559,10 +1504,11 @@ async function writeReviewEvidenceContext(root, projectId, capsuleProject, input
559
1504
  details: { fixedBytes, maxBytes, views: views.length },
560
1505
  });
561
1506
  }
562
- const contentBudgetPerView = Math.floor((maxBytes - fixedBytes) / views.length);
1507
+ const activeViews = views.filter((view) => view.active).length;
1508
+ const contentBudgetPerView = activeViews ? Math.floor((maxBytes - fixedBytes) / activeViews) : 0;
563
1509
  const sections = [
564
1510
  header,
565
- ...views.map((view) => `${view.prefix}${boundedUtf8ReviewExcerpt(view.content, contentBudgetPerView)}${view.suffix}`),
1511
+ ...views.map((view) => `${view.prefix}${view.active ? boundedUtf8ReviewExcerpt(view.content, contentBudgetPerView) : view.content}${view.suffix}`),
566
1512
  ];
567
1513
  const logicalPath = "inputs/review-evidence-context.txt";
568
1514
  const path = resolveContained(capsuleProject, logicalPath);
@@ -597,8 +1543,155 @@ async function writeReviewEvidenceContext(root, projectId, capsuleProject, input
597
1543
  persistent: await fileRecord(persistentPath, persistentLogicalPath),
598
1544
  };
599
1545
  }
1546
+ async function writeProducerArtifactContext(capsuleProject, evidenceArtifacts, maxBytes) {
1547
+ const header = [
1548
+ "TIANGONG PRODUCER ARTIFACT CONTEXT v1",
1549
+ "Only deterministic sanitized excerpts from producer-visible text artifacts are included. Binary files remain hash-bound but are not treated as read full text.",
1550
+ ].join("\n");
1551
+ const views = await Promise.all([...evidenceArtifacts]
1552
+ .sort((left, right) => left.artifactId.localeCompare(right.artifactId))
1553
+ .map(async (artifact) => {
1554
+ const active = reviewableTextContentType(artifact.mediaType);
1555
+ return {
1556
+ prefix: [
1557
+ `--- FROZEN ARTIFACT ${artifact.artifactId} ---`,
1558
+ `candidateId: ${artifact.candidateId}`,
1559
+ `sha256: ${artifact.sha256}`,
1560
+ `mediaType: ${artifact.mediaType}`,
1561
+ "--- BEGIN BOUNDED ARTIFACT CONTEXT ---",
1562
+ "",
1563
+ ].join("\n"),
1564
+ content: active
1565
+ ? sanitizeResearchText((await readFile(resolveContained(capsuleProject, artifact.locator), "utf8")).trimEnd())
1566
+ : "[Binary artifact is hash-bound but omitted from producer context. It is not counted as producer-visible full text.]",
1567
+ suffix: "\n--- END BOUNDED ARTIFACT CONTEXT ---",
1568
+ active,
1569
+ };
1570
+ }));
1571
+ const fixed = `${header}\n\n${views
1572
+ .map((view) => `${view.prefix}${view.active ? "" : view.content}${view.suffix}`)
1573
+ .join("\n\n")}\n`;
1574
+ const fixedBytes = Buffer.byteLength(fixed, "utf8");
1575
+ if (fixedBytes > maxBytes) {
1576
+ throw new CliError("Producer artifact metadata exceeds the configured context budget.", {
1577
+ code: "RESEARCH_INPUT_CONTEXT_BUDGET_EXCEEDED",
1578
+ exitCode: 3,
1579
+ details: { fixedBytes, maxBytes, artifacts: views.length },
1580
+ });
1581
+ }
1582
+ const activeViews = views.filter((view) => view.active).length;
1583
+ const perView = activeViews ? Math.floor((maxBytes - fixedBytes) / activeViews) : 0;
1584
+ const content = `${header}\n\n${views
1585
+ .map((view) => `${view.prefix}${view.active ? boundedUtf8ReviewExcerpt(view.content, perView) : view.content}${view.suffix}`)
1586
+ .join("\n\n")}\n`;
1587
+ const logicalPath = "inputs/evidence-artifact-context.txt";
1588
+ const path = resolveContained(capsuleProject, logicalPath);
1589
+ await writeTextAtomic(path, content);
1590
+ return fileRecord(path, logicalPath);
1591
+ }
1592
+ async function loadBrokerReviewReferences(capsuleProject) {
1593
+ const evidencePath = resolveContained(capsuleProject, "outputs/evidence.json");
1594
+ if (!(await pathExists(evidencePath)))
1595
+ return new Map();
1596
+ const evidence = await readJsonFile(evidencePath, "Research evidence");
1597
+ const references = new Map();
1598
+ if (!Array.isArray(evidence.sources))
1599
+ return references;
1600
+ for (const source of evidence.sources) {
1601
+ if (!isObject(source) || !isObject(source.provenance))
1602
+ continue;
1603
+ if (source.provenance.kind !== "broker" || typeof source.provenance.id !== "string")
1604
+ continue;
1605
+ if (typeof source.id !== "string" || typeof source.title !== "string")
1606
+ continue;
1607
+ const current = references.get(source.provenance.id) ?? [];
1608
+ current.push({
1609
+ sourceId: source.id,
1610
+ title: source.title,
1611
+ jsonPointer: typeof source.jsonPointer === "string" ? source.jsonPointer : null,
1612
+ excerpt: typeof source.excerpt === "string" ? source.excerpt : null,
1613
+ });
1614
+ references.set(source.provenance.id, current);
1615
+ }
1616
+ for (const values of references.values()) {
1617
+ values.sort((left, right) => left.sourceId.localeCompare(right.sourceId));
1618
+ }
1619
+ return references;
1620
+ }
1621
+ async function citedBrokerReviewContent(capsuleProject, receipt, references) {
1622
+ let rawValue;
1623
+ if (jsonReviewContentType(receipt.contentType)) {
1624
+ try {
1625
+ rawValue = JSON.parse(await readFile(resolveContained(capsuleProject, receipt.locator), "utf8"));
1626
+ }
1627
+ catch {
1628
+ rawValue = undefined;
1629
+ }
1630
+ }
1631
+ let needsFallback = false;
1632
+ const sections = [
1633
+ "The following items are deterministic, sanitized projections selected from the hash-bound raw broker object by the JSON Pointers declared in admitted evidence.",
1634
+ ];
1635
+ for (const reference of references) {
1636
+ const lines = [
1637
+ `--- CITED EVIDENCE SOURCE ${reference.sourceId} ---`,
1638
+ `title: ${reference.title}`,
1639
+ `jsonPointer: ${reference.jsonPointer ?? "unavailable"}`,
1640
+ ];
1641
+ if (rawValue !== undefined && reference.jsonPointer !== null) {
1642
+ try {
1643
+ const selected = resolveReviewJsonPointer(rawValue, reference.jsonPointer);
1644
+ lines.push("exactItem:", JSON.stringify(selected, null, 2));
1645
+ }
1646
+ catch {
1647
+ needsFallback = true;
1648
+ lines.push("exactItem: [JSON Pointer did not resolve; bounded-context fallback follows.] ");
1649
+ }
1650
+ }
1651
+ else {
1652
+ needsFallback = true;
1653
+ lines.push("exactItem: [Exact JSON item unavailable; bounded-context fallback follows.] ");
1654
+ }
1655
+ if (reference.excerpt !== null)
1656
+ lines.push(`declaredExcerpt: ${reference.excerpt}`);
1657
+ sections.push(lines.join("\n"));
1658
+ }
1659
+ if (needsFallback) {
1660
+ const fallback = reviewableTextContentType(receipt.contentType)
1661
+ ? await readFile(resolveContained(capsuleProject, receipt.contextLocator), "utf8")
1662
+ : "[Binary bounded view omitted from model context; verify the bound file mechanically.]";
1663
+ sections.push(`--- BOUNDED CONTEXT FALLBACK ---\n${fallback.trimEnd()}`);
1664
+ }
1665
+ return sanitizeResearchText(sections.join("\n\n"));
1666
+ }
1667
+ function jsonReviewContentType(contentType) {
1668
+ return /^application\/(?:[^;]+\+)?json(?:;|$)/i.test(contentType);
1669
+ }
1670
+ function resolveReviewJsonPointer(value, pointer) {
1671
+ if (pointer === "")
1672
+ return value;
1673
+ if (!pointer.startsWith("/") || /~(?:[^01]|$)/.test(pointer)) {
1674
+ throw new Error("invalid JSON Pointer");
1675
+ }
1676
+ let selected = value;
1677
+ for (const rawPart of pointer.slice(1).split("/")) {
1678
+ const part = rawPart.replaceAll("~1", "/").replaceAll("~0", "~");
1679
+ if (Array.isArray(selected) &&
1680
+ /^(0|[1-9][0-9]*)$/.test(part) &&
1681
+ Number(part) < selected.length) {
1682
+ selected = selected[Number(part)];
1683
+ }
1684
+ else if (isObject(selected) && Object.hasOwn(selected, part)) {
1685
+ selected = selected[part];
1686
+ }
1687
+ else {
1688
+ throw new Error("JSON Pointer does not resolve");
1689
+ }
1690
+ }
1691
+ return selected;
1692
+ }
600
1693
  function reviewableTextContentType(contentType) {
601
- return /^(?:text\/|application\/(?:[^;]+\+)?(?:json|xml|javascript|xhtml\+xml|csv))(?:;|$)/i.test(contentType);
1694
+ return /^(?:text\/[^;]+|application\/(?:[^;]+\+)?(?:json|xml|javascript|xhtml\+xml|csv))(?:;|$)/i.test(contentType);
602
1695
  }
603
1696
  function boundedUtf8ReviewExcerpt(value, maxBytes) {
604
1697
  if (Buffer.byteLength(value, "utf8") <= maxBytes)
@@ -675,6 +1768,41 @@ async function loadVerifiedReviewPacket(root, projectId, packetSha256) {
675
1768
  exitCode: 3,
676
1769
  });
677
1770
  }
1771
+ const snapshotChain = packet.snapshotChain;
1772
+ if (!Array.isArray(snapshotChain)) {
1773
+ throw new CliError("Persistent review packet has no evidence snapshot chain.", {
1774
+ code: "RESEARCH_REVIEW_PACKET_DRIFT",
1775
+ exitCode: 3,
1776
+ });
1777
+ }
1778
+ for (const record of snapshotChain) {
1779
+ if (!isObject(record) ||
1780
+ typeof record.path !== "string" ||
1781
+ typeof record.sha256 !== "string" ||
1782
+ !Number.isInteger(record.bytes) ||
1783
+ !/^evidence\/snapshots\/[0-9a-f]{64}\.json$/.test(record.path)) {
1784
+ throw new CliError("Persistent review packet has an invalid snapshot-chain record.", {
1785
+ code: "RESEARCH_REVIEW_PACKET_DRIFT",
1786
+ exitCode: 3,
1787
+ });
1788
+ }
1789
+ let actual;
1790
+ try {
1791
+ actual = await fileRecord(resolveContained(projectRoot(root, projectId), record.path), record.path);
1792
+ }
1793
+ catch {
1794
+ throw new CliError("Persistent review snapshot chain is missing.", {
1795
+ code: "RESEARCH_REVIEW_PACKET_DRIFT",
1796
+ exitCode: 3,
1797
+ });
1798
+ }
1799
+ if (actual.sha256 !== record.sha256 || actual.bytes !== record.bytes) {
1800
+ throw new CliError("Persistent review snapshot chain failed hash verification.", {
1801
+ code: "RESEARCH_REVIEW_PACKET_DRIFT",
1802
+ exitCode: 3,
1803
+ });
1804
+ }
1805
+ }
678
1806
  return fileRecord(path, logicalPath);
679
1807
  }
680
1808
  function verifyReviewPacketValue(packet, packetSha256) {
@@ -783,7 +1911,8 @@ function agentRequest(input) {
783
1911
  maxTurns,
784
1912
  maxOutputTokens: input.maxOutputTokens,
785
1913
  maxToolContextTokens: input.brokerUrl
786
- ? input.config.budget.maxBrokerContextTokens * input.config.budget.maxBrokerCalls
1914
+ ? input.config.budget.maxBrokerContextTokens *
1915
+ (input.brokerCallBudget ?? input.config.budget.maxBrokerCalls)
787
1916
  : 0,
788
1917
  maxCostUsd: input.maxCostUsd,
789
1918
  expectedRuntime: input.expectedRuntime,
@@ -814,8 +1943,10 @@ async function materializeAndValidateStageOutput(root, project, capsuleProject,
814
1943
  const parsed = parseStructuredStageOutput(workPackage.stage, raw, reviewPacketSha256);
815
1944
  const destination = resolveContained(capsuleProject, workPackage.expectedOutputs[0]);
816
1945
  const fileContent = workPackage.stage === "discover"
817
- ? `${JSON.stringify(normalizeEvidenceCoverage(project, parsed.value), null, 2)}\n`
818
- : parsed.fileContent;
1946
+ ? `${JSON.stringify(normalizeEvidenceCoverage(project, await materializeDiscoveryEvidence(root, project, parsed.value)), null, 2)}\n`
1947
+ : workPackage.stage === "acquire"
1948
+ ? `${JSON.stringify(await materializeAcquisitionAudit(root, project, parsed.value), null, 2)}\n`
1949
+ : parsed.fileContent;
819
1950
  await writeTextAtomic(destination, fileContent);
820
1951
  await validateOutputShape(root, project, workPackage, destination, reviewPacketSha256);
821
1952
  if (parsed.normalizations.length > 0) {
@@ -854,12 +1985,20 @@ async function validateOutputShape(root, project, workPackage, path, reviewPacke
854
1985
  const content = await readFile(path, "utf8");
855
1986
  if (!content.trim())
856
1987
  throw deterministicError(`${workPackage.expectedOutputs[0]} is empty.`);
857
- if (workPackage.stage === "synthesize")
1988
+ if (workPackage.stage === "synthesize") {
1989
+ await validateSynthesisDocument(path, content);
858
1990
  return;
859
- const { value } = parseStructuredStageOutput(workPackage.stage, content, reviewPacketSha256);
1991
+ }
860
1992
  if (workPackage.stage === "discover") {
1993
+ const value = parseEvidenceRecord(content);
861
1994
  await validateEvidenceSources(root, project, value.sources);
1995
+ return;
862
1996
  }
1997
+ if (workPackage.stage === "acquire") {
1998
+ parseMaterializedAcquisitionAudit(JSON.parse(content));
1999
+ return;
2000
+ }
2001
+ const { value } = parseStructuredStageOutput(workPackage.stage, content, reviewPacketSha256);
863
2002
  if (workPackage.stage === "analyze") {
864
2003
  await validateFindings(path, value.findings);
865
2004
  }
@@ -933,10 +2072,10 @@ async function validateEvidenceSources(root, project, sources) {
933
2072
  }
934
2073
  }
935
2074
  async function validateFindings(path, findings) {
936
- const evidencePath = join(dirname(path), "evidence.json");
2075
+ const evidencePath = join(dirname(path), "evidence-snapshot.json");
937
2076
  const evidence = JSON.parse(await readFile(evidencePath, "utf8"));
938
2077
  if (!isObject(evidence) || !Array.isArray(evidence.sources)) {
939
- throw deterministicError("Analysis requires admitted evidence.json.");
2078
+ throw deterministicError("Analysis requires a frozen evidence-snapshot.json.");
940
2079
  }
941
2080
  const sourceIds = new Set(evidence.sources
942
2081
  .filter((source) => isObject(source))
@@ -959,6 +2098,105 @@ async function validateFindings(path, findings) {
959
2098
  findingIds.add(finding.id);
960
2099
  }
961
2100
  }
2101
+ async function validateSynthesisDocument(path, content) {
2102
+ const validation = [];
2103
+ if ([...content.matchAll(/`([^`\n]*)`/g)].some((match) => /https?:\/\//i.test(match[1] ?? ""))) {
2104
+ validation.push("URLs must be real Markdown links or bare links, not inline-code literals");
2105
+ }
2106
+ if (/%(?:60|0a|0d)|\\u0060|https?:\/\/[^\s<>"'`)\]]*`/i.test(content)) {
2107
+ validation.push("URLs contain encoded or literal backtick/newline contamination");
2108
+ }
2109
+ const markdownTargets = [];
2110
+ for (const match of content.matchAll(/!?\[[^\]\n]*\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g)) {
2111
+ if (match[1])
2112
+ markdownTargets.push(stripMarkdownDestination(match[1]));
2113
+ }
2114
+ const referenceIds = new Set();
2115
+ for (const match of content.matchAll(/^\s*\[([^\]\n]+)\]:\s*(\S+)/gm)) {
2116
+ const id = match[1].trim().toLowerCase().replace(/\s+/g, " ");
2117
+ if (referenceIds.has(id))
2118
+ validation.push(`duplicate Markdown reference ID: ${id}`);
2119
+ referenceIds.add(id);
2120
+ markdownTargets.push(stripMarkdownDestination(match[2]));
2121
+ }
2122
+ for (const target of markdownTargets) {
2123
+ await validateReportTarget(path, target, validation);
2124
+ }
2125
+ const urls = new Set([...content.matchAll(/https?:\/\/[^\s<>"'`)\]]+/gi)]
2126
+ .map((match) => stripTrailingUrlPunctuation(match[0]))
2127
+ .filter(Boolean));
2128
+ for (const value of urls)
2129
+ validateReportHttpsUrl(value, validation);
2130
+ if (validation.length) {
2131
+ throw new StructuredOutputError("Synthesis output failed mechanical link QA.", {
2132
+ validation: [...new Set(validation)].slice(0, 20),
2133
+ });
2134
+ }
2135
+ }
2136
+ async function validateReportTarget(reportPath, target, validation) {
2137
+ if (!target || target.startsWith("#"))
2138
+ return;
2139
+ if (/^https?:\/\//i.test(target)) {
2140
+ validateReportHttpsUrl(target, validation);
2141
+ return;
2142
+ }
2143
+ if (/^mailto:/i.test(target))
2144
+ return;
2145
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target)) {
2146
+ validation.push(`unsupported Markdown link scheme: ${target.split(":", 1)[0]}`);
2147
+ return;
2148
+ }
2149
+ let decoded;
2150
+ try {
2151
+ decoded = decodeURIComponent(target.split(/[?#]/, 1)[0] ?? "");
2152
+ }
2153
+ catch {
2154
+ validation.push("Markdown link contains invalid percent encoding");
2155
+ return;
2156
+ }
2157
+ if (!decoded || isAbsolute(decoded)) {
2158
+ validation.push("Markdown local links must be relative files inside the research capsule");
2159
+ return;
2160
+ }
2161
+ const capsuleProject = dirname(dirname(reportPath));
2162
+ const selected = resolve(dirname(reportPath), decoded);
2163
+ if (relative(capsuleProject, selected).startsWith("..")) {
2164
+ validation.push("Markdown local link escapes the research capsule");
2165
+ return;
2166
+ }
2167
+ const info = await lstat(selected).catch(() => undefined);
2168
+ if (!info?.isFile() || info.isSymbolicLink()) {
2169
+ validation.push(`Markdown local link does not resolve to a regular file: ${decoded}`);
2170
+ }
2171
+ }
2172
+ function validateReportHttpsUrl(value, validation) {
2173
+ if (/%(?:60|0a|0d)|`/i.test(value)) {
2174
+ validation.push("URL contains encoded or literal backtick/newline contamination");
2175
+ return;
2176
+ }
2177
+ let url;
2178
+ try {
2179
+ url = new URL(value);
2180
+ }
2181
+ catch {
2182
+ validation.push("report contains an invalid URL");
2183
+ return;
2184
+ }
2185
+ if (url.protocol !== "https:")
2186
+ validation.push(`report URL must use HTTPS: ${url.host}`);
2187
+ if (url.username || url.password)
2188
+ validation.push("report URL contains embedded credentials");
2189
+ const sensitive = /^(access_token|api[_-]?key|apikey|auth|authorization|code|cookie|key|password|secret|session|sig|signature|token)$/i;
2190
+ if ([...url.searchParams.keys()].some((key) => sensitive.test(key))) {
2191
+ validation.push("report URL contains sensitive query parameters");
2192
+ }
2193
+ }
2194
+ function stripMarkdownDestination(value) {
2195
+ return value.startsWith("<") && value.endsWith(">") ? value.slice(1, -1) : value;
2196
+ }
2197
+ function stripTrailingUrlPunctuation(value) {
2198
+ return value.replace(/[),.;!?`]+$/g, "");
2199
+ }
962
2200
  function normalizeEvidenceCoverage(project, value) {
963
2201
  const inputIds = new Set(project.inputs.map((input) => input.id));
964
2202
  const sources = (value.sources ?? []).map((source) => {
@@ -1051,13 +2289,16 @@ function computeEvidenceCoverage(project, sources, declared) {
1051
2289
  mechanicalGaps: gaps,
1052
2290
  };
1053
2291
  }
1054
- async function assertEvidenceCoverage(root, project) {
1055
- const path = resolveContained(projectRoot(root, project.id), "outputs/evidence.json");
2292
+ async function assertDiscoveryCoverage(root, project, evidencePath) {
2293
+ const path = evidencePath ?? resolveContained(projectRoot(root, project.id), "outputs/evidence.json");
1056
2294
  const value = JSON.parse(await readFile(path, "utf8"));
1057
2295
  const sources = value.sources;
1058
2296
  const declared = value.coverage;
1059
2297
  const computed = computeEvidenceCoverage(project, sources, declared);
1060
- const gaps = [...computed.mechanicalGaps];
2298
+ // Full-text acquisition is intentionally the next phase. Discovery must
2299
+ // establish breadth, dates, source types and dimensions, but cannot claim
2300
+ // that a search-result receipt is already acquired full text.
2301
+ const gaps = computed.mechanicalGaps.filter((gap) => !/full-text source\(s\)/.test(gap));
1061
2302
  const requiredCapabilities = requiredDiscoveryCapabilityIds(await loadCapabilityDeclarations(root));
1062
2303
  const exercisedCapabilities = new Set((await loadProjectEvidenceReceipts(root, project.id)).map((receipt) => receipt.capabilityId));
1063
2304
  const journalEvents = await readJournal(workspacePaths(root).journal);
@@ -1103,9 +2344,6 @@ async function assertEvidenceCoverage(root, project) {
1103
2344
  canonicalJson(declared.publicationDateRange) !== canonicalJson(computed.publicationDateRange)) {
1104
2345
  gaps.push("coverage summary does not match admitted sources");
1105
2346
  }
1106
- if (declared.decision !== computed.decision) {
1107
- gaps.push(`coverage decision must be ${computed.decision}`);
1108
- }
1109
2347
  if (gaps.length) {
1110
2348
  throw new CliError("Evidence coverage is insufficient; downstream packages were not started.", {
1111
2349
  code: "RESEARCH_EVIDENCE_INSUFFICIENT",
@@ -1142,9 +2380,14 @@ function publicationDateInterval(value) {
1142
2380
  return { earliest: `${match[1]}-01-01`, latest: `${match[1]}-12-31` };
1143
2381
  }
1144
2382
  async function closeProjectMechanically(root, project, workPackage) {
1145
- await assertEvidenceCoverage(root, project);
2383
+ const snapshot = await loadCurrentEvidenceSnapshot(root, project.id);
2384
+ project.evidenceState.currentSnapshotId = snapshot.snapshotId;
2385
+ project.evidenceState.currentSnapshotSha256 = snapshot.snapshotSha256;
2386
+ project.evidenceState.closureSnapshotId = snapshot.snapshotId;
1146
2387
  const required = [
1147
2388
  "outputs/evidence.json",
2389
+ "outputs/acquisition.json",
2390
+ "outputs/evidence-snapshot.json",
1148
2391
  "outputs/analysis.json",
1149
2392
  "outputs/report.md",
1150
2393
  "outputs/review.json",
@@ -1159,6 +2402,7 @@ async function closeProjectMechanically(root, project, workPackage) {
1159
2402
  throw deterministicError("Project review does not bind a valid review packet hash.");
1160
2403
  }
1161
2404
  const reviewPacket = await loadVerifiedReviewPacket(root, project.id, review.packetSha256);
2405
+ await verifyReviewLedgerBinding(root, project.id, snapshot.snapshotId, review.packetSha256);
1162
2406
  const evidenceReceipts = await loadProjectEvidenceReceipts(root, project.id);
1163
2407
  const journal = await verifyJournal(workspacePaths(root).journal);
1164
2408
  const closure = {
@@ -1168,6 +2412,12 @@ async function closeProjectMechanically(root, project, workPackage) {
1168
2412
  closedAt: new Date().toISOString(),
1169
2413
  questionSha256: sha256Text(project.question),
1170
2414
  evidenceRequirements: project.evidenceRequirements,
2415
+ evidenceSnapshot: {
2416
+ snapshotId: snapshot.snapshotId,
2417
+ snapshotSha256: snapshot.snapshotSha256,
2418
+ parentSnapshotId: snapshot.parentSnapshotId,
2419
+ parentSnapshotSha256: snapshot.parentSnapshotSha256,
2420
+ },
1171
2421
  inputs: project.inputs.map((input) => ({
1172
2422
  id: input.id,
1173
2423
  role: input.role,
@@ -1191,6 +2441,75 @@ async function closeProjectMechanically(root, project, workPackage) {
1191
2441
  await writeJsonAtomic(closurePath, closure);
1192
2442
  return zeroExecutionResult();
1193
2443
  }
2444
+ async function commitStageEvidenceBindings(root, project, workPackage) {
2445
+ if (workPackage.stage !== "analyze" && workPackage.stage !== "review")
2446
+ return;
2447
+ const snapshot = await loadCurrentEvidenceSnapshot(root, project.id);
2448
+ const events = await readJournal(evidenceLedgerPath(root, project.id));
2449
+ if (workPackage.stage === "analyze") {
2450
+ const path = join(projectRoot(root, project.id), "outputs", "analysis.json");
2451
+ const value = JSON.parse(await readFile(path, "utf8"));
2452
+ if (!isObject(value) || !Array.isArray(value.findings)) {
2453
+ throw deterministicError("Analysis claim bindings require a valid analysis output.");
2454
+ }
2455
+ const committed = new Set(events
2456
+ .filter((event) => event.type === "claim.used")
2457
+ .map((event) => String(event.payload.bindingSha256)));
2458
+ for (const finding of value.findings) {
2459
+ if (!isObject(finding) ||
2460
+ typeof finding.id !== "string" ||
2461
+ !Array.isArray(finding.evidence) ||
2462
+ finding.evidence.some((sourceId) => typeof sourceId !== "string")) {
2463
+ throw deterministicError("Analysis contains an invalid claim binding.");
2464
+ }
2465
+ const binding = {
2466
+ claimId: finding.id,
2467
+ snapshotId: snapshot.snapshotId,
2468
+ sourceIds: [...finding.evidence].sort(),
2469
+ claimSha256: sha256Text(canonicalJson(finding)),
2470
+ };
2471
+ const bindingSha256 = sha256Text(canonicalJson(binding));
2472
+ if (committed.has(bindingSha256))
2473
+ continue;
2474
+ await appendEvidenceLedgerEvent(root, project.id, "claim.used", {
2475
+ ...binding,
2476
+ bindingSha256,
2477
+ });
2478
+ }
2479
+ return;
2480
+ }
2481
+ const path = join(projectRoot(root, project.id), "outputs", "review.json");
2482
+ const value = JSON.parse(await readFile(path, "utf8"));
2483
+ if (!isObject(value) || value.decision !== "pass" || typeof value.packetSha256 !== "string") {
2484
+ throw deterministicError("Review binding requires a passing schema-valid review.");
2485
+ }
2486
+ const binding = {
2487
+ snapshotId: snapshot.snapshotId,
2488
+ packetSha256: value.packetSha256,
2489
+ reviewSha256: await sha256File(path),
2490
+ decision: value.decision,
2491
+ };
2492
+ const bindingSha256 = sha256Text(canonicalJson(binding));
2493
+ if (events.some((event) => event.type === "review.bound" && event.payload.bindingSha256 === bindingSha256)) {
2494
+ return;
2495
+ }
2496
+ await appendEvidenceLedgerEvent(root, project.id, "review.bound", {
2497
+ ...binding,
2498
+ bindingSha256,
2499
+ });
2500
+ }
2501
+ async function verifyReviewLedgerBinding(root, projectId, snapshotId, packetSha256) {
2502
+ const reviewPath = join(projectRoot(root, projectId), "outputs", "review.json");
2503
+ const reviewSha256 = await sha256File(reviewPath);
2504
+ const events = await readJournal(evidenceLedgerPath(root, projectId));
2505
+ const bound = events.some((event) => event.type === "review.bound" &&
2506
+ event.payload.snapshotId === snapshotId &&
2507
+ event.payload.packetSha256 === packetSha256 &&
2508
+ event.payload.reviewSha256 === reviewSha256);
2509
+ if (!bound) {
2510
+ throw deterministicError("Project review is not bound to the current evidence snapshot ledger.");
2511
+ }
2512
+ }
1194
2513
  async function verifyProjectInputBindings(project) {
1195
2514
  for (const input of project.inputs) {
1196
2515
  const info = await lstat(input.path).catch(() => undefined);
@@ -1230,19 +2549,37 @@ async function verifyProjectInputBindings(project) {
1230
2549
  async function outputRecords(root, project, logicalPaths) {
1231
2550
  return Promise.all(logicalPaths.map((logicalPath) => fileRecord(resolveContained(projectRoot(root, project.id), logicalPath), logicalPath)));
1232
2551
  }
1233
- async function stageContextForPackage(capsuleProject, workPackage, config) {
1234
- const logicalPaths = workPackage.stage === "analyze"
1235
- ? ["outputs/evidence.json"]
1236
- : workPackage.stage === "synthesize"
1237
- ? ["outputs/evidence.json", "outputs/analysis.json"]
1238
- : workPackage.stage === "review"
2552
+ async function stageContextForPackage(capsuleProject, project, workPackage, config) {
2553
+ const logicalPaths = workPackage.stage === "discover" && project.lineage.kind === "addendum"
2554
+ ? ["outputs/base-evidence-snapshot.json"]
2555
+ : workPackage.stage === "acquire"
2556
+ ? [
2557
+ "outputs/evidence.json",
2558
+ ...(project.lineage.kind === "addendum" ? ["outputs/base-evidence-snapshot.json"] : []),
2559
+ ]
2560
+ : workPackage.stage === "analyze"
1239
2561
  ? [
1240
- "inputs/review-evidence-context.txt",
1241
- "outputs/evidence.json",
1242
- "outputs/analysis.json",
1243
- "outputs/report.md",
2562
+ "outputs/evidence-snapshot.json",
2563
+ ...((await pathExists(resolveContained(capsuleProject, "inputs/evidence-artifact-context.txt")))
2564
+ ? ["inputs/evidence-artifact-context.txt"]
2565
+ : []),
1244
2566
  ]
1245
- : [];
2567
+ : workPackage.stage === "synthesize"
2568
+ ? [
2569
+ "outputs/evidence-snapshot.json",
2570
+ "outputs/analysis.json",
2571
+ ...((await pathExists(resolveContained(capsuleProject, "inputs/evidence-artifact-context.txt")))
2572
+ ? ["inputs/evidence-artifact-context.txt"]
2573
+ : []),
2574
+ ]
2575
+ : workPackage.stage === "review"
2576
+ ? [
2577
+ "inputs/review-evidence-context.txt",
2578
+ "outputs/evidence-snapshot.json",
2579
+ "outputs/analysis.json",
2580
+ "outputs/report.md",
2581
+ ]
2582
+ : [];
1246
2583
  const sections = [];
1247
2584
  for (const logicalPath of logicalPaths) {
1248
2585
  const content = await readFile(resolveContained(capsuleProject, logicalPath), "utf8");
@@ -1250,13 +2587,17 @@ async function stageContextForPackage(capsuleProject, workPackage, config) {
1250
2587
  }
1251
2588
  const bundled = sections.join("\n\n");
1252
2589
  const estimatedTokens = Math.ceil(Buffer.byteLength(bundled, "utf8") / RESEARCH_ESTIMATED_BYTES_PER_TOKEN);
1253
- const maxStageContextTokens = workPackage.stage === "review"
1254
- ? config.budget.maxInputContextTokens + config.budget.maxOutputTokens * 3
1255
- : workPackage.stage === "synthesize"
1256
- ? config.budget.maxOutputTokens * 2
1257
- : workPackage.stage === "analyze"
1258
- ? config.budget.maxOutputTokens
1259
- : 0;
2590
+ const maxStageContextTokens = workPackage.stage === "discover" && project.lineage.kind === "addendum"
2591
+ ? config.budget.maxInputContextTokens
2592
+ : workPackage.stage === "review"
2593
+ ? config.budget.maxInputContextTokens + config.budget.maxOutputTokens * 4
2594
+ : workPackage.stage === "synthesize"
2595
+ ? config.budget.maxInputContextTokens
2596
+ : workPackage.stage === "analyze"
2597
+ ? config.budget.maxInputContextTokens
2598
+ : workPackage.stage === "acquire"
2599
+ ? config.budget.maxOutputTokens
2600
+ : 0;
1260
2601
  if (estimatedTokens > maxStageContextTokens) {
1261
2602
  throw new CliError(`Admitted stage context exceeds the configured input context limit for ${workPackage.id}.`, {
1262
2603
  code: "RESEARCH_INPUT_CONTEXT_BUDGET_EXCEEDED",
@@ -1270,10 +2611,11 @@ async function stageContextForPackage(capsuleProject, workPackage, config) {
1270
2611
  }
1271
2612
  return bundled;
1272
2613
  }
1273
- function packagePrompt(project, workPackage, inputs, stagedSkills, capabilityDocumentation, reviewPacketSha256, contextBundle, contextBundleContent, stageContextContent, maxBrokerCalls) {
2614
+ function packagePrompt(project, workPackage, inputs, stagedSkills, capabilityDocumentation, reviewPacketSha256, contextBundle, contextBundleContent, stageContextContent, discovery, evidenceCandidates) {
1274
2615
  const stageInstructions = {
1275
- 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.",
1276
- 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.",
2616
+ discover: "Assess candidates incrementally through the packet's recordAssessment command; do not accumulate a source-sized final response. The control plane has already assigned every immutable input and broker result a candidateId and retains its title, URL, DOI, dates, receipt, locator, JSON Pointer, hashes, and retrieval metadata. Reference candidateId; never repeat or invent those deterministic fields. Give each admitted candidate a concise sourceId plus source type, relevance, quality, applicability, coverage dimensions, and limitations. Record meaningful explicit rejections; omitted candidates remain unassessed for later gap filling. Native Web or Browser discoveries are supplemental candidates only and cannot be admitted until an immutable broker receipt is attached to the same canonical URL or DOI. After broad search, strict assessment, and focused gap filling, return only the small closeout object with one judgment for every reviewed dimension plus limitations and remaining gaps. The CLI mechanically joins the latest recorded assessments to provenance, derives counts/date range/coverage, and rejects unknown or unformalized candidates.",
2617
+ acquire: "Audit every provisionally admitted source exactly once. For each source, bind its ledger candidateId, list only artifactIds returned by the exact artifact registration command, and choose accepted, limited, or rejected with a concise rationale and explicit limitations. A broker receipt is an immutable discovery record but is not full text. Use an empty artifactIds array only when intentionally retaining a source as metadata/abstract evidence or when the source is an already registered local input. Put unresolved blocking acquisition or coverage deficiencies in gaps; put honest non-blocking scope constraints in limitations. Do not invent file paths, hashes, URLs, artifact IDs, or successful downloads.",
2618
+ analyze: "Use only the complete embedded frozen evidence snapshot below and return the schema-defined analysis object. Every finding must cite source IDs present in that snapshot and state uncertainty and applicability. When the snapshot has a parent, use its mechanical delta to focus re-analysis on added, changed, and removed evidence while still returning one complete current analysis.",
1277
2619
  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. Use real Markdown line breaks encoded exactly once for JSON; never place literal /n or double-escaped \\n markers in reportMarkdown. When the admitted publication-date range is narrower than the requested range, state the exact admitted range and missing interval prominently in the opening summary.",
1278
2620
  review: `Independently inspect the complete embedded artifacts and globally bounded evidence excerpts. The CLI has already verified every bound full evidence object's size and SHA-256 and persistently stored the complete review packet; its hash is schema-bound even though the packet metadata is not duplicated in model context. Do not claim to have read beyond the embedded excerpts. 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.`,
1279
2621
  close: "No agent action is allowed for mechanical closure.",
@@ -1289,16 +2631,26 @@ function packagePrompt(project, workPackage, inputs, stagedSkills, capabilityDoc
1289
2631
  `Staged capability directories: ${JSON.stringify(stagedSkills.map((path) => `skills/${basename(path)}`))}`,
1290
2632
  workPackage.stage === "discover"
1291
2633
  ? "The exact capability manifest and each staged external SKILL.md are embedded below. Use this documentation directly; filesystem tools are disabled."
1292
- : "Capability files are provenance-bound but are not available as execution tools in this stage.",
2634
+ : workPackage.stage === "acquire"
2635
+ ? "Use the installed external acquisition/document Skills in the current native host, but treat the CLI artifact registry and acquisition schema as the only authority for durable evidence."
2636
+ : "Capability files are provenance-bound but are not available as execution tools in this stage.",
1293
2637
  workPackage.stage === "discover"
1294
- ? `Use the broker only, with at most ${maxBrokerCalls} total fetch_candidate_source calls. Prefer broad, high-yield queries and stop querying once the declared coverage minimums are met. Do not execute a staged Skill's curl/CLI examples or read provider environment variables. Invoke fetch_candidate_source with the manifest capability ID and obey its exact declared HTTP method. GET capabilities use only declared query parameters; POST capabilities require request_body containing only the documented non-secret request JSON. Never place API keys, tokens, authorization data, cookies, or other credential-like fields in request_body. The broker injects the sole declared logical credential, disables caching for credentialed requests, and never persists the POST body (only its hash). The tool result includes the exact bounded context together with its receipt and remaining call budget; use that inline context and its provenance fields. Exercise every manifest capability with requiredForDiscovery=true, or the mechanical coverage gate will stop downstream work.`
2638
+ ? `Follow the reviewed discovery plan: ${JSON.stringify(discovery)}. The plan's max broker-view count is a hard working ceiling, not a target to exhaust. Execute required first-pass channels before supplemental channels, prefer broad high-yield queries, assess registered candidates between batches, and use the next gap-fill batch only for explicit uncovered dimensions, source types, date ranges, full text, limitations, or counterevidence. Stop fetching as soon as the declared coverage minimums are supportable. Native Web/Browser may broaden lead discovery, but every such action must be recorded through recordActivity, every useful result must be registered as a candidate, and the same URL/DOI must then be formalized through the broker before admission. The broker or an immutable registered input is the sole admissible evidence path. Do not execute a staged Skill's curl/CLI examples or read provider environment variables. Invoke fetch_candidate_source with the manifest capability ID and obey its exact declared HTTP method. GET capabilities use only declared query parameters; POST capabilities require request_body containing only the documented non-secret request JSON. Never place API keys, tokens, authorization data, cookies, or other credential-like fields in request_body. The broker injects the sole declared logical credential, disables caching for credentialed requests, and never persists the POST body (only its hash). The tool result includes exact bounded context, registered candidate IDs, its receipt, whether a network call was avoided, and remaining view budget. Exercise every manifest capability with requiredForDiscovery=true, or the mechanical coverage gate will stop downstream work.`
1295
2639
  : "Use only the complete embedded stage context; no tools or additional source reads are allowed.",
1296
2640
  stageInstructions[workPackage.stage],
1297
2641
  "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.",
1298
2642
  "Do not edit project.json, input manifests, prior outputs, evidence objects, or staged capability files.",
1299
2643
  ];
1300
2644
  if (workPackage.stage === "discover") {
1301
- prompt.push("The complete external capability documentation bundle is embedded below:", capabilityDocumentation, `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);
2645
+ prompt.push(`Candidate index already registered at stage start: ${JSON.stringify(evidenceCandidates.map((candidate) => ({
2646
+ candidateId: candidate.id,
2647
+ title: candidate.title,
2648
+ url: candidate.url,
2649
+ doi: candidate.doi,
2650
+ publicationDate: candidate.publicationDate,
2651
+ excerpt: candidate.excerpt,
2652
+ originKind: candidate.origin.kind,
2653
+ })))}`, "The complete external capability documentation bundle is embedded below:", capabilityDocumentation, "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);
1302
2654
  }
1303
2655
  if (stageContextContent) {
1304
2656
  prompt.push("The complete admitted stage context is embedded below. Use it directly and do not re-read output files.", stageContextContent);
@@ -1345,11 +2697,12 @@ function assertPreCallTokenReservation(project, workPackage, config, request, al
1345
2697
  });
1346
2698
  }
1347
2699
  }
1348
- function reservePackageBudget(project, workPackage, config) {
2700
+ function reservePackageBudget(project, workPackage, config, requestedTokens) {
1349
2701
  if (workPackage.stage === "close")
1350
2702
  return { tokens: 0, costUsd: 0 };
1351
2703
  const route = workPackage.executor === "reviewer" ? config.reviewer : config.producer;
1352
- const tokens = config.budget.packageMaxTokens[workPackage.stage];
2704
+ const packageMaximum = config.budget.packageMaxTokens[workPackage.stage];
2705
+ const tokens = Math.min(packageMaximum, requestedTokens ?? packageMaximum);
1353
2706
  const costUsd = roundMoney(reservedAgentPackageCost(route, tokens, config));
1354
2707
  const wallSeconds = config.budget.packageMaxWallSeconds[workPackage.stage];
1355
2708
  const remaining = remainingBudget(project, config);
@@ -1443,6 +2796,10 @@ function remainingWallSeconds(project, config) {
1443
2796
  return Math.max(1, Math.floor(config.budget.maxWallSeconds - project.usage.wallSeconds));
1444
2797
  }
1445
2798
  function assertExecutionConfiguration(config) {
2799
+ if (config.producer.executionMode !== "native-host" ||
2800
+ config.reviewer.executionMode !== "headless-cli") {
2801
+ throw new CliError("Research requires a native-host producer and a separate headless-CLI reviewer.", { code: "RESEARCH_EXECUTION_MODE_INVALID", exitCode: 3 });
2802
+ }
1446
2803
  if (config.producer.agent === config.reviewer.agent) {
1447
2804
  throw new CliError("Research producer and reviewer must use different agent families.", {
1448
2805
  code: "RESEARCH_REVIEW_ROUTE_INVALID",
@@ -1686,21 +3043,33 @@ async function summarizeRun(root, requestId, cycles, executed, maxCycles, projec
1686
3043
  usage: project.usage,
1687
3044
  }));
1688
3045
  const unfinished = summaries.filter((project) => project.status !== "complete");
3046
+ const waiting = unfinished.filter((project) => project.status === "waiting-user" || project.status === "waiting-external");
1689
3047
  const hasReadyPackage = summaries.some((project) => project.readyPackage !== null);
3048
+ const nativeStageRequired = projects.some((project) => project.packages.some((workPackage) => workPackage.executor === "producer" &&
3049
+ (workPackage.status === "ready" || workPackage.status === "running")));
1690
3050
  const status = summaries.length > 0 && summaries.every((project) => project.status === "complete")
1691
3051
  ? "complete"
1692
- : unfinished.length > 0 && unfinished.every((project) => project.status === "blocked")
1693
- ? "blocked"
1694
- : "ready";
3052
+ : waiting.length > 0 &&
3053
+ unfinished.every((project) => project.status === "blocked" ||
3054
+ project.status === "waiting-user" ||
3055
+ project.status === "waiting-external")
3056
+ ? "waiting"
3057
+ : unfinished.length > 0 && unfinished.every((project) => project.status === "blocked")
3058
+ ? "blocked"
3059
+ : "ready";
1695
3060
  const stopReason = summaries.length === 0
1696
3061
  ? "no-projects"
1697
3062
  : status === "complete"
1698
3063
  ? "all-projects-complete"
1699
- : hasReadyPackage && cycles >= maxCycles
1700
- ? "cycle-limit"
1701
- : status === "blocked"
1702
- ? "project-blocked"
1703
- : "no-ready-work";
3064
+ : status === "waiting"
3065
+ ? "handoff-required"
3066
+ : hasReadyPackage && cycles >= maxCycles
3067
+ ? "cycle-limit"
3068
+ : nativeStageRequired
3069
+ ? "native-stage-required"
3070
+ : status === "blocked"
3071
+ ? "project-blocked"
3072
+ : "no-ready-work";
1704
3073
  return {
1705
3074
  workspace: root,
1706
3075
  requestId,
@@ -1713,7 +3082,26 @@ async function summarizeRun(root, requestId, cycles, executed, maxCycles, projec
1713
3082
  };
1714
3083
  }
1715
3084
  async function projectsForRun(root, projectId) {
1716
- return projectId ? [await loadProject(root, projectId)] : listProjects(root);
3085
+ if (projectId) {
3086
+ const project = await loadProject(root, projectId);
3087
+ if (project.lineage.supersededBy ||
3088
+ project.status === "archived" ||
3089
+ project.status === "abandoned") {
3090
+ throw new CliError(`Research project ${projectId} is historical and cannot be executed.`, {
3091
+ code: "RESEARCH_PROJECT_NOT_AUTHORITATIVE",
3092
+ exitCode: 3,
3093
+ details: {
3094
+ projectId,
3095
+ status: project.status,
3096
+ authoritativeProjectId: project.lineage.supersededBy ?? null,
3097
+ },
3098
+ });
3099
+ }
3100
+ return [project];
3101
+ }
3102
+ return (await listProjects(root)).filter((project) => project.lineage.supersededBy === null &&
3103
+ project.status !== "archived" &&
3104
+ project.status !== "abandoned");
1717
3105
  }
1718
3106
  function assertPublicEvidenceUrl(value, sourceId) {
1719
3107
  let url;
@@ -1723,6 +3111,9 @@ function assertPublicEvidenceUrl(value, sourceId) {
1723
3111
  catch {
1724
3112
  throw deterministicError(`Evidence source ${sourceId} contains an invalid URL.`);
1725
3113
  }
3114
+ if (url.protocol !== "https:") {
3115
+ throw deterministicError(`Evidence source ${sourceId} URL must use HTTPS.`);
3116
+ }
1726
3117
  if (url.username || url.password) {
1727
3118
  throw deterministicError(`Evidence source ${sourceId} URL contains credentials.`);
1728
3119
  }
@@ -1737,6 +3128,120 @@ function deterministicError(message) {
1737
3128
  function projectRoot(root, projectId) {
1738
3129
  return join(workspacePaths(root).projects, projectId);
1739
3130
  }
3131
+ function nativeStageSessionPath(root, projectId) {
3132
+ return join(projectRoot(root, projectId), "native", "active.json");
3133
+ }
3134
+ async function readNativeStageSession(root, projectId) {
3135
+ const path = nativeStageSessionPath(root, projectId);
3136
+ const info = await lstat(path).catch(() => undefined);
3137
+ if (!info?.isFile() || info.isSymbolicLink()) {
3138
+ throw new CliError("No valid native stage session is active for this project.", {
3139
+ code: "RESEARCH_NATIVE_STAGE_SESSION_REQUIRED",
3140
+ exitCode: 3,
3141
+ });
3142
+ }
3143
+ const value = await readJsonFile(path, "Native research stage session");
3144
+ if (!isObject(value) ||
3145
+ value.schemaVersion !== 1 ||
3146
+ value.kind !== "tiangong-native-research-stage-session" ||
3147
+ !isObject(value.packet) ||
3148
+ typeof value.capsuleRoot !== "string" ||
3149
+ typeof value.capsuleProject !== "string" ||
3150
+ typeof value.sessionSha256 !== "string") {
3151
+ throw new CliError("Native stage session has an unsupported shape.", {
3152
+ code: "RESEARCH_NATIVE_STAGE_SESSION_INVALID",
3153
+ exitCode: 3,
3154
+ });
3155
+ }
3156
+ const { sessionSha256, ...core } = value;
3157
+ if (sha256Text(canonicalJson(core)) !== sessionSha256) {
3158
+ throw new CliError("Native stage session failed its content hash.", {
3159
+ code: "RESEARCH_NATIVE_STAGE_SESSION_INVALID",
3160
+ exitCode: 3,
3161
+ });
3162
+ }
3163
+ const packet = value.packet;
3164
+ const { packetSha256, ...packetCore } = packet;
3165
+ if (packet.schemaVersion !== 1 ||
3166
+ packet.kind !== "tiangong-native-research-stage" ||
3167
+ packet.projectId !== projectId ||
3168
+ typeof packetSha256 !== "string" ||
3169
+ sha256Text(canonicalJson(packetCore)) !== packetSha256) {
3170
+ throw new CliError("Native stage packet failed its content hash or project binding.", {
3171
+ code: "RESEARCH_NATIVE_STAGE_SESSION_INVALID",
3172
+ exitCode: 3,
3173
+ });
3174
+ }
3175
+ const runtimeRoot = resolve(workspacePaths(root).runtime);
3176
+ const capsuleRoot = resolve(value.capsuleRoot);
3177
+ const capsuleProject = resolve(value.capsuleProject);
3178
+ if (relative(runtimeRoot, capsuleRoot).startsWith("..") ||
3179
+ relative(capsuleRoot, capsuleProject).startsWith("..") ||
3180
+ !(await lstat(capsuleRoot).catch(() => undefined))?.isDirectory() ||
3181
+ !(await lstat(capsuleProject).catch(() => undefined))?.isDirectory()) {
3182
+ throw new CliError("Native stage capsule is missing or outside the workspace runtime.", {
3183
+ code: "RESEARCH_NATIVE_STAGE_SESSION_INVALID",
3184
+ exitCode: 3,
3185
+ });
3186
+ }
3187
+ return value;
3188
+ }
3189
+ async function nativeStageBinding(root, project, workPackage) {
3190
+ const paths = workspacePaths(root);
3191
+ const outputRoot = join(projectRoot(root, project.id), "outputs");
3192
+ const outputs = [];
3193
+ if (await pathExists(outputRoot)) {
3194
+ for (const path of await regularTreeFiles(outputRoot)) {
3195
+ outputs.push(await fileRecord(path, relative(projectRoot(root, project.id), path).replaceAll("\\", "/")));
3196
+ }
3197
+ }
3198
+ return sha256Text(canonicalJson({
3199
+ projectId: project.id,
3200
+ questionSha256: sha256Text(project.question),
3201
+ evidenceRequirements: project.evidenceRequirements,
3202
+ inputs: project.inputs.map((record) => ({
3203
+ id: record.id,
3204
+ role: record.role,
3205
+ sha256: record.sha256,
3206
+ bytes: record.bytes,
3207
+ contextSha256: record.contextSha256 ?? null,
3208
+ contextBytes: record.contextBytes ?? null,
3209
+ })),
3210
+ package: {
3211
+ id: workPackage.id,
3212
+ stage: workPackage.stage,
3213
+ dependencies: workPackage.dependencies,
3214
+ expectedOutputs: workPackage.expectedOutputs,
3215
+ },
3216
+ outputs,
3217
+ configSha256: await sha256File(paths.config),
3218
+ runtimeLockSha256: await sha256File(paths.runtimeLock),
3219
+ capabilityDeclarationsSha256: await sha256File(paths.capabilityDeclarations),
3220
+ capabilityLockSha256: (await pathExists(paths.capabilityLock))
3221
+ ? await sha256File(paths.capabilityLock)
3222
+ : null,
3223
+ }));
3224
+ }
3225
+ async function assertNativeStageBinding(root, project, packet) {
3226
+ const workPackage = packageById(project, packet.packageId);
3227
+ const actual = await nativeStageBinding(root, project, workPackage);
3228
+ if (actual !== packet.bindingSha256) {
3229
+ throw new CliError("Native stage inputs, configuration, or admitted outputs drifted.", {
3230
+ code: "RESEARCH_NATIVE_STAGE_BINDING_DRIFT",
3231
+ exitCode: 3,
3232
+ details: { expectedSha256: packet.bindingSha256, actualSha256: actual },
3233
+ });
3234
+ }
3235
+ }
3236
+ function requireNativeOutputPath(value) {
3237
+ if (!value || !isAbsolute(value) || /[\0\r\n]/.test(value)) {
3238
+ throw new CliError("Native stage output requires an explicit absolute file path.", {
3239
+ code: "RESEARCH_NATIVE_STAGE_OUTPUT_INVALID",
3240
+ exitCode: 2,
3241
+ });
3242
+ }
3243
+ return resolve(value);
3244
+ }
1740
3245
  function validateRunOptions(options) {
1741
3246
  if (!Number.isInteger(options.maxParallel) ||
1742
3247
  options.maxParallel < 1 ||