@tiangong-ai/cli 0.0.31 → 0.0.32

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.
@@ -1,6 +1,6 @@
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";
@@ -16,6 +16,13 @@ import { parseStructuredStageOutput, schemaForStage, StructuredOutputError } fro
16
16
  import { canonicalJson, ensureDirectory, fileRecord, isObject, pathExists, readJsonFile, regularTreeFiles, resolveContained, sha256File, sha256Text, workspacePaths, writeJsonAtomic, writeTextAtomic, } from "./storage.js";
17
17
  import { loadWorkspaceConfig, verifyDoctorAttestation, withWorkspaceLock } from "./workspace.js";
18
18
  export async function runResearchWorkspace(root, options, packageExecutor = executeAgent) {
19
+ return runResearchWorkspaceInternal(root, options, packageExecutor, false);
20
+ }
21
+ /** @internal Test-only seam for exercising deterministic package admission with fake agents. */
22
+ export async function runResearchWorkspaceWithInjectedProducerForTesting(root, options, packageExecutor) {
23
+ return runResearchWorkspaceInternal(root, options, packageExecutor, true);
24
+ }
25
+ async function runResearchWorkspaceInternal(root, options, packageExecutor, allowInjectedProducerForTesting) {
19
26
  validateRunOptions(options);
20
27
  const requestId = randomUUID();
21
28
  if (options.dryRun)
@@ -36,7 +43,7 @@ export async function runResearchWorkspace(root, options, packageExecutor = exec
36
43
  if (config.mode === "production-research") {
37
44
  const verification = await verifyDoctorAttestation(root);
38
45
  if (verification.status !== "verified" || !verification.attestation) {
39
- throw new CliError("Production research requires a current successful producer/reviewer doctor smoke.", {
46
+ throw new CliError("Production research requires a current successful independent-reviewer doctor smoke.", {
40
47
  code: "RESEARCH_DOCTOR_ATTESTATION_REQUIRED",
41
48
  exitCode: 3,
42
49
  details: { status: verification.status, errors: verification.errors },
@@ -61,6 +68,7 @@ export async function runResearchWorkspace(root, options, packageExecutor = exec
61
68
  const selected = projects
62
69
  .map((project) => ({ project, workPackage: nextReadyPackage(project) }))
63
70
  .filter((item) => Boolean(item.workPackage) &&
71
+ (allowInjectedProducerForTesting || item.workPackage?.executor !== "producer") &&
64
72
  item.project.status !== "blocked" &&
65
73
  item.project.status !== "complete")
66
74
  .slice(0, options.maxParallel);
@@ -78,6 +86,405 @@ export async function runResearchWorkspace(root, options, packageExecutor = exec
78
86
  return result;
79
87
  });
80
88
  }
89
+ export async function prepareNativeResearchStage(input) {
90
+ return withWorkspaceLock(input.root, "research.native-stage.prepare", async () => {
91
+ await verifyJournal(workspacePaths(input.root).journal);
92
+ const config = await loadWorkspaceConfig(input.root);
93
+ assertExecutionConfiguration(config);
94
+ if (config.producer.agent !== input.hostAgent) {
95
+ throw new CliError(`This workspace requires the current native ${config.producer.agent} host, not ${input.hostAgent}.`, { code: "RESEARCH_NATIVE_HOST_MISMATCH", exitCode: 3 });
96
+ }
97
+ const capabilityVerification = await verifyCapabilities(input.root);
98
+ if (capabilityVerification.status !== "verified") {
99
+ throw new CliError("Native research requires verified capability locks.", {
100
+ code: "RESEARCH_CAPABILITY_DRIFT",
101
+ exitCode: 3,
102
+ details: capabilityVerification,
103
+ });
104
+ }
105
+ if (config.mode === "production-research") {
106
+ const attestation = await verifyDoctorAttestation(input.root);
107
+ if (attestation.status !== "verified") {
108
+ throw new CliError("Native research requires a current independent-reviewer attestation.", {
109
+ code: "RESEARCH_DOCTOR_ATTESTATION_REQUIRED",
110
+ exitCode: 3,
111
+ details: { status: attestation.status, errors: attestation.errors },
112
+ });
113
+ }
114
+ }
115
+ const project = await loadProject(input.root, input.projectId);
116
+ if (config.mode === "production-research" &&
117
+ config.budget.maxCostUsd > config.budget.confirmationCostUsd &&
118
+ !project.budgetConfirmedAt) {
119
+ throw new CliError("Production research budget has not been explicitly confirmed.", {
120
+ code: "RESEARCH_BUDGET_CONFIRMATION_REQUIRED",
121
+ exitCode: 3,
122
+ });
123
+ }
124
+ const activePath = nativeStageSessionPath(input.root, project.id);
125
+ if (await pathExists(activePath)) {
126
+ const active = await readNativeStageSession(input.root, project.id);
127
+ if (active.packet.stage === input.stage &&
128
+ active.packet.hostAgent === input.hostAgent &&
129
+ project.packages.find((item) => item.id === active.packet.packageId)?.status === "running") {
130
+ await assertNativeStageBinding(input.root, project, active.packet);
131
+ return active.packet;
132
+ }
133
+ throw new CliError("Another native stage session is already active for this project.", {
134
+ code: "RESEARCH_NATIVE_STAGE_ACTIVE",
135
+ exitCode: 3,
136
+ details: { sessionId: active.packet.sessionId, stage: active.packet.stage },
137
+ });
138
+ }
139
+ const workPackage = nextReadyPackage(project);
140
+ if (!workPackage || workPackage.executor !== "producer" || workPackage.stage !== input.stage) {
141
+ throw new CliError(`Stage ${input.stage} is not the next native producer package.`, {
142
+ code: "RESEARCH_NATIVE_STAGE_NOT_READY",
143
+ exitCode: 3,
144
+ details: { readyPackage: workPackage?.id ?? null },
145
+ });
146
+ }
147
+ if (workPackage.attempts >= workPackage.maxAttempts) {
148
+ throw new CliError("Native producer package exhausted its reviewed attempt limit.", {
149
+ code: "RESEARCH_PACKAGE_ATTEMPTS_EXHAUSTED",
150
+ exitCode: 3,
151
+ });
152
+ }
153
+ const reservation = reservePackageBudget(project, workPackage, config);
154
+ const sessionId = randomUUID();
155
+ let capsule = null;
156
+ let preparedStatePersisted = false;
157
+ try {
158
+ capsule = await createCapsule(input.root, project, workPackage, sessionId, config);
159
+ const stageContextContent = await stageContextForPackage(capsule.projectRoot, workPackage, config);
160
+ const declarations = await loadCapabilityDeclarations(input.root);
161
+ 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);
163
+ const prompt = [
164
+ "Perform this producer stage in the current interactive host session. Do not launch codex exec, claude -p, or any other nested reasoning agent.",
165
+ 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.",
168
+ basePrompt,
169
+ "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
+ ].join("\n\n");
171
+ const preparedAt = new Date().toISOString();
172
+ const bindingSha256 = await nativeStageBinding(input.root, project, workPackage);
173
+ const packetCore = {
174
+ schemaVersion: 1,
175
+ kind: "tiangong-native-research-stage",
176
+ sessionId,
177
+ projectId: project.id,
178
+ packageId: workPackage.id,
179
+ stage: input.stage,
180
+ hostAgent: input.hostAgent,
181
+ expectedModel: config.producer.model,
182
+ preparedAt,
183
+ bindingSha256,
184
+ prompt,
185
+ outputSchema: schemaForStage(input.stage, null, input.stage === "discover" && !hasBrokeredEvidence
186
+ ? { inputOnlyProvenanceIds: capsule.inputManifest.map((record) => record.id) }
187
+ : {}),
188
+ limits: {
189
+ maxOutputBytes: config.budget.maxBytesPerPackage,
190
+ maxOutputTokens: config.budget.maxOutputTokens,
191
+ reservedPackageTokens: config.budget.packageMaxTokens[input.stage],
192
+ reservedMaxCostUsd: reservation.costUsd,
193
+ maxWallSeconds: config.budget.packageMaxWallSeconds[input.stage],
194
+ },
195
+ commands: {
196
+ fetchEvidence: input.stage === "discover" && hasBrokeredEvidence
197
+ ? {
198
+ argv: [
199
+ "tiangong-ai",
200
+ "research",
201
+ "project",
202
+ "evidence",
203
+ "fetch",
204
+ project.id,
205
+ "--request",
206
+ "<absolute-request.json>",
207
+ "--workspace",
208
+ input.root,
209
+ "--json",
210
+ ],
211
+ requestSchema: {
212
+ type: "object",
213
+ additionalProperties: false,
214
+ required: ["capability_id", "url"],
215
+ properties: {
216
+ capability_id: { type: "string" },
217
+ credential_id: { type: "string" },
218
+ url: { type: "string", format: "uri" },
219
+ request_body: { type: "object" },
220
+ json_pointer: { type: "string" },
221
+ item_offset: { type: "integer", minimum: 0 },
222
+ max_items: { type: "integer", minimum: 1 },
223
+ cache_mode: { enum: ["prefer", "bypass"] },
224
+ },
225
+ },
226
+ }
227
+ : null,
228
+ submit: {
229
+ argv: [
230
+ "tiangong-ai",
231
+ "research",
232
+ "project",
233
+ "stage",
234
+ "submit",
235
+ project.id,
236
+ "--session",
237
+ sessionId,
238
+ "--output",
239
+ "<absolute-output.json>",
240
+ ...(config.producer.model ? ["--confirm-model", config.producer.model] : []),
241
+ "--workspace",
242
+ input.root,
243
+ "--json",
244
+ ],
245
+ },
246
+ abort: {
247
+ argv: [
248
+ "tiangong-ai",
249
+ "research",
250
+ "project",
251
+ "stage",
252
+ "abort",
253
+ project.id,
254
+ "--session",
255
+ sessionId,
256
+ "--workspace",
257
+ input.root,
258
+ "--json",
259
+ ],
260
+ },
261
+ },
262
+ rules: [
263
+ "Current native host performs producer reasoning; the CLI does not spawn a producer.",
264
+ "Only broker receipts or registered immutable inputs may support discover output.",
265
+ "Do not place credentials, cookies, authorization data, or sensitive URL parameters in request/output files.",
266
+ "A file's existence is not success; submit performs schema, provenance, budget, hash, and atomic-commit checks.",
267
+ ],
268
+ };
269
+ const packet = {
270
+ ...packetCore,
271
+ packetSha256: sha256Text(canonicalJson(packetCore)),
272
+ };
273
+ const sessionCore = {
274
+ schemaVersion: 1,
275
+ kind: "tiangong-native-research-stage-session",
276
+ packet,
277
+ capsuleRoot: capsule.capsuleRoot,
278
+ capsuleProject: capsule.projectRoot,
279
+ };
280
+ const session = {
281
+ ...sessionCore,
282
+ sessionSha256: sha256Text(canonicalJson(sessionCore)),
283
+ };
284
+ const now = new Date().toISOString();
285
+ workPackage.status = "running";
286
+ workPackage.attempts += 1;
287
+ workPackage.startedAt = now;
288
+ workPackage.completedAt = null;
289
+ workPackage.lastError = null;
290
+ workPackage.lastFailureKind = null;
291
+ workPackage.retryNotBefore = null;
292
+ refreshProject(project);
293
+ await ensureDirectory(dirname(activePath));
294
+ await writeJsonAtomic(activePath, session);
295
+ await saveProject(input.root, project);
296
+ preparedStatePersisted = true;
297
+ await appendJournalEvent(workspacePaths(input.root).journal, "native.stage.prepared", project.id, {
298
+ sessionId,
299
+ packetSha256: packet.packetSha256,
300
+ bindingSha256,
301
+ projectId: project.id,
302
+ packageId: workPackage.id,
303
+ stage: input.stage,
304
+ hostAgent: input.hostAgent,
305
+ expectedModel: config.producer.model,
306
+ accountingMode: "reserved-native-host",
307
+ });
308
+ return packet;
309
+ }
310
+ catch (error) {
311
+ if (!preparedStatePersisted) {
312
+ await rm(activePath, { force: true });
313
+ if (capsule)
314
+ await rm(capsule.capsuleRoot, { recursive: true, force: true });
315
+ }
316
+ throw error;
317
+ }
318
+ });
319
+ }
320
+ export async function submitNativeResearchStage(input) {
321
+ return withWorkspaceLock(input.root, "research.native-stage.submit", async () => {
322
+ const session = await readNativeStageSession(input.root, input.projectId);
323
+ if (session.packet.sessionId !== input.sessionId) {
324
+ throw new CliError("Native stage session ID does not match the active session.", {
325
+ code: "RESEARCH_NATIVE_STAGE_SESSION_MISMATCH",
326
+ exitCode: 3,
327
+ });
328
+ }
329
+ const outputPath = requireNativeOutputPath(input.outputPath);
330
+ const outputInfo = await lstat(outputPath).catch(() => undefined);
331
+ if (!outputInfo?.isFile() || outputInfo.isSymbolicLink()) {
332
+ throw new CliError("Native stage output must be an existing regular non-symlink file.", {
333
+ code: "RESEARCH_NATIVE_STAGE_OUTPUT_INVALID",
334
+ exitCode: 2,
335
+ });
336
+ }
337
+ if (outputInfo.size > session.packet.limits.maxOutputBytes) {
338
+ throw new CliError("Native stage output exceeds the reviewed byte limit.", {
339
+ code: "RESEARCH_NATIVE_STAGE_OUTPUT_INVALID",
340
+ exitCode: 3,
341
+ });
342
+ }
343
+ const raw = await readFile(outputPath, "utf8");
344
+ const config = await loadWorkspaceConfig(input.root);
345
+ assertExecutionConfiguration(config);
346
+ if (input.confirmedModel !== session.packet.expectedModel) {
347
+ throw new CliError("The confirmed native model does not match the reviewed route.", {
348
+ code: "RESEARCH_NATIVE_MODEL_MISMATCH",
349
+ exitCode: 3,
350
+ details: { expectedModel: session.packet.expectedModel },
351
+ });
352
+ }
353
+ const project = await loadProject(input.root, input.projectId);
354
+ const workPackage = packageById(project, session.packet.packageId);
355
+ if (workPackage.status !== "running" || workPackage.executor !== "producer") {
356
+ throw new CliError("The bound native producer package is no longer running.", {
357
+ code: "RESEARCH_NATIVE_STAGE_SESSION_MISMATCH",
358
+ exitCode: 3,
359
+ });
360
+ }
361
+ await assertNativeStageBinding(input.root, project, session.packet);
362
+ try {
363
+ await materializeAndValidateStageOutput(input.root, project, session.capsuleProject, workPackage, raw, null);
364
+ const elapsed = Math.max(0.001, (Date.now() - Date.parse(session.packet.preparedAt)) / 1_000);
365
+ const outputTokens = Math.ceil(Buffer.byteLength(raw, "utf8") / RESEARCH_ESTIMATED_BYTES_PER_TOKEN);
366
+ const reservedTokens = config.budget.packageMaxTokens[session.packet.stage];
367
+ const result = {
368
+ exitCode: 0,
369
+ stdout: raw,
370
+ stderr: "",
371
+ tokens: reservedTokens,
372
+ inputTokens: Math.max(0, reservedTokens - outputTokens),
373
+ cachedInputTokens: 0,
374
+ outputTokens,
375
+ costUsd: roundMoney(reservedAgentPackageCost(config.producer, reservedTokens, config)),
376
+ wallSeconds: elapsed,
377
+ model: config.producer.model,
378
+ runtime: null,
379
+ };
380
+ assertActualPackageBudget(project, workPackage, config, result, config.budget.maxOutputTokens);
381
+ assertProjectedBudget(project, config, result);
382
+ if (workPackage.stage === "discover") {
383
+ await assertEvidenceCoverage(input.root, project, resolveContained(session.capsuleProject, "outputs/evidence.json"));
384
+ }
385
+ const outputs = await validateAndImportOutputs(input.root, project, workPackage, session.capsuleProject, config, null);
386
+ applyUsage(project, result);
387
+ workPackage.status = "complete";
388
+ workPackage.completedAt = new Date().toISOString();
389
+ workPackage.lastError = null;
390
+ workPackage.lastFailureKind = null;
391
+ workPackage.retryNotBefore = null;
392
+ refreshProject(project);
393
+ await saveProject(input.root, project);
394
+ await writeRunRecord(input.root, {
395
+ schemaVersion: 1,
396
+ runId: session.packet.sessionId,
397
+ projectId: project.id,
398
+ packageId: workPackage.id,
399
+ executor: config.producer.agent,
400
+ startedAt: session.packet.preparedAt,
401
+ completedAt: workPackage.completedAt,
402
+ exitCode: 0,
403
+ tokens: result.tokens,
404
+ inputTokens: result.inputTokens,
405
+ cachedInputTokens: result.cachedInputTokens,
406
+ outputTokens: result.outputTokens,
407
+ costUsd: result.costUsd,
408
+ wallSeconds: result.wallSeconds,
409
+ outputs,
410
+ stdoutSha256: sha256Text(raw),
411
+ stderrSha256: sha256Text(""),
412
+ failureKind: null,
413
+ failureDetails: null,
414
+ runtime: null,
415
+ accountingMode: "reserved-native-host",
416
+ });
417
+ const usage = { ...usageSlice(result), accountingMode: "reserved-native-host" };
418
+ await appendJournalEvent(workspacePaths(input.root).journal, "native.stage.completed", project.id, {
419
+ sessionId: session.packet.sessionId,
420
+ packetSha256: session.packet.packetSha256,
421
+ projectId: project.id,
422
+ packageId: workPackage.id,
423
+ stage: workPackage.stage,
424
+ outputs,
425
+ usage,
426
+ });
427
+ await rm(nativeStageSessionPath(input.root, project.id), { force: true });
428
+ await rm(session.capsuleRoot, { recursive: true, force: true });
429
+ return {
430
+ projectId: project.id,
431
+ packageId: workPackage.id,
432
+ stage: workPackage.stage,
433
+ status: "complete",
434
+ outputs,
435
+ usage,
436
+ };
437
+ }
438
+ catch (error) {
439
+ await appendJournalEvent(workspacePaths(input.root).journal, "native.stage.submit.rejected", input.projectId, {
440
+ sessionId: session.packet.sessionId,
441
+ packetSha256: session.packet.packetSha256,
442
+ error: bounded(sanitizeResearchText(error instanceof Error ? error.message : String(error)), 1_000),
443
+ });
444
+ throw error;
445
+ }
446
+ });
447
+ }
448
+ export async function abortNativeResearchStage(input) {
449
+ return withWorkspaceLock(input.root, "research.native-stage.abort", async () => {
450
+ const session = await readNativeStageSession(input.root, input.projectId);
451
+ if (session.packet.sessionId !== input.sessionId) {
452
+ throw new CliError("Native stage session ID does not match the active session.", {
453
+ code: "RESEARCH_NATIVE_STAGE_SESSION_MISMATCH",
454
+ exitCode: 3,
455
+ });
456
+ }
457
+ const project = await loadProject(input.root, input.projectId);
458
+ const workPackage = packageById(project, session.packet.packageId);
459
+ if (workPackage.status !== "running") {
460
+ throw new CliError("The native stage package is not running.", {
461
+ code: "RESEARCH_NATIVE_STAGE_SESSION_MISMATCH",
462
+ exitCode: 3,
463
+ });
464
+ }
465
+ workPackage.status = workPackage.attempts < workPackage.maxAttempts ? "retry" : "failed";
466
+ workPackage.completedAt = new Date().toISOString();
467
+ workPackage.lastError = "Native stage was explicitly aborted before submission.";
468
+ workPackage.lastFailureKind = "deterministic";
469
+ workPackage.retryNotBefore = null;
470
+ refreshProject(project);
471
+ await saveProject(input.root, project);
472
+ await appendJournalEvent(workspacePaths(input.root).journal, "native.stage.aborted", project.id, {
473
+ sessionId: session.packet.sessionId,
474
+ packetSha256: session.packet.packetSha256,
475
+ projectId: project.id,
476
+ packageId: workPackage.id,
477
+ stage: workPackage.stage,
478
+ });
479
+ await rm(nativeStageSessionPath(input.root, project.id), { force: true });
480
+ await rm(session.capsuleRoot, { recursive: true, force: true });
481
+ return {
482
+ projectId: project.id,
483
+ packageId: workPackage.id,
484
+ status: project.status === "blocked" ? "blocked" : "ready",
485
+ };
486
+ });
487
+ }
81
488
  async function executeWorkPackage(root, projectId, packageId, config, options, requestId, packageExecutor, doctorAttestation) {
82
489
  const project = await loadProject(root, projectId);
83
490
  const workPackage = packageById(project, packageId);
@@ -523,11 +930,13 @@ async function writeReviewEvidenceContext(root, projectId, capsuleProject, input
523
930
  "TIANGONG REVIEW EVIDENCE CONTEXT v1",
524
931
  "The following are deterministic excerpts from hash-verified bounded views. Full objects and original bounded contexts remain bound in the review packet.",
525
932
  ].join("\n");
933
+ const brokerReferences = await loadBrokerReviewReferences(capsuleProject);
526
934
  const views = [
527
935
  {
528
936
  prefix: "--- LOCAL INPUT CONTEXT BUNDLE ---\n--- BEGIN BOUNDED REVIEW EXCERPT ---\n",
529
- content: inputContextBundle.trimEnd(),
937
+ content: sanitizeResearchText(inputContextBundle.trimEnd()),
530
938
  suffix: "\n--- END BOUNDED REVIEW EXCERPT ---",
939
+ active: true,
531
940
  },
532
941
  ];
533
942
  const seen = new Set();
@@ -536,9 +945,10 @@ async function writeReviewEvidenceContext(root, projectId, capsuleProject, input
536
945
  continue;
537
946
  seen.add(receipt.contextLocator);
538
947
  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.]";
948
+ const references = brokerReferences.get(receipt.attemptId) ?? [];
949
+ const content = references.length
950
+ ? await citedBrokerReviewContent(capsuleProject, receipt, references)
951
+ : "[No admitted evidence source cites this receipt; its raw object and bounded context remain hash-bound in the review packet.]";
542
952
  views.push({
543
953
  prefix: [
544
954
  `--- BROKER RECEIPT ${receipt.attemptId} ---`,
@@ -548,9 +958,13 @@ async function writeReviewEvidenceContext(root, projectId, capsuleProject, input
548
958
  ].join("\n"),
549
959
  content: content.trimEnd(),
550
960
  suffix: "\n--- END BOUNDED REVIEW EXCERPT ---",
961
+ active: references.length > 0,
551
962
  });
552
963
  }
553
- const fixedContent = [header, ...views.map((view) => `${view.prefix}${view.suffix}`)].join("\n\n");
964
+ const fixedContent = [
965
+ header,
966
+ ...views.map((view) => `${view.prefix}${view.active ? "" : view.content}${view.suffix}`),
967
+ ].join("\n\n");
554
968
  const fixedBytes = Buffer.byteLength(`${fixedContent}\n`, "utf8");
555
969
  if (fixedBytes > maxBytes) {
556
970
  throw new CliError("Review evidence metadata exceeds the configured context budget.", {
@@ -559,10 +973,11 @@ async function writeReviewEvidenceContext(root, projectId, capsuleProject, input
559
973
  details: { fixedBytes, maxBytes, views: views.length },
560
974
  });
561
975
  }
562
- const contentBudgetPerView = Math.floor((maxBytes - fixedBytes) / views.length);
976
+ const activeViews = views.filter((view) => view.active).length;
977
+ const contentBudgetPerView = Math.floor((maxBytes - fixedBytes) / activeViews);
563
978
  const sections = [
564
979
  header,
565
- ...views.map((view) => `${view.prefix}${boundedUtf8ReviewExcerpt(view.content, contentBudgetPerView)}${view.suffix}`),
980
+ ...views.map((view) => `${view.prefix}${view.active ? boundedUtf8ReviewExcerpt(view.content, contentBudgetPerView) : view.content}${view.suffix}`),
566
981
  ];
567
982
  const logicalPath = "inputs/review-evidence-context.txt";
568
983
  const path = resolveContained(capsuleProject, logicalPath);
@@ -597,6 +1012,107 @@ async function writeReviewEvidenceContext(root, projectId, capsuleProject, input
597
1012
  persistent: await fileRecord(persistentPath, persistentLogicalPath),
598
1013
  };
599
1014
  }
1015
+ async function loadBrokerReviewReferences(capsuleProject) {
1016
+ const evidencePath = resolveContained(capsuleProject, "outputs/evidence.json");
1017
+ if (!(await pathExists(evidencePath)))
1018
+ return new Map();
1019
+ const evidence = await readJsonFile(evidencePath, "Research evidence");
1020
+ const references = new Map();
1021
+ if (!Array.isArray(evidence.sources))
1022
+ return references;
1023
+ for (const source of evidence.sources) {
1024
+ if (!isObject(source) || !isObject(source.provenance))
1025
+ continue;
1026
+ if (source.provenance.kind !== "broker" || typeof source.provenance.id !== "string")
1027
+ continue;
1028
+ if (typeof source.id !== "string" || typeof source.title !== "string")
1029
+ continue;
1030
+ const current = references.get(source.provenance.id) ?? [];
1031
+ current.push({
1032
+ sourceId: source.id,
1033
+ title: source.title,
1034
+ jsonPointer: typeof source.jsonPointer === "string" ? source.jsonPointer : null,
1035
+ excerpt: typeof source.excerpt === "string" ? source.excerpt : null,
1036
+ });
1037
+ references.set(source.provenance.id, current);
1038
+ }
1039
+ for (const values of references.values()) {
1040
+ values.sort((left, right) => left.sourceId.localeCompare(right.sourceId));
1041
+ }
1042
+ return references;
1043
+ }
1044
+ async function citedBrokerReviewContent(capsuleProject, receipt, references) {
1045
+ let rawValue;
1046
+ if (jsonReviewContentType(receipt.contentType)) {
1047
+ try {
1048
+ rawValue = JSON.parse(await readFile(resolveContained(capsuleProject, receipt.locator), "utf8"));
1049
+ }
1050
+ catch {
1051
+ rawValue = undefined;
1052
+ }
1053
+ }
1054
+ let needsFallback = false;
1055
+ const sections = [
1056
+ "The following items are deterministic, sanitized projections selected from the hash-bound raw broker object by the JSON Pointers declared in admitted evidence.",
1057
+ ];
1058
+ for (const reference of references) {
1059
+ const lines = [
1060
+ `--- CITED EVIDENCE SOURCE ${reference.sourceId} ---`,
1061
+ `title: ${reference.title}`,
1062
+ `jsonPointer: ${reference.jsonPointer ?? "unavailable"}`,
1063
+ ];
1064
+ if (rawValue !== undefined && reference.jsonPointer !== null) {
1065
+ try {
1066
+ const selected = resolveReviewJsonPointer(rawValue, reference.jsonPointer);
1067
+ lines.push("exactItem:", JSON.stringify(selected, null, 2));
1068
+ }
1069
+ catch {
1070
+ needsFallback = true;
1071
+ lines.push("exactItem: [JSON Pointer did not resolve; bounded-context fallback follows.] ");
1072
+ }
1073
+ }
1074
+ else {
1075
+ needsFallback = true;
1076
+ lines.push("exactItem: [Exact JSON item unavailable; bounded-context fallback follows.] ");
1077
+ }
1078
+ if (reference.excerpt !== null)
1079
+ lines.push(`declaredExcerpt: ${reference.excerpt}`);
1080
+ sections.push(lines.join("\n"));
1081
+ }
1082
+ if (needsFallback) {
1083
+ const fallback = reviewableTextContentType(receipt.contentType)
1084
+ ? await readFile(resolveContained(capsuleProject, receipt.contextLocator), "utf8")
1085
+ : "[Binary bounded view omitted from model context; verify the bound file mechanically.]";
1086
+ sections.push(`--- BOUNDED CONTEXT FALLBACK ---\n${fallback.trimEnd()}`);
1087
+ }
1088
+ return sanitizeResearchText(sections.join("\n\n"));
1089
+ }
1090
+ function jsonReviewContentType(contentType) {
1091
+ return /^application\/(?:[^;]+\+)?json(?:;|$)/i.test(contentType);
1092
+ }
1093
+ function resolveReviewJsonPointer(value, pointer) {
1094
+ if (pointer === "")
1095
+ return value;
1096
+ if (!pointer.startsWith("/") || /~(?:[^01]|$)/.test(pointer)) {
1097
+ throw new Error("invalid JSON Pointer");
1098
+ }
1099
+ let selected = value;
1100
+ for (const rawPart of pointer.slice(1).split("/")) {
1101
+ const part = rawPart.replaceAll("~1", "/").replaceAll("~0", "~");
1102
+ if (Array.isArray(selected) &&
1103
+ /^(0|[1-9][0-9]*)$/.test(part) &&
1104
+ Number(part) < selected.length) {
1105
+ selected = selected[Number(part)];
1106
+ }
1107
+ else if (isObject(selected) && Object.hasOwn(selected, part)) {
1108
+ selected = selected[part];
1109
+ }
1110
+ else {
1111
+ throw new Error("JSON Pointer does not resolve");
1112
+ }
1113
+ }
1114
+ return selected;
1115
+ }
600
1116
  function reviewableTextContentType(contentType) {
601
1117
  return /^(?:text\/|application\/(?:[^;]+\+)?(?:json|xml|javascript|xhtml\+xml|csv))(?:;|$)/i.test(contentType);
602
1118
  }
@@ -1051,8 +1567,8 @@ function computeEvidenceCoverage(project, sources, declared) {
1051
1567
  mechanicalGaps: gaps,
1052
1568
  };
1053
1569
  }
1054
- async function assertEvidenceCoverage(root, project) {
1055
- const path = resolveContained(projectRoot(root, project.id), "outputs/evidence.json");
1570
+ async function assertEvidenceCoverage(root, project, evidencePath) {
1571
+ const path = evidencePath ?? resolveContained(projectRoot(root, project.id), "outputs/evidence.json");
1056
1572
  const value = JSON.parse(await readFile(path, "utf8"));
1057
1573
  const sources = value.sources;
1058
1574
  const declared = value.coverage;
@@ -1443,6 +1959,10 @@ function remainingWallSeconds(project, config) {
1443
1959
  return Math.max(1, Math.floor(config.budget.maxWallSeconds - project.usage.wallSeconds));
1444
1960
  }
1445
1961
  function assertExecutionConfiguration(config) {
1962
+ if (config.producer.executionMode !== "native-host" ||
1963
+ config.reviewer.executionMode !== "headless-cli") {
1964
+ throw new CliError("Research requires a native-host producer and a separate headless-CLI reviewer.", { code: "RESEARCH_EXECUTION_MODE_INVALID", exitCode: 3 });
1965
+ }
1446
1966
  if (config.producer.agent === config.reviewer.agent) {
1447
1967
  throw new CliError("Research producer and reviewer must use different agent families.", {
1448
1968
  code: "RESEARCH_REVIEW_ROUTE_INVALID",
@@ -1687,6 +2207,8 @@ async function summarizeRun(root, requestId, cycles, executed, maxCycles, projec
1687
2207
  }));
1688
2208
  const unfinished = summaries.filter((project) => project.status !== "complete");
1689
2209
  const hasReadyPackage = summaries.some((project) => project.readyPackage !== null);
2210
+ const nativeStageRequired = projects.some((project) => project.packages.some((workPackage) => workPackage.executor === "producer" &&
2211
+ (workPackage.status === "ready" || workPackage.status === "running")));
1690
2212
  const status = summaries.length > 0 && summaries.every((project) => project.status === "complete")
1691
2213
  ? "complete"
1692
2214
  : unfinished.length > 0 && unfinished.every((project) => project.status === "blocked")
@@ -1698,9 +2220,11 @@ async function summarizeRun(root, requestId, cycles, executed, maxCycles, projec
1698
2220
  ? "all-projects-complete"
1699
2221
  : hasReadyPackage && cycles >= maxCycles
1700
2222
  ? "cycle-limit"
1701
- : status === "blocked"
1702
- ? "project-blocked"
1703
- : "no-ready-work";
2223
+ : nativeStageRequired
2224
+ ? "native-stage-required"
2225
+ : status === "blocked"
2226
+ ? "project-blocked"
2227
+ : "no-ready-work";
1704
2228
  return {
1705
2229
  workspace: root,
1706
2230
  requestId,
@@ -1737,6 +2261,120 @@ function deterministicError(message) {
1737
2261
  function projectRoot(root, projectId) {
1738
2262
  return join(workspacePaths(root).projects, projectId);
1739
2263
  }
2264
+ function nativeStageSessionPath(root, projectId) {
2265
+ return join(projectRoot(root, projectId), "native", "active.json");
2266
+ }
2267
+ async function readNativeStageSession(root, projectId) {
2268
+ const path = nativeStageSessionPath(root, projectId);
2269
+ const info = await lstat(path).catch(() => undefined);
2270
+ if (!info?.isFile() || info.isSymbolicLink()) {
2271
+ throw new CliError("No valid native stage session is active for this project.", {
2272
+ code: "RESEARCH_NATIVE_STAGE_SESSION_REQUIRED",
2273
+ exitCode: 3,
2274
+ });
2275
+ }
2276
+ const value = await readJsonFile(path, "Native research stage session");
2277
+ if (!isObject(value) ||
2278
+ value.schemaVersion !== 1 ||
2279
+ value.kind !== "tiangong-native-research-stage-session" ||
2280
+ !isObject(value.packet) ||
2281
+ typeof value.capsuleRoot !== "string" ||
2282
+ typeof value.capsuleProject !== "string" ||
2283
+ typeof value.sessionSha256 !== "string") {
2284
+ throw new CliError("Native stage session has an unsupported shape.", {
2285
+ code: "RESEARCH_NATIVE_STAGE_SESSION_INVALID",
2286
+ exitCode: 3,
2287
+ });
2288
+ }
2289
+ const { sessionSha256, ...core } = value;
2290
+ if (sha256Text(canonicalJson(core)) !== sessionSha256) {
2291
+ throw new CliError("Native stage session failed its content hash.", {
2292
+ code: "RESEARCH_NATIVE_STAGE_SESSION_INVALID",
2293
+ exitCode: 3,
2294
+ });
2295
+ }
2296
+ const packet = value.packet;
2297
+ const { packetSha256, ...packetCore } = packet;
2298
+ if (packet.schemaVersion !== 1 ||
2299
+ packet.kind !== "tiangong-native-research-stage" ||
2300
+ packet.projectId !== projectId ||
2301
+ typeof packetSha256 !== "string" ||
2302
+ sha256Text(canonicalJson(packetCore)) !== packetSha256) {
2303
+ throw new CliError("Native stage packet failed its content hash or project binding.", {
2304
+ code: "RESEARCH_NATIVE_STAGE_SESSION_INVALID",
2305
+ exitCode: 3,
2306
+ });
2307
+ }
2308
+ const runtimeRoot = resolve(workspacePaths(root).runtime);
2309
+ const capsuleRoot = resolve(value.capsuleRoot);
2310
+ const capsuleProject = resolve(value.capsuleProject);
2311
+ if (relative(runtimeRoot, capsuleRoot).startsWith("..") ||
2312
+ relative(capsuleRoot, capsuleProject).startsWith("..") ||
2313
+ !(await lstat(capsuleRoot).catch(() => undefined))?.isDirectory() ||
2314
+ !(await lstat(capsuleProject).catch(() => undefined))?.isDirectory()) {
2315
+ throw new CliError("Native stage capsule is missing or outside the workspace runtime.", {
2316
+ code: "RESEARCH_NATIVE_STAGE_SESSION_INVALID",
2317
+ exitCode: 3,
2318
+ });
2319
+ }
2320
+ return value;
2321
+ }
2322
+ async function nativeStageBinding(root, project, workPackage) {
2323
+ const paths = workspacePaths(root);
2324
+ const outputRoot = join(projectRoot(root, project.id), "outputs");
2325
+ const outputs = [];
2326
+ if (await pathExists(outputRoot)) {
2327
+ for (const path of await regularTreeFiles(outputRoot)) {
2328
+ outputs.push(await fileRecord(path, relative(projectRoot(root, project.id), path).replaceAll("\\", "/")));
2329
+ }
2330
+ }
2331
+ return sha256Text(canonicalJson({
2332
+ projectId: project.id,
2333
+ questionSha256: sha256Text(project.question),
2334
+ evidenceRequirements: project.evidenceRequirements,
2335
+ inputs: project.inputs.map((record) => ({
2336
+ id: record.id,
2337
+ role: record.role,
2338
+ sha256: record.sha256,
2339
+ bytes: record.bytes,
2340
+ contextSha256: record.contextSha256 ?? null,
2341
+ contextBytes: record.contextBytes ?? null,
2342
+ })),
2343
+ package: {
2344
+ id: workPackage.id,
2345
+ stage: workPackage.stage,
2346
+ dependencies: workPackage.dependencies,
2347
+ expectedOutputs: workPackage.expectedOutputs,
2348
+ },
2349
+ outputs,
2350
+ configSha256: await sha256File(paths.config),
2351
+ runtimeLockSha256: await sha256File(paths.runtimeLock),
2352
+ capabilityDeclarationsSha256: await sha256File(paths.capabilityDeclarations),
2353
+ capabilityLockSha256: (await pathExists(paths.capabilityLock))
2354
+ ? await sha256File(paths.capabilityLock)
2355
+ : null,
2356
+ }));
2357
+ }
2358
+ async function assertNativeStageBinding(root, project, packet) {
2359
+ const workPackage = packageById(project, packet.packageId);
2360
+ const actual = await nativeStageBinding(root, project, workPackage);
2361
+ if (actual !== packet.bindingSha256) {
2362
+ throw new CliError("Native stage inputs, configuration, or admitted outputs drifted.", {
2363
+ code: "RESEARCH_NATIVE_STAGE_BINDING_DRIFT",
2364
+ exitCode: 3,
2365
+ details: { expectedSha256: packet.bindingSha256, actualSha256: actual },
2366
+ });
2367
+ }
2368
+ }
2369
+ function requireNativeOutputPath(value) {
2370
+ if (!value || !isAbsolute(value) || /[\0\r\n]/.test(value)) {
2371
+ throw new CliError("Native stage output requires an explicit absolute file path.", {
2372
+ code: "RESEARCH_NATIVE_STAGE_OUTPUT_INVALID",
2373
+ exitCode: 2,
2374
+ });
2375
+ }
2376
+ return resolve(value);
2377
+ }
1740
2378
  function validateRunOptions(options) {
1741
2379
  if (!Number.isInteger(options.maxParallel) ||
1742
2380
  options.maxParallel < 1 ||