@tiangong-ai/cli 0.0.32 → 0.0.34

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 (69) hide show
  1. package/AGENTS.md +7 -2
  2. package/README.md +72 -19
  3. package/dist/research/commands.js +58 -1
  4. package/dist/research/commands.js.map +1 -1
  5. package/dist/research/orchestration.js +388 -31
  6. package/dist/research/orchestration.js.map +1 -1
  7. package/dist/research/workspace/acquisition.d.ts +71 -0
  8. package/dist/research/workspace/acquisition.js +593 -0
  9. package/dist/research/workspace/acquisition.js.map +1 -0
  10. package/dist/research/workspace/artifacts.d.ts +43 -0
  11. package/dist/research/workspace/artifacts.js +544 -0
  12. package/dist/research/workspace/artifacts.js.map +1 -0
  13. package/dist/research/workspace/broker.js +265 -56
  14. package/dist/research/workspace/broker.js.map +1 -1
  15. package/dist/research/workspace/constants.d.ts +1 -0
  16. package/dist/research/workspace/constants.js +4 -2
  17. package/dist/research/workspace/constants.js.map +1 -1
  18. package/dist/research/workspace/context.js +106 -11
  19. package/dist/research/workspace/context.js.map +1 -1
  20. package/dist/research/workspace/discovery-planning.d.ts +25 -0
  21. package/dist/research/workspace/discovery-planning.js +106 -0
  22. package/dist/research/workspace/discovery-planning.js.map +1 -0
  23. package/dist/research/workspace/discovery-status.d.ts +46 -0
  24. package/dist/research/workspace/discovery-status.js +183 -0
  25. package/dist/research/workspace/discovery-status.js.map +1 -0
  26. package/dist/research/workspace/discovery.d.ts +25 -0
  27. package/dist/research/workspace/discovery.js +268 -0
  28. package/dist/research/workspace/discovery.js.map +1 -0
  29. package/dist/research/workspace/downloads.d.ts +76 -0
  30. package/dist/research/workspace/downloads.js +274 -0
  31. package/dist/research/workspace/downloads.js.map +1 -0
  32. package/dist/research/workspace/evidence-ledger.d.ts +52 -0
  33. package/dist/research/workspace/evidence-ledger.js +487 -0
  34. package/dist/research/workspace/evidence-ledger.js.map +1 -0
  35. package/dist/research/workspace/evidence.d.ts +1 -0
  36. package/dist/research/workspace/evidence.js +2 -0
  37. package/dist/research/workspace/evidence.js.map +1 -1
  38. package/dist/research/workspace/input-plan.js +4 -0
  39. package/dist/research/workspace/input-plan.js.map +1 -1
  40. package/dist/research/workspace/native-activity.d.ts +65 -0
  41. package/dist/research/workspace/native-activity.js +153 -0
  42. package/dist/research/workspace/native-activity.js.map +1 -0
  43. package/dist/research/workspace/preflight.d.ts +5 -0
  44. package/dist/research/workspace/preflight.js +37 -17
  45. package/dist/research/workspace/preflight.js.map +1 -1
  46. package/dist/research/workspace/projects.d.ts +3 -1
  47. package/dist/research/workspace/projects.js +346 -5
  48. package/dist/research/workspace/projects.js.map +1 -1
  49. package/dist/research/workspace/runtime.d.ts +106 -4
  50. package/dist/research/workspace/runtime.js +947 -80
  51. package/dist/research/workspace/runtime.js.map +1 -1
  52. package/dist/research/workspace/sanitization.js +28 -6
  53. package/dist/research/workspace/sanitization.js.map +1 -1
  54. package/dist/research/workspace/schemas.d.ts +3 -0
  55. package/dist/research/workspace/schemas.js +206 -33
  56. package/dist/research/workspace/schemas.js.map +1 -1
  57. package/dist/research/workspace/setup-catalog.js +5 -5
  58. package/dist/research/workspace/setup-invocation.d.ts +11 -0
  59. package/dist/research/workspace/setup-invocation.js +34 -0
  60. package/dist/research/workspace/setup-invocation.js.map +1 -0
  61. package/dist/research/workspace/setup-wizard.js +10 -4
  62. package/dist/research/workspace/setup-wizard.js.map +1 -1
  63. package/dist/research/workspace/setup.d.ts +55 -7
  64. package/dist/research/workspace/setup.js +603 -32
  65. package/dist/research/workspace/setup.js.map +1 -1
  66. package/dist/research/workspace/types.d.ts +52 -3
  67. package/dist/research/workspace/workspace.js +45 -6
  68. package/dist/research/workspace/workspace.js.map +1 -1
  69. package/package.json +4 -2
@@ -4,17 +4,53 @@ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:pat
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) {
19
55
  return runResearchWorkspaceInternal(root, options, packageExecutor, false);
20
56
  }
@@ -86,6 +122,53 @@ async function runResearchWorkspaceInternal(root, options, packageExecutor, allo
86
122
  return result;
87
123
  });
88
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
+ }
89
172
  export async function prepareNativeResearchStage(input) {
90
173
  return withWorkspaceLock(input.root, "research.native-stage.prepare", async () => {
91
174
  await verifyJournal(workspacePaths(input.root).journal);
@@ -113,6 +196,12 @@ export async function prepareNativeResearchStage(input) {
113
196
  }
114
197
  }
115
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
+ }
116
205
  if (config.mode === "production-research" &&
117
206
  config.budget.maxCostUsd > config.budget.confirmationCostUsd &&
118
207
  !project.budgetConfirmedAt) {
@@ -150,21 +239,28 @@ export async function prepareNativeResearchStage(input) {
150
239
  exitCode: 3,
151
240
  });
152
241
  }
153
- const reservation = reservePackageBudget(project, workPackage, config);
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);
154
248
  const sessionId = randomUUID();
155
249
  let capsule = null;
156
250
  let preparedStatePersisted = false;
157
251
  try {
158
252
  capsule = await createCapsule(input.root, project, workPackage, sessionId, config);
159
- const stageContextContent = await stageContextForPackage(capsule.projectRoot, workPackage, config);
253
+ const stageContextContent = await stageContextForPackage(capsule.projectRoot, project, workPackage, config);
160
254
  const declarations = await loadCapabilityDeclarations(input.root);
161
255
  const hasBrokeredEvidence = declarations.capabilities.some((capability) => capability.permissions.includes("brokered-network"));
162
- const basePrompt = packagePrompt(project, workPackage, capsule.inputManifest, capsule.stagedSkills, capsule.capabilityDocumentation, null, capsule.contextBundle, capsule.contextBundleContent, stageContextContent, config.budget.maxBrokerCalls);
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));
163
257
  const prompt = [
164
258
  "Perform this producer stage in the current interactive host session. Do not launch codex exec, claude -p, or any other nested reasoning agent.",
165
259
  input.stage === "discover" && hasBrokeredEvidence
166
- ? "For every internet/database request, write one non-secret request JSON file and invoke the packet's fetchEvidence argv through the CLI control plane. Use only its returned bounded context and receipt. Do not use standalone web search as evidence."
167
- : "Do not acquire additional evidence in this stage.",
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.",
168
264
  basePrompt,
169
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.",
170
266
  ].join("\n\n");
@@ -185,14 +281,50 @@ export async function prepareNativeResearchStage(input) {
185
281
  outputSchema: schemaForStage(input.stage, null, input.stage === "discover" && !hasBrokeredEvidence
186
282
  ? { inputOnlyProvenanceIds: capsule.inputManifest.map((record) => record.id) }
187
283
  : {}),
284
+ discovery,
188
285
  limits: {
189
286
  maxOutputBytes: config.budget.maxBytesPerPackage,
190
- maxOutputTokens: config.budget.maxOutputTokens,
191
- reservedPackageTokens: config.budget.packageMaxTokens[input.stage],
287
+ maxOutputTokens: stageOutputTokens,
288
+ reservedPackageTokens,
192
289
  reservedMaxCostUsd: reservation.costUsd,
193
290
  maxWallSeconds: config.budget.packageMaxWallSeconds[input.stage],
194
291
  },
195
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,
196
328
  fetchEvidence: input.stage === "discover" && hasBrokeredEvidence
197
329
  ? {
198
330
  argv: [
@@ -225,6 +357,122 @@ export async function prepareNativeResearchStage(input) {
225
357
  },
226
358
  }
227
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,
228
476
  submit: {
229
477
  argv: [
230
478
  "tiangong-ai",
@@ -261,7 +509,10 @@ export async function prepareNativeResearchStage(input) {
261
509
  },
262
510
  rules: [
263
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.",
264
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.",
265
516
  "Do not place credentials, cookies, authorization data, or sensitive URL parameters in request/output files.",
266
517
  "A file's existence is not success; submit performs schema, provenance, budget, hash, and atomic-commit checks.",
267
518
  ],
@@ -351,6 +602,14 @@ export async function submitNativeResearchStage(input) {
351
602
  });
352
603
  }
353
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);
354
613
  const workPackage = packageById(project, session.packet.packageId);
355
614
  if (workPackage.status !== "running" || workPackage.executor !== "producer") {
356
615
  throw new CliError("The bound native producer package is no longer running.", {
@@ -363,7 +622,7 @@ export async function submitNativeResearchStage(input) {
363
622
  await materializeAndValidateStageOutput(input.root, project, session.capsuleProject, workPackage, raw, null);
364
623
  const elapsed = Math.max(0.001, (Date.now() - Date.parse(session.packet.preparedAt)) / 1_000);
365
624
  const outputTokens = Math.ceil(Buffer.byteLength(raw, "utf8") / RESEARCH_ESTIMATED_BYTES_PER_TOKEN);
366
- const reservedTokens = config.budget.packageMaxTokens[session.packet.stage];
625
+ const reservedTokens = session.packet.limits.reservedPackageTokens;
367
626
  const result = {
368
627
  exitCode: 0,
369
628
  stdout: raw,
@@ -377,12 +636,22 @@ export async function submitNativeResearchStage(input) {
377
636
  model: config.producer.model,
378
637
  runtime: null,
379
638
  };
380
- assertActualPackageBudget(project, workPackage, config, result, config.budget.maxOutputTokens);
639
+ assertActualPackageBudget(project, workPackage, config, result, session.packet.limits.maxOutputTokens);
381
640
  assertProjectedBudget(project, config, result);
382
641
  if (workPackage.stage === "discover") {
383
- await assertEvidenceCoverage(input.root, project, resolveContained(session.capsuleProject, "outputs/evidence.json"));
642
+ await assertDiscoveryCoverage(input.root, project, resolveContained(session.capsuleProject, "outputs/evidence.json"));
384
643
  }
385
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);
386
655
  applyUsage(project, result);
387
656
  workPackage.status = "complete";
388
657
  workPackage.completedAt = new Date().toISOString();
@@ -445,6 +714,22 @@ export async function submitNativeResearchStage(input) {
445
714
  }
446
715
  });
447
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
+ }
448
733
  export async function abortNativeResearchStage(input) {
449
734
  return withWorkspaceLock(input.root, "research.native-stage.abort", async () => {
450
735
  const session = await readNativeStageSession(input.root, input.projectId);
@@ -485,6 +770,159 @@ export async function abortNativeResearchStage(input) {
485
770
  };
486
771
  });
487
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
+ }
488
926
  async function executeWorkPackage(root, projectId, packageId, config, options, requestId, packageExecutor, doctorAttestation) {
489
927
  const project = await loadProject(root, projectId);
490
928
  const workPackage = packageById(project, packageId);
@@ -521,7 +959,14 @@ async function executeWorkPackage(root, projectId, packageId, config, options, r
521
959
  promotedOutputs = await outputRecords(root, project, workPackage.expectedOutputs);
522
960
  }
523
961
  else {
524
- 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);
525
970
  const capsule = await createCapsule(root, project, workPackage, runId, config);
526
971
  capsuleRoot = capsule.capsuleRoot;
527
972
  if (capsule.reviewPacketRecord) {
@@ -533,7 +978,7 @@ async function executeWorkPackage(root, projectId, packageId, config, options, r
533
978
  packet: capsule.reviewPacketRecord,
534
979
  });
535
980
  }
536
- const stageContextContent = await stageContextForPackage(capsule.projectRoot, workPackage, config);
981
+ const stageContextContent = await stageContextForPackage(capsule.projectRoot, project, workPackage, config);
537
982
  const route = workPackage.executor === "reviewer" ? config.reviewer : config.producer;
538
983
  executor = route.agent;
539
984
  broker =
@@ -552,10 +997,11 @@ async function executeWorkPackage(root, projectId, packageId, config, options, r
552
997
  options,
553
998
  requestId,
554
999
  purpose: "primary",
555
- 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)),
556
1001
  brokerUrl: primaryBrokerUrl,
557
1002
  inputOnlyProvenance,
558
- maxOutputTokens: Math.min(config.budget.maxOutputTokens, reservation.tokens),
1003
+ maxOutputTokens: Math.min(stageOutputTokens, reservation.tokens),
1004
+ ...(discovery ? { brokerCallBudget: discovery.plan.maxCalls } : {}),
559
1005
  maxCostUsd: reservation.costUsd,
560
1006
  expectedRuntime: runtimeForRoute(doctorAttestation, route),
561
1007
  });
@@ -563,7 +1009,8 @@ async function executeWorkPackage(root, projectId, packageId, config, options, r
563
1009
  result = await withHeartbeat(packageExecutor(primaryRequest), options, requestId, project, workPackage, config);
564
1010
  accountedResult = result;
565
1011
  assertExecutorSucceeded(result);
566
- assertActualPackageBudget(project, workPackage, config, result, config.budget.maxOutputTokens);
1012
+ assertActualPackageBudget(project, workPackage, config, result, stageOutputTokens);
1013
+ let acceptedRaw = result.stdout;
567
1014
  try {
568
1015
  await materializeAndValidateStageOutput(root, project, capsule.projectRoot, workPackage, result.stdout, capsule.reviewPacketSha256);
569
1016
  }
@@ -596,14 +1043,23 @@ async function executeWorkPackage(root, projectId, packageId, config, options, r
596
1043
  const repair = await withHeartbeat(packageExecutor(repairRequest), options, requestId, project, workPackage, config);
597
1044
  accountedResult = combineExecutionResults(result, repair);
598
1045
  assertExecutorSucceeded(repair);
599
- 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;
600
1048
  await materializeAndValidateStageOutput(root, project, capsule.projectRoot, workPackage, repair.stdout, capsule.reviewPacketSha256);
601
1049
  }
602
1050
  assertProjectedBudget(project, config, accountedResult);
603
1051
  promotedOutputs = await validateAndImportOutputs(root, project, workPackage, capsule.projectRoot, config, capsule.reviewPacketSha256);
604
1052
  if (workPackage.stage === "discover") {
605
- await assertEvidenceCoverage(root, project);
1053
+ await assertDiscoveryCoverage(root, project);
1054
+ await commitDiscoveryDecisions(root, project.id, parseStructuredStageOutput("discover", acceptedRaw).value);
606
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"));
1061
+ }
1062
+ await commitStageEvidenceBindings(root, project, workPackage);
607
1063
  }
608
1064
  const completedAt = new Date().toISOString();
609
1065
  applyUsage(project, accountedResult);
@@ -809,6 +1265,16 @@ async function createCapsule(root, project, workPackage, runId, config) {
809
1265
  await writeTextAtomic(contextBundlePath, contextBundleContent);
810
1266
  const contextBundle = await fileRecord(contextBundlePath, "inputs/context-bundle.txt");
811
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
+ }
812
1278
  await writeJsonAtomic(join(capsuleProject, "inputs", "evidence-receipts.json"), evidenceReceipts.map(reviewSafeReceipt));
813
1279
  await writeJsonAtomic(join(capsuleProject, "project.json"), {
814
1280
  ...project,
@@ -821,10 +1287,10 @@ async function createCapsule(root, project, workPackage, runId, config) {
821
1287
  const stagedSkills = await stageLockedCapabilities(root, join(capsuleProject, "skills"));
822
1288
  const capabilityDocumentation = await buildCapabilityDocumentation(capsuleProject, stagedSkills, config.budget.maxInputContextTokens * RESEARCH_ESTIMATED_BYTES_PER_TOKEN);
823
1289
  const reviewEvidenceContext = workPackage.stage === "review"
824
- ? 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)
825
1291
  : null;
826
1292
  const reviewPacket = reviewEvidenceContext
827
- ? await writeReviewPacket(root, capsuleProject, project, inputManifest, evidenceReceipts, reviewEvidenceContext.persistent)
1293
+ ? await writeReviewPacket(root, capsuleProject, project, inputManifest, evidenceReceipts, evidenceArtifacts, reviewEvidenceContext.persistent)
828
1294
  : null;
829
1295
  return {
830
1296
  capsuleRoot,
@@ -882,8 +1348,21 @@ async function buildInputContextBundle(capsuleProject, inputManifest) {
882
1348
  }
883
1349
  return `${sections.join("\n\n")}\n`;
884
1350
  }
885
- async function writeReviewPacket(root, capsuleProject, project, inputManifest, evidenceReceipts, reviewEvidenceContext) {
886
- 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
+ ];
887
1366
  const evidenceFiles = new Map();
888
1367
  for (const receipt of evidenceReceipts) {
889
1368
  for (const locator of [receipt.locator, receipt.contextLocator]) {
@@ -892,6 +1371,11 @@ async function writeReviewPacket(root, capsuleProject, project, inputManifest, e
892
1371
  }
893
1372
  }
894
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
+ }
895
1379
  const environment = await reviewEnvironmentPacket(root, project.id);
896
1380
  const inputFiles = new Map();
897
1381
  for (const input of inputManifest) {
@@ -907,10 +1391,18 @@ async function writeReviewPacket(root, capsuleProject, project, inputManifest, e
907
1391
  projectId: project.id,
908
1392
  questionSha256: sha256Text(project.question),
909
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,
910
1401
  inputs: inputManifest,
911
1402
  reviewEvidenceContext,
912
1403
  inputFiles: [...inputFiles.values()].sort((left, right) => left.path.localeCompare(right.path)),
913
1404
  evidenceReceipts: evidenceReceipts.map(reviewSafeReceipt),
1405
+ evidenceArtifacts,
914
1406
  evidenceFiles: [...evidenceFiles.values()].sort((left, right) => left.path.localeCompare(right.path)),
915
1407
  environment,
916
1408
  environmentFile: await fileRecord(join(capsuleProject, "inputs", "runtime-fingerprint.json"), "inputs/runtime-fingerprint.json"),
@@ -925,7 +1417,7 @@ async function writeReviewPacket(root, capsuleProject, project, inputManifest, e
925
1417
  const record = await persistReviewPacket(root, project.id, completePacket, packetSha256);
926
1418
  return { sha256: packetSha256, record };
927
1419
  }
928
- async function writeReviewEvidenceContext(root, projectId, capsuleProject, inputContextBundle, evidenceReceipts, maxBytes) {
1420
+ async function writeReviewEvidenceContext(root, projectId, capsuleProject, inputContextBundle, evidenceReceipts, evidenceArtifacts, maxBytes) {
929
1421
  const header = [
930
1422
  "TIANGONG REVIEW EVIDENCE CONTEXT v1",
931
1423
  "The following are deterministic excerpts from hash-verified bounded views. Full objects and original bounded contexts remain bound in the review packet.",
@@ -939,13 +1431,25 @@ async function writeReviewEvidenceContext(root, projectId, capsuleProject, input
939
1431
  active: true,
940
1432
  },
941
1433
  ];
942
- const seen = new Set();
943
- for (const receipt of [...evidenceReceipts].sort((left, right) => left.attemptId.localeCompare(right.attemptId))) {
944
- if (seen.has(receipt.contextLocator))
945
- continue;
946
- 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) {
947
1443
  const metadata = reviewSafeReceipt(receipt);
948
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);
949
1453
  const content = references.length
950
1454
  ? await citedBrokerReviewContent(capsuleProject, receipt, references)
951
1455
  : "[No admitted evidence source cites this receipt; its raw object and bounded context remain hash-bound in the review packet.]";
@@ -961,6 +1465,33 @@ async function writeReviewEvidenceContext(root, projectId, capsuleProject, input
961
1465
  active: references.length > 0,
962
1466
  });
963
1467
  }
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
+ }
964
1495
  const fixedContent = [
965
1496
  header,
966
1497
  ...views.map((view) => `${view.prefix}${view.active ? "" : view.content}${view.suffix}`),
@@ -974,7 +1505,7 @@ async function writeReviewEvidenceContext(root, projectId, capsuleProject, input
974
1505
  });
975
1506
  }
976
1507
  const activeViews = views.filter((view) => view.active).length;
977
- const contentBudgetPerView = Math.floor((maxBytes - fixedBytes) / activeViews);
1508
+ const contentBudgetPerView = activeViews ? Math.floor((maxBytes - fixedBytes) / activeViews) : 0;
978
1509
  const sections = [
979
1510
  header,
980
1511
  ...views.map((view) => `${view.prefix}${view.active ? boundedUtf8ReviewExcerpt(view.content, contentBudgetPerView) : view.content}${view.suffix}`),
@@ -1012,6 +1543,52 @@ async function writeReviewEvidenceContext(root, projectId, capsuleProject, input
1012
1543
  persistent: await fileRecord(persistentPath, persistentLogicalPath),
1013
1544
  };
1014
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
+ }
1015
1592
  async function loadBrokerReviewReferences(capsuleProject) {
1016
1593
  const evidencePath = resolveContained(capsuleProject, "outputs/evidence.json");
1017
1594
  if (!(await pathExists(evidencePath)))
@@ -1114,7 +1691,7 @@ function resolveReviewJsonPointer(value, pointer) {
1114
1691
  return selected;
1115
1692
  }
1116
1693
  function reviewableTextContentType(contentType) {
1117
- 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);
1118
1695
  }
1119
1696
  function boundedUtf8ReviewExcerpt(value, maxBytes) {
1120
1697
  if (Buffer.byteLength(value, "utf8") <= maxBytes)
@@ -1191,6 +1768,41 @@ async function loadVerifiedReviewPacket(root, projectId, packetSha256) {
1191
1768
  exitCode: 3,
1192
1769
  });
1193
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
+ }
1194
1806
  return fileRecord(path, logicalPath);
1195
1807
  }
1196
1808
  function verifyReviewPacketValue(packet, packetSha256) {
@@ -1299,7 +1911,8 @@ function agentRequest(input) {
1299
1911
  maxTurns,
1300
1912
  maxOutputTokens: input.maxOutputTokens,
1301
1913
  maxToolContextTokens: input.brokerUrl
1302
- ? input.config.budget.maxBrokerContextTokens * input.config.budget.maxBrokerCalls
1914
+ ? input.config.budget.maxBrokerContextTokens *
1915
+ (input.brokerCallBudget ?? input.config.budget.maxBrokerCalls)
1303
1916
  : 0,
1304
1917
  maxCostUsd: input.maxCostUsd,
1305
1918
  expectedRuntime: input.expectedRuntime,
@@ -1330,8 +1943,10 @@ async function materializeAndValidateStageOutput(root, project, capsuleProject,
1330
1943
  const parsed = parseStructuredStageOutput(workPackage.stage, raw, reviewPacketSha256);
1331
1944
  const destination = resolveContained(capsuleProject, workPackage.expectedOutputs[0]);
1332
1945
  const fileContent = workPackage.stage === "discover"
1333
- ? `${JSON.stringify(normalizeEvidenceCoverage(project, parsed.value), null, 2)}\n`
1334
- : 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;
1335
1950
  await writeTextAtomic(destination, fileContent);
1336
1951
  await validateOutputShape(root, project, workPackage, destination, reviewPacketSha256);
1337
1952
  if (parsed.normalizations.length > 0) {
@@ -1370,12 +1985,20 @@ async function validateOutputShape(root, project, workPackage, path, reviewPacke
1370
1985
  const content = await readFile(path, "utf8");
1371
1986
  if (!content.trim())
1372
1987
  throw deterministicError(`${workPackage.expectedOutputs[0]} is empty.`);
1373
- if (workPackage.stage === "synthesize")
1988
+ if (workPackage.stage === "synthesize") {
1989
+ await validateSynthesisDocument(path, content);
1374
1990
  return;
1375
- const { value } = parseStructuredStageOutput(workPackage.stage, content, reviewPacketSha256);
1991
+ }
1376
1992
  if (workPackage.stage === "discover") {
1993
+ const value = parseEvidenceRecord(content);
1377
1994
  await validateEvidenceSources(root, project, value.sources);
1995
+ return;
1996
+ }
1997
+ if (workPackage.stage === "acquire") {
1998
+ parseMaterializedAcquisitionAudit(JSON.parse(content));
1999
+ return;
1378
2000
  }
2001
+ const { value } = parseStructuredStageOutput(workPackage.stage, content, reviewPacketSha256);
1379
2002
  if (workPackage.stage === "analyze") {
1380
2003
  await validateFindings(path, value.findings);
1381
2004
  }
@@ -1449,10 +2072,10 @@ async function validateEvidenceSources(root, project, sources) {
1449
2072
  }
1450
2073
  }
1451
2074
  async function validateFindings(path, findings) {
1452
- const evidencePath = join(dirname(path), "evidence.json");
2075
+ const evidencePath = join(dirname(path), "evidence-snapshot.json");
1453
2076
  const evidence = JSON.parse(await readFile(evidencePath, "utf8"));
1454
2077
  if (!isObject(evidence) || !Array.isArray(evidence.sources)) {
1455
- throw deterministicError("Analysis requires admitted evidence.json.");
2078
+ throw deterministicError("Analysis requires a frozen evidence-snapshot.json.");
1456
2079
  }
1457
2080
  const sourceIds = new Set(evidence.sources
1458
2081
  .filter((source) => isObject(source))
@@ -1475,6 +2098,105 @@ async function validateFindings(path, findings) {
1475
2098
  findingIds.add(finding.id);
1476
2099
  }
1477
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
+ }
1478
2200
  function normalizeEvidenceCoverage(project, value) {
1479
2201
  const inputIds = new Set(project.inputs.map((input) => input.id));
1480
2202
  const sources = (value.sources ?? []).map((source) => {
@@ -1567,13 +2289,16 @@ function computeEvidenceCoverage(project, sources, declared) {
1567
2289
  mechanicalGaps: gaps,
1568
2290
  };
1569
2291
  }
1570
- async function assertEvidenceCoverage(root, project, evidencePath) {
2292
+ async function assertDiscoveryCoverage(root, project, evidencePath) {
1571
2293
  const path = evidencePath ?? resolveContained(projectRoot(root, project.id), "outputs/evidence.json");
1572
2294
  const value = JSON.parse(await readFile(path, "utf8"));
1573
2295
  const sources = value.sources;
1574
2296
  const declared = value.coverage;
1575
2297
  const computed = computeEvidenceCoverage(project, sources, declared);
1576
- 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));
1577
2302
  const requiredCapabilities = requiredDiscoveryCapabilityIds(await loadCapabilityDeclarations(root));
1578
2303
  const exercisedCapabilities = new Set((await loadProjectEvidenceReceipts(root, project.id)).map((receipt) => receipt.capabilityId));
1579
2304
  const journalEvents = await readJournal(workspacePaths(root).journal);
@@ -1619,9 +2344,6 @@ async function assertEvidenceCoverage(root, project, evidencePath) {
1619
2344
  canonicalJson(declared.publicationDateRange) !== canonicalJson(computed.publicationDateRange)) {
1620
2345
  gaps.push("coverage summary does not match admitted sources");
1621
2346
  }
1622
- if (declared.decision !== computed.decision) {
1623
- gaps.push(`coverage decision must be ${computed.decision}`);
1624
- }
1625
2347
  if (gaps.length) {
1626
2348
  throw new CliError("Evidence coverage is insufficient; downstream packages were not started.", {
1627
2349
  code: "RESEARCH_EVIDENCE_INSUFFICIENT",
@@ -1658,9 +2380,14 @@ function publicationDateInterval(value) {
1658
2380
  return { earliest: `${match[1]}-01-01`, latest: `${match[1]}-12-31` };
1659
2381
  }
1660
2382
  async function closeProjectMechanically(root, project, workPackage) {
1661
- 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;
1662
2387
  const required = [
1663
2388
  "outputs/evidence.json",
2389
+ "outputs/acquisition.json",
2390
+ "outputs/evidence-snapshot.json",
1664
2391
  "outputs/analysis.json",
1665
2392
  "outputs/report.md",
1666
2393
  "outputs/review.json",
@@ -1675,6 +2402,7 @@ async function closeProjectMechanically(root, project, workPackage) {
1675
2402
  throw deterministicError("Project review does not bind a valid review packet hash.");
1676
2403
  }
1677
2404
  const reviewPacket = await loadVerifiedReviewPacket(root, project.id, review.packetSha256);
2405
+ await verifyReviewLedgerBinding(root, project.id, snapshot.snapshotId, review.packetSha256);
1678
2406
  const evidenceReceipts = await loadProjectEvidenceReceipts(root, project.id);
1679
2407
  const journal = await verifyJournal(workspacePaths(root).journal);
1680
2408
  const closure = {
@@ -1684,6 +2412,12 @@ async function closeProjectMechanically(root, project, workPackage) {
1684
2412
  closedAt: new Date().toISOString(),
1685
2413
  questionSha256: sha256Text(project.question),
1686
2414
  evidenceRequirements: project.evidenceRequirements,
2415
+ evidenceSnapshot: {
2416
+ snapshotId: snapshot.snapshotId,
2417
+ snapshotSha256: snapshot.snapshotSha256,
2418
+ parentSnapshotId: snapshot.parentSnapshotId,
2419
+ parentSnapshotSha256: snapshot.parentSnapshotSha256,
2420
+ },
1687
2421
  inputs: project.inputs.map((input) => ({
1688
2422
  id: input.id,
1689
2423
  role: input.role,
@@ -1707,6 +2441,75 @@ async function closeProjectMechanically(root, project, workPackage) {
1707
2441
  await writeJsonAtomic(closurePath, closure);
1708
2442
  return zeroExecutionResult();
1709
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
+ }
1710
2513
  async function verifyProjectInputBindings(project) {
1711
2514
  for (const input of project.inputs) {
1712
2515
  const info = await lstat(input.path).catch(() => undefined);
@@ -1746,19 +2549,37 @@ async function verifyProjectInputBindings(project) {
1746
2549
  async function outputRecords(root, project, logicalPaths) {
1747
2550
  return Promise.all(logicalPaths.map((logicalPath) => fileRecord(resolveContained(projectRoot(root, project.id), logicalPath), logicalPath)));
1748
2551
  }
1749
- async function stageContextForPackage(capsuleProject, workPackage, config) {
1750
- const logicalPaths = workPackage.stage === "analyze"
1751
- ? ["outputs/evidence.json"]
1752
- : workPackage.stage === "synthesize"
1753
- ? ["outputs/evidence.json", "outputs/analysis.json"]
1754
- : 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"
1755
2561
  ? [
1756
- "inputs/review-evidence-context.txt",
1757
- "outputs/evidence.json",
1758
- "outputs/analysis.json",
1759
- "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
+ : []),
1760
2566
  ]
1761
- : [];
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
+ : [];
1762
2583
  const sections = [];
1763
2584
  for (const logicalPath of logicalPaths) {
1764
2585
  const content = await readFile(resolveContained(capsuleProject, logicalPath), "utf8");
@@ -1766,13 +2587,17 @@ async function stageContextForPackage(capsuleProject, workPackage, config) {
1766
2587
  }
1767
2588
  const bundled = sections.join("\n\n");
1768
2589
  const estimatedTokens = Math.ceil(Buffer.byteLength(bundled, "utf8") / RESEARCH_ESTIMATED_BYTES_PER_TOKEN);
1769
- const maxStageContextTokens = workPackage.stage === "review"
1770
- ? config.budget.maxInputContextTokens + config.budget.maxOutputTokens * 3
1771
- : workPackage.stage === "synthesize"
1772
- ? config.budget.maxOutputTokens * 2
1773
- : workPackage.stage === "analyze"
1774
- ? config.budget.maxOutputTokens
1775
- : 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;
1776
2601
  if (estimatedTokens > maxStageContextTokens) {
1777
2602
  throw new CliError(`Admitted stage context exceeds the configured input context limit for ${workPackage.id}.`, {
1778
2603
  code: "RESEARCH_INPUT_CONTEXT_BUDGET_EXCEEDED",
@@ -1786,10 +2611,11 @@ async function stageContextForPackage(capsuleProject, workPackage, config) {
1786
2611
  }
1787
2612
  return bundled;
1788
2613
  }
1789
- 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) {
1790
2615
  const stageInstructions = {
1791
- 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.",
1792
- 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.",
1793
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.",
1794
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.`,
1795
2621
  close: "No agent action is allowed for mechanical closure.",
@@ -1805,16 +2631,26 @@ function packagePrompt(project, workPackage, inputs, stagedSkills, capabilityDoc
1805
2631
  `Staged capability directories: ${JSON.stringify(stagedSkills.map((path) => `skills/${basename(path)}`))}`,
1806
2632
  workPackage.stage === "discover"
1807
2633
  ? "The exact capability manifest and each staged external SKILL.md are embedded below. Use this documentation directly; filesystem tools are disabled."
1808
- : "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.",
1809
2637
  workPackage.stage === "discover"
1810
- ? `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.`
1811
2639
  : "Use only the complete embedded stage context; no tools or additional source reads are allowed.",
1812
2640
  stageInstructions[workPackage.stage],
1813
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.",
1814
2642
  "Do not edit project.json, input manifests, prior outputs, evidence objects, or staged capability files.",
1815
2643
  ];
1816
2644
  if (workPackage.stage === "discover") {
1817
- 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);
1818
2654
  }
1819
2655
  if (stageContextContent) {
1820
2656
  prompt.push("The complete admitted stage context is embedded below. Use it directly and do not re-read output files.", stageContextContent);
@@ -1861,11 +2697,12 @@ function assertPreCallTokenReservation(project, workPackage, config, request, al
1861
2697
  });
1862
2698
  }
1863
2699
  }
1864
- function reservePackageBudget(project, workPackage, config) {
2700
+ function reservePackageBudget(project, workPackage, config, requestedTokens) {
1865
2701
  if (workPackage.stage === "close")
1866
2702
  return { tokens: 0, costUsd: 0 };
1867
2703
  const route = workPackage.executor === "reviewer" ? config.reviewer : config.producer;
1868
- const tokens = config.budget.packageMaxTokens[workPackage.stage];
2704
+ const packageMaximum = config.budget.packageMaxTokens[workPackage.stage];
2705
+ const tokens = Math.min(packageMaximum, requestedTokens ?? packageMaximum);
1869
2706
  const costUsd = roundMoney(reservedAgentPackageCost(route, tokens, config));
1870
2707
  const wallSeconds = config.budget.packageMaxWallSeconds[workPackage.stage];
1871
2708
  const remaining = remainingBudget(project, config);
@@ -2206,25 +3043,33 @@ async function summarizeRun(root, requestId, cycles, executed, maxCycles, projec
2206
3043
  usage: project.usage,
2207
3044
  }));
2208
3045
  const unfinished = summaries.filter((project) => project.status !== "complete");
3046
+ const waiting = unfinished.filter((project) => project.status === "waiting-user" || project.status === "waiting-external");
2209
3047
  const hasReadyPackage = summaries.some((project) => project.readyPackage !== null);
2210
3048
  const nativeStageRequired = projects.some((project) => project.packages.some((workPackage) => workPackage.executor === "producer" &&
2211
3049
  (workPackage.status === "ready" || workPackage.status === "running")));
2212
3050
  const status = summaries.length > 0 && summaries.every((project) => project.status === "complete")
2213
3051
  ? "complete"
2214
- : unfinished.length > 0 && unfinished.every((project) => project.status === "blocked")
2215
- ? "blocked"
2216
- : "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";
2217
3060
  const stopReason = summaries.length === 0
2218
3061
  ? "no-projects"
2219
3062
  : status === "complete"
2220
3063
  ? "all-projects-complete"
2221
- : hasReadyPackage && cycles >= maxCycles
2222
- ? "cycle-limit"
2223
- : nativeStageRequired
2224
- ? "native-stage-required"
2225
- : status === "blocked"
2226
- ? "project-blocked"
2227
- : "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";
2228
3073
  return {
2229
3074
  workspace: root,
2230
3075
  requestId,
@@ -2237,7 +3082,26 @@ async function summarizeRun(root, requestId, cycles, executed, maxCycles, projec
2237
3082
  };
2238
3083
  }
2239
3084
  async function projectsForRun(root, projectId) {
2240
- 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");
2241
3105
  }
2242
3106
  function assertPublicEvidenceUrl(value, sourceId) {
2243
3107
  let url;
@@ -2247,6 +3111,9 @@ function assertPublicEvidenceUrl(value, sourceId) {
2247
3111
  catch {
2248
3112
  throw deterministicError(`Evidence source ${sourceId} contains an invalid URL.`);
2249
3113
  }
3114
+ if (url.protocol !== "https:") {
3115
+ throw deterministicError(`Evidence source ${sourceId} URL must use HTTPS.`);
3116
+ }
2250
3117
  if (url.username || url.password) {
2251
3118
  throw deterministicError(`Evidence source ${sourceId} URL contains credentials.`);
2252
3119
  }