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