@tiangong-ai/cli 0.0.19 → 0.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -2
- package/README.md +209 -4
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/research/commands.js +6 -0
- package/dist/research/commands.js.map +1 -1
- package/dist/research/orchestration.d.ts +3 -0
- package/dist/research/orchestration.js +391 -0
- package/dist/research/orchestration.js.map +1 -0
- package/dist/research/workspace/broker.d.ts +5 -0
- package/dist/research/workspace/broker.js +729 -0
- package/dist/research/workspace/broker.js.map +1 -0
- package/dist/research/workspace/capabilities.d.ts +10 -0
- package/dist/research/workspace/capabilities.js +356 -0
- package/dist/research/workspace/capabilities.js.map +1 -0
- package/dist/research/workspace/constants.d.ts +8 -0
- package/dist/research/workspace/constants.js +41 -0
- package/dist/research/workspace/constants.js.map +1 -0
- package/dist/research/workspace/context.d.ts +3 -0
- package/dist/research/workspace/context.js +77 -0
- package/dist/research/workspace/context.js.map +1 -0
- package/dist/research/workspace/evidence.d.ts +32 -0
- package/dist/research/workspace/evidence.js +235 -0
- package/dist/research/workspace/evidence.js.map +1 -0
- package/dist/research/workspace/executor.d.ts +22 -0
- package/dist/research/workspace/executor.js +926 -0
- package/dist/research/workspace/executor.js.map +1 -0
- package/dist/research/workspace/input-plan.d.ts +5 -0
- package/dist/research/workspace/input-plan.js +319 -0
- package/dist/research/workspace/input-plan.js.map +1 -0
- package/dist/research/workspace/journal.d.ts +7 -0
- package/dist/research/workspace/journal.js +105 -0
- package/dist/research/workspace/journal.js.map +1 -0
- package/dist/research/workspace/preflight.d.ts +108 -0
- package/dist/research/workspace/preflight.js +261 -0
- package/dist/research/workspace/preflight.js.map +1 -0
- package/dist/research/workspace/projects.d.ts +12 -0
- package/dist/research/workspace/projects.js +514 -0
- package/dist/research/workspace/projects.js.map +1 -0
- package/dist/research/workspace/runtime.d.ts +31 -0
- package/dist/research/workspace/runtime.js +1637 -0
- package/dist/research/workspace/runtime.js.map +1 -0
- package/dist/research/workspace/sanitization.d.ts +5 -0
- package/dist/research/workspace/sanitization.js +72 -0
- package/dist/research/workspace/sanitization.js.map +1 -0
- package/dist/research/workspace/schemas.d.ts +17 -0
- package/dist/research/workspace/schemas.js +342 -0
- package/dist/research/workspace/schemas.js.map +1 -0
- package/dist/research/workspace/storage.d.ts +25 -0
- package/dist/research/workspace/storage.js +222 -0
- package/dist/research/workspace/storage.js.map +1 -0
- package/dist/research/workspace/types.d.ts +341 -0
- package/dist/research/workspace/types.js +2 -0
- package/dist/research/workspace/types.js.map +1 -0
- package/dist/research/workspace/workspace.d.ts +23 -0
- package/dist/research/workspace/workspace.js +702 -0
- package/dist/research/workspace/workspace.js.map +1 -0
- package/package.json +4 -2
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
4
|
+
import { CliError } from "../../errors.js";
|
|
5
|
+
import { declaredCredentialIds, verifyCapabilities } from "./capabilities.js";
|
|
6
|
+
import { packageVersion, RESEARCH_PACKAGE_NAME, RESEARCH_PROTOCOL_VERSION } from "./constants.js";
|
|
7
|
+
import { inspectResearchContext, isWorkspaceMarker } from "./context.js";
|
|
8
|
+
import { appendJournalEvent, verifyJournal } from "./journal.js";
|
|
9
|
+
import { loadProjectEvidenceReceipts } from "./evidence.js";
|
|
10
|
+
import { executeAgent } from "./executor.js";
|
|
11
|
+
import { parseStructuredStageOutput, schemaForStage } from "./schemas.js";
|
|
12
|
+
import { acquireFileLock, canonicalJson, ensureDirectory, isObject, pathExists, readJsonFile, requireAbsolutePath, sha256File, sha256Text, workspacePaths, writeJsonAtomic, } from "./storage.js";
|
|
13
|
+
const DOCTOR_ATTESTATION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
const DEFAULT_BUDGET = {
|
|
15
|
+
maxTokens: 320_000,
|
|
16
|
+
maxCostUsd: 60,
|
|
17
|
+
maxWallSeconds: 72 * 60 * 60,
|
|
18
|
+
maxFilesPerPackage: 20,
|
|
19
|
+
maxBytesPerPackage: 20 * 1024 * 1024,
|
|
20
|
+
maxAttemptsPerPackage: 3,
|
|
21
|
+
confirmationCostUsd: 10,
|
|
22
|
+
packageMaxTokens: {
|
|
23
|
+
discover: 80_000,
|
|
24
|
+
analyze: 55_000,
|
|
25
|
+
synthesize: 60_000,
|
|
26
|
+
review: 120_000,
|
|
27
|
+
},
|
|
28
|
+
packageMaxWallSeconds: {
|
|
29
|
+
discover: 2 * 60 * 60,
|
|
30
|
+
analyze: 2 * 60 * 60,
|
|
31
|
+
synthesize: 60 * 60,
|
|
32
|
+
review: 60 * 60,
|
|
33
|
+
},
|
|
34
|
+
maxOutputTokens: 4_000,
|
|
35
|
+
maxRepairTokens: 4_000,
|
|
36
|
+
maxBrokerResponseBytes: 512 * 1024,
|
|
37
|
+
maxBrokerContextTokens: 12_000,
|
|
38
|
+
maxBrokerItems: 100,
|
|
39
|
+
maxInputContextTokens: 12_000,
|
|
40
|
+
};
|
|
41
|
+
export async function initializeResearchWorkspace(targetPath, name, mode = "smoke-test") {
|
|
42
|
+
const root = requireAbsolutePath(targetPath, "Workspace path");
|
|
43
|
+
await mkdir(root, { recursive: true, mode: 0o755 });
|
|
44
|
+
const selectedInfo = await lstat(root);
|
|
45
|
+
if (!selectedInfo.isDirectory() || selectedInfo.isSymbolicLink()) {
|
|
46
|
+
throw new CliError(`Workspace path must be a regular directory: ${root}`, {
|
|
47
|
+
code: "RESEARCH_WORKSPACE_PATH_INVALID",
|
|
48
|
+
exitCode: 2,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const paths = workspacePaths(root);
|
|
52
|
+
if (await pathExists(paths.control)) {
|
|
53
|
+
throw new CliError(`Research workspace state already exists: ${paths.control}`, {
|
|
54
|
+
code: "RESEARCH_WORKSPACE_EXISTS",
|
|
55
|
+
exitCode: 2,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const workspaceName = normalizeWorkspaceName(name ?? basename(root));
|
|
59
|
+
const workspaceId = randomUUID();
|
|
60
|
+
const now = new Date().toISOString();
|
|
61
|
+
const marker = {
|
|
62
|
+
schemaVersion: 1,
|
|
63
|
+
kind: "tiangong-research-workspace",
|
|
64
|
+
workspaceId,
|
|
65
|
+
name: workspaceName,
|
|
66
|
+
createdAt: now,
|
|
67
|
+
};
|
|
68
|
+
const config = {
|
|
69
|
+
schemaVersion: 1,
|
|
70
|
+
mode,
|
|
71
|
+
producer: {
|
|
72
|
+
agent: "codex",
|
|
73
|
+
binary: "codex",
|
|
74
|
+
model: null,
|
|
75
|
+
effort: "low",
|
|
76
|
+
verbosity: "low",
|
|
77
|
+
},
|
|
78
|
+
reviewer: { agent: "claude", binary: "claude", model: null, effort: "low" },
|
|
79
|
+
budget: { ...DEFAULT_BUDGET },
|
|
80
|
+
};
|
|
81
|
+
const runtimeLock = {
|
|
82
|
+
schemaVersion: 1,
|
|
83
|
+
protocolVersion: RESEARCH_PROTOCOL_VERSION,
|
|
84
|
+
packageName: RESEARCH_PACKAGE_NAME,
|
|
85
|
+
packageVersion: packageVersion(),
|
|
86
|
+
workspaceId,
|
|
87
|
+
};
|
|
88
|
+
await ensureDirectory(paths.control);
|
|
89
|
+
await Promise.all([
|
|
90
|
+
ensureDirectory(paths.evidenceCache),
|
|
91
|
+
ensureDirectory(paths.evidenceObjects),
|
|
92
|
+
ensureDirectory(paths.projects),
|
|
93
|
+
ensureDirectory(paths.runtime),
|
|
94
|
+
ensureDirectory(paths.locks),
|
|
95
|
+
]);
|
|
96
|
+
await writeJsonAtomic(paths.marker, marker);
|
|
97
|
+
await writeJsonAtomic(paths.config, config);
|
|
98
|
+
await writeJsonAtomic(paths.runtimeLock, runtimeLock);
|
|
99
|
+
await writeJsonAtomic(paths.capabilityDeclarations, { schemaVersion: 1, capabilities: [] });
|
|
100
|
+
await writeFile(paths.envExample, [
|
|
101
|
+
"# Map capability-declared logical credential IDs to owner-provided values.",
|
|
102
|
+
"TIANGONG_RESEARCH_CAPABILITY_CREDENTIALS_JSON={}",
|
|
103
|
+
"",
|
|
104
|
+
].join("\n"), { encoding: "utf8", mode: 0o600 });
|
|
105
|
+
await writeFile(join(paths.control, ".gitignore"), ".env\nlocks/\nruntime/\n", "utf8");
|
|
106
|
+
await appendJournalEvent(paths.journal, "workspace.initialized", workspaceId, {
|
|
107
|
+
workspaceId,
|
|
108
|
+
protocolVersion: RESEARCH_PROTOCOL_VERSION,
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
workspace: root,
|
|
112
|
+
workspaceId,
|
|
113
|
+
created: [
|
|
114
|
+
paths.marker,
|
|
115
|
+
paths.config,
|
|
116
|
+
paths.runtimeLock,
|
|
117
|
+
paths.capabilityDeclarations,
|
|
118
|
+
paths.envExample,
|
|
119
|
+
paths.journal,
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export async function requireResearchWorkspace(inputPath) {
|
|
124
|
+
const inspection = await inspectResearchContext(inputPath);
|
|
125
|
+
if (inspection.role !== "workspace" || !inspection.root) {
|
|
126
|
+
throw new CliError(`Path is not inside a valid Tiangong research workspace: ${inputPath}`, {
|
|
127
|
+
code: "RESEARCH_WORKSPACE_REQUIRED",
|
|
128
|
+
exitCode: 2,
|
|
129
|
+
details: inspection,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return inspection.root;
|
|
133
|
+
}
|
|
134
|
+
export async function loadWorkspaceMarker(root) {
|
|
135
|
+
const marker = await readJsonFile(workspacePaths(root).marker, "Research workspace marker");
|
|
136
|
+
if (!isWorkspaceMarker(marker)) {
|
|
137
|
+
throw new CliError("Research workspace marker has an unsupported shape.", {
|
|
138
|
+
code: "RESEARCH_WORKSPACE_INVALID",
|
|
139
|
+
exitCode: 2,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return marker;
|
|
143
|
+
}
|
|
144
|
+
export async function loadWorkspaceConfig(root) {
|
|
145
|
+
const config = await readJsonFile(workspacePaths(root).config, "Research configuration");
|
|
146
|
+
if (!isWorkspaceConfig(config)) {
|
|
147
|
+
throw new CliError("Research configuration has an unsupported shape.", {
|
|
148
|
+
code: "RESEARCH_CONFIG_INVALID",
|
|
149
|
+
exitCode: 2,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return config;
|
|
153
|
+
}
|
|
154
|
+
export async function doctorResearchWorkspace(inputPath, options = {}) {
|
|
155
|
+
const workspace = await requireResearchWorkspace(inputPath);
|
|
156
|
+
const paths = workspacePaths(workspace);
|
|
157
|
+
const checks = [];
|
|
158
|
+
const marker = await checked(checks, "workspace-marker", async () => {
|
|
159
|
+
const value = await loadWorkspaceMarker(workspace);
|
|
160
|
+
return { value, detail: `workspaceId=${value.workspaceId}` };
|
|
161
|
+
});
|
|
162
|
+
const config = await checked(checks, "workspace-config", async () => {
|
|
163
|
+
const value = await loadWorkspaceConfig(workspace);
|
|
164
|
+
return {
|
|
165
|
+
value,
|
|
166
|
+
detail: `producer=${value.producer.agent} reviewer=${value.reviewer.agent}`,
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
await checked(checks, "runtime-lock", async () => {
|
|
170
|
+
const lock = await requireCurrentRuntimeLock(workspace, marker);
|
|
171
|
+
return { value: lock, detail: `${lock.packageName}@${lock.packageVersion}` };
|
|
172
|
+
});
|
|
173
|
+
await checked(checks, "journal-chain", async () => {
|
|
174
|
+
const result = await verifyJournal(paths.journal);
|
|
175
|
+
return { value: result, detail: `${result.events} event(s), head=${result.head.slice(0, 12)}` };
|
|
176
|
+
});
|
|
177
|
+
const credentialIds = await checked(checks, "capability-policy", async () => {
|
|
178
|
+
const result = await verifyCapabilities(workspace);
|
|
179
|
+
if (result.status !== "verified") {
|
|
180
|
+
throw new Error(result.errors.join("; ") || "capability verification failed");
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
value: await declaredCredentialIds(workspace),
|
|
184
|
+
detail: `${result.checked} locked capability declaration(s)`,
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
await checked(checks, "credential-environment", async () => {
|
|
188
|
+
const result = await inspectCredentialEnvironment(paths.env, credentialIds ?? new Set());
|
|
189
|
+
return { value: result, detail: result };
|
|
190
|
+
});
|
|
191
|
+
await checked(checks, "project-state", async () => {
|
|
192
|
+
const projects = await readProjectStates(paths.projects);
|
|
193
|
+
return { value: projects, detail: `${projects.length} project(s)` };
|
|
194
|
+
});
|
|
195
|
+
await checked(checks, "evidence-store", async () => {
|
|
196
|
+
const projects = await readProjectStates(paths.projects);
|
|
197
|
+
let receipts = 0;
|
|
198
|
+
for (const project of projects) {
|
|
199
|
+
receipts += (await loadProjectEvidenceReceipts(workspace, project.id)).length;
|
|
200
|
+
}
|
|
201
|
+
return { value: receipts, detail: `${receipts} verified broker evidence receipt(s)` };
|
|
202
|
+
});
|
|
203
|
+
if (config && config.producer.agent === config.reviewer.agent) {
|
|
204
|
+
checks.push({
|
|
205
|
+
id: "independent-review-route",
|
|
206
|
+
status: "fail",
|
|
207
|
+
detail: "Producer and reviewer must use different agent families.",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
else if (config) {
|
|
211
|
+
checks.push({
|
|
212
|
+
id: "independent-review-route",
|
|
213
|
+
status: "pass",
|
|
214
|
+
detail: `${config.producer.agent} -> ${config.reviewer.agent}`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
if (config?.mode === "production-research" &&
|
|
218
|
+
(!config.producer.model || !config.reviewer.model)) {
|
|
219
|
+
checks.push({
|
|
220
|
+
id: "explicit-model-routes",
|
|
221
|
+
status: "fail",
|
|
222
|
+
detail: "Production research requires explicit producer and reviewer model IDs.",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
else if (config) {
|
|
226
|
+
checks.push({
|
|
227
|
+
id: "explicit-model-routes",
|
|
228
|
+
status: config.mode === "production-research" ? "pass" : "warn",
|
|
229
|
+
detail: config.mode === "production-research"
|
|
230
|
+
? `${config.producer.model} -> ${config.reviewer.model}`
|
|
231
|
+
: "Smoke-test mode does not require pinned model IDs.",
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
if (config?.mode === "production-research" &&
|
|
235
|
+
(!config.producer.pricing || !config.reviewer.pricing)) {
|
|
236
|
+
checks.push({
|
|
237
|
+
id: "explicit-agent-pricing",
|
|
238
|
+
status: "fail",
|
|
239
|
+
detail: "Production research requires explicit input, cached-input, and output prices.",
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
else if (config) {
|
|
243
|
+
checks.push({
|
|
244
|
+
id: "explicit-agent-pricing",
|
|
245
|
+
status: config.mode === "production-research" ? "pass" : "warn",
|
|
246
|
+
detail: config.mode === "production-research"
|
|
247
|
+
? "Producer and reviewer pricing are configured."
|
|
248
|
+
: "Smoke-test mode permits zero/unknown price accounting.",
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
if (config) {
|
|
252
|
+
if (options.agentSmoke) {
|
|
253
|
+
const smokeResults = [];
|
|
254
|
+
for (const route of [config.producer, config.reviewer]) {
|
|
255
|
+
const result = await checked(checks, `agent-sandbox-smoke.${route.agent}`, async () => {
|
|
256
|
+
const value = await runAgentSmokeCheck(workspace, config, route, options.environment ?? process.env, options.executor ?? executeAgent);
|
|
257
|
+
return { value, detail: agentSmokeDetail(value) };
|
|
258
|
+
});
|
|
259
|
+
if (result)
|
|
260
|
+
smokeResults.push(result);
|
|
261
|
+
}
|
|
262
|
+
const smoke = smokeResults.length === 2
|
|
263
|
+
? {
|
|
264
|
+
runtimes: smokeResults
|
|
265
|
+
.map((result) => result.runtime)
|
|
266
|
+
.sort((left, right) => left.agent.localeCompare(right.agent)),
|
|
267
|
+
smokeUsage: smokeResults
|
|
268
|
+
.map((result) => result.usage)
|
|
269
|
+
.sort((left, right) => left.agent.localeCompare(right.agent)),
|
|
270
|
+
}
|
|
271
|
+
: undefined;
|
|
272
|
+
checks.push({
|
|
273
|
+
id: "agent-sandbox-smoke",
|
|
274
|
+
status: smoke ? "pass" : "fail",
|
|
275
|
+
detail: smoke
|
|
276
|
+
? "Both isolated producer and reviewer smoke checks passed."
|
|
277
|
+
: `${smokeResults.length}/2 isolated agent smoke checks passed.`,
|
|
278
|
+
});
|
|
279
|
+
if (smoke &&
|
|
280
|
+
config.mode === "production-research" &&
|
|
281
|
+
!checks.some((check) => check.status === "fail")) {
|
|
282
|
+
await checked(checks, "doctor-attestation", async () => {
|
|
283
|
+
const value = await persistDoctorAttestation(workspace, config, marker, smoke);
|
|
284
|
+
return {
|
|
285
|
+
value,
|
|
286
|
+
detail: `valid until ${value.expiresAt}; sha256=${value.attestationSha256.slice(0, 12)}`,
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
checks.push({
|
|
293
|
+
id: "agent-sandbox-smoke",
|
|
294
|
+
status: config.mode === "production-research" ? "fail" : "warn",
|
|
295
|
+
detail: "Producer/reviewer execution smoke was not run; rerun doctor with --agent-smoke before production research.",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
workspace,
|
|
301
|
+
status: checks.some((check) => check.status === "fail") ? "blocked" : "ready",
|
|
302
|
+
checks,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
async function runAgentSmokeCheck(workspace, config, route, environment, executor) {
|
|
306
|
+
const smokeRoot = join(workspacePaths(workspace).runtime, `doctor-${route.agent}-${randomUUID()}`);
|
|
307
|
+
await ensureDirectory(smokeRoot);
|
|
308
|
+
try {
|
|
309
|
+
const projectRoot = join(smokeRoot, "project");
|
|
310
|
+
await ensureDirectory(projectRoot);
|
|
311
|
+
const result = await executor({
|
|
312
|
+
route,
|
|
313
|
+
prompt: 'Return exactly the JSON object {"ok":true}. This checks executable, authentication, structured output, and capsule sandbox readiness only.',
|
|
314
|
+
outputSchema: schemaForStage("doctor"),
|
|
315
|
+
requestId: randomUUID(),
|
|
316
|
+
purpose: "doctor",
|
|
317
|
+
capsuleRoot: smokeRoot,
|
|
318
|
+
projectRoot,
|
|
319
|
+
workspaceRoot: workspace,
|
|
320
|
+
timeoutSeconds: 120,
|
|
321
|
+
maxTurns: 2,
|
|
322
|
+
maxOutputTokens: 128,
|
|
323
|
+
maxCostUsd: Math.min(0.25, config.budget.maxCostUsd),
|
|
324
|
+
toolPolicy: "none",
|
|
325
|
+
environment,
|
|
326
|
+
brokerUrl: null,
|
|
327
|
+
});
|
|
328
|
+
if (result.exitCode !== 0) {
|
|
329
|
+
const diagnostic = [result.stderr.trim(), result.stdout.trim()]
|
|
330
|
+
.filter(Boolean)
|
|
331
|
+
.join("\n")
|
|
332
|
+
.slice(0, 2_000);
|
|
333
|
+
throw new Error(`${route.agent} smoke failed: ${diagnostic || `executor exited ${result.exitCode}`}`);
|
|
334
|
+
}
|
|
335
|
+
parseStructuredStageOutput("doctor", result.stdout);
|
|
336
|
+
if (!result.runtime) {
|
|
337
|
+
throw new Error(`${route.agent} smoke did not return a runtime fingerprint`);
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
runtime: result.runtime,
|
|
341
|
+
usage: {
|
|
342
|
+
agent: route.agent,
|
|
343
|
+
tokens: result.tokens,
|
|
344
|
+
inputTokens: result.inputTokens,
|
|
345
|
+
cachedInputTokens: result.cachedInputTokens,
|
|
346
|
+
outputTokens: result.outputTokens,
|
|
347
|
+
costUsd: result.costUsd,
|
|
348
|
+
wallSeconds: result.wallSeconds,
|
|
349
|
+
telemetry: result.telemetry,
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
await rm(smokeRoot, { recursive: true, force: true });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function agentSmokeDetail(result) {
|
|
358
|
+
const usage = result.usage;
|
|
359
|
+
const detail = [
|
|
360
|
+
`${result.runtime.agent}@${result.runtime.binaryVersion}`,
|
|
361
|
+
`model=${result.runtime.model ?? "unknown"}`,
|
|
362
|
+
`effort=${result.runtime.effort ?? "unknown"}`,
|
|
363
|
+
`tokens=${usage.tokens}`,
|
|
364
|
+
`input=${usage.inputTokens}`,
|
|
365
|
+
`cached=${usage.cachedInputTokens}`,
|
|
366
|
+
`output=${usage.outputTokens}`,
|
|
367
|
+
`costUsd=${usage.costUsd}`,
|
|
368
|
+
`wallSeconds=${Math.round(usage.wallSeconds * 1000) / 1000}`,
|
|
369
|
+
];
|
|
370
|
+
if (usage.telemetry?.providerErrors.length) {
|
|
371
|
+
detail.push(`providerErrors=${JSON.stringify(usage.telemetry.providerErrors)}`);
|
|
372
|
+
}
|
|
373
|
+
return detail.join(" ");
|
|
374
|
+
}
|
|
375
|
+
async function persistDoctorAttestation(root, expectedConfig, marker, smoke) {
|
|
376
|
+
return withWorkspaceLock(root, "workspace.doctor.attest", async () => {
|
|
377
|
+
const paths = workspacePaths(root);
|
|
378
|
+
const config = await loadWorkspaceConfig(root);
|
|
379
|
+
if (canonicalJson(config) !== canonicalJson(expectedConfig)) {
|
|
380
|
+
throw new Error("workspace configuration changed during doctor smoke");
|
|
381
|
+
}
|
|
382
|
+
const capabilities = await verifyCapabilities(root);
|
|
383
|
+
if (capabilities.status !== "verified") {
|
|
384
|
+
throw new Error("capability lock changed during doctor smoke");
|
|
385
|
+
}
|
|
386
|
+
const checkedAt = new Date().toISOString();
|
|
387
|
+
const core = {
|
|
388
|
+
schemaVersion: 1,
|
|
389
|
+
workspaceId: marker.workspaceId,
|
|
390
|
+
checkedAt,
|
|
391
|
+
expiresAt: new Date(Date.parse(checkedAt) + DOCTOR_ATTESTATION_TTL_MS).toISOString(),
|
|
392
|
+
configSha256: sha256Text(canonicalJson(config)),
|
|
393
|
+
runtimeLockSha256: await sha256File(paths.runtimeLock),
|
|
394
|
+
capabilityDeclarationsSha256: await sha256File(paths.capabilityDeclarations),
|
|
395
|
+
capabilityLockSha256: await sha256File(paths.capabilityLock),
|
|
396
|
+
doctorSchemaSha256: sha256Text(canonicalJson(schemaForStage("doctor"))),
|
|
397
|
+
runtimes: smoke.runtimes,
|
|
398
|
+
smokeUsage: smoke.smokeUsage,
|
|
399
|
+
};
|
|
400
|
+
const value = {
|
|
401
|
+
...core,
|
|
402
|
+
attestationSha256: sha256Text(canonicalJson(core)),
|
|
403
|
+
};
|
|
404
|
+
await writeJsonAtomic(paths.doctorAttestation, value);
|
|
405
|
+
await appendJournalEvent(paths.journal, "workspace.doctor.attested", marker.workspaceId, {
|
|
406
|
+
attestationSha256: value.attestationSha256,
|
|
407
|
+
checkedAt: value.checkedAt,
|
|
408
|
+
expiresAt: value.expiresAt,
|
|
409
|
+
runtimes: value.runtimes,
|
|
410
|
+
smokeUsage: value.smokeUsage,
|
|
411
|
+
});
|
|
412
|
+
return value;
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
export async function verifyDoctorAttestation(root) {
|
|
416
|
+
const paths = workspacePaths(root);
|
|
417
|
+
const value = await readJsonFile(paths.doctorAttestation, "Doctor attestation").catch(() => null);
|
|
418
|
+
if (!isDoctorAttestation(value)) {
|
|
419
|
+
return {
|
|
420
|
+
status: value === null ? "missing" : "invalid",
|
|
421
|
+
errors: ["doctor attestation is missing or invalid"],
|
|
422
|
+
attestation: null,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
const { attestationSha256, ...core } = value;
|
|
426
|
+
const errors = [];
|
|
427
|
+
if (sha256Text(canonicalJson(core)) !== attestationSha256)
|
|
428
|
+
errors.push("attestation hash mismatch");
|
|
429
|
+
const marker = await loadWorkspaceMarker(root);
|
|
430
|
+
const config = await loadWorkspaceConfig(root);
|
|
431
|
+
if (value.workspaceId !== marker.workspaceId)
|
|
432
|
+
errors.push("workspace ID drifted");
|
|
433
|
+
if (value.configSha256 !== sha256Text(canonicalJson(config)))
|
|
434
|
+
errors.push("workspace config drifted");
|
|
435
|
+
if (value.runtimeLockSha256 !== (await sha256File(paths.runtimeLock)))
|
|
436
|
+
errors.push("runtime lock drifted");
|
|
437
|
+
if (value.capabilityDeclarationsSha256 !== (await sha256File(paths.capabilityDeclarations))) {
|
|
438
|
+
errors.push("capability declarations drifted");
|
|
439
|
+
}
|
|
440
|
+
if (value.capabilityLockSha256 !== (await sha256File(paths.capabilityLock).catch(() => ""))) {
|
|
441
|
+
errors.push("capability lock drifted");
|
|
442
|
+
}
|
|
443
|
+
if (value.doctorSchemaSha256 !== sha256Text(canonicalJson(schemaForStage("doctor")))) {
|
|
444
|
+
errors.push("doctor schema drifted");
|
|
445
|
+
}
|
|
446
|
+
if (errors.length)
|
|
447
|
+
return { status: "drifted", errors, attestation: value };
|
|
448
|
+
if (Date.parse(value.expiresAt) <= Date.now()) {
|
|
449
|
+
return { status: "expired", errors: ["doctor attestation expired"], attestation: value };
|
|
450
|
+
}
|
|
451
|
+
return { status: "verified", errors: [], attestation: value };
|
|
452
|
+
}
|
|
453
|
+
function isDoctorAttestation(value) {
|
|
454
|
+
if (!isObject(value) || value.schemaVersion !== 1)
|
|
455
|
+
return false;
|
|
456
|
+
const hashFields = [
|
|
457
|
+
value.configSha256,
|
|
458
|
+
value.runtimeLockSha256,
|
|
459
|
+
value.capabilityDeclarationsSha256,
|
|
460
|
+
value.capabilityLockSha256,
|
|
461
|
+
value.doctorSchemaSha256,
|
|
462
|
+
value.attestationSha256,
|
|
463
|
+
];
|
|
464
|
+
if (typeof value.workspaceId !== "string" ||
|
|
465
|
+
typeof value.checkedAt !== "string" ||
|
|
466
|
+
!Number.isFinite(Date.parse(value.checkedAt)) ||
|
|
467
|
+
typeof value.expiresAt !== "string" ||
|
|
468
|
+
!Number.isFinite(Date.parse(value.expiresAt)) ||
|
|
469
|
+
hashFields.some((hash) => typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash)) ||
|
|
470
|
+
!Array.isArray(value.runtimes) ||
|
|
471
|
+
value.runtimes.length !== 2 ||
|
|
472
|
+
!Array.isArray(value.smokeUsage) ||
|
|
473
|
+
value.smokeUsage.length !== 2) {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
return value.runtimes.every((runtime) => isObject(runtime) &&
|
|
477
|
+
(runtime.agent === "codex" || runtime.agent === "claude") &&
|
|
478
|
+
(runtime.model === null || typeof runtime.model === "string") &&
|
|
479
|
+
typeof runtime.binarySha256 === "string" &&
|
|
480
|
+
/^[0-9a-f]{64}$/.test(runtime.binarySha256) &&
|
|
481
|
+
typeof runtime.wrapperSha256 === "string" &&
|
|
482
|
+
/^[0-9a-f]{64}$/.test(runtime.wrapperSha256) &&
|
|
483
|
+
typeof runtime.adapterSha256 === "string" &&
|
|
484
|
+
/^[0-9a-f]{64}$/.test(runtime.adapterSha256) &&
|
|
485
|
+
typeof runtime.binaryVersion === "string" &&
|
|
486
|
+
typeof runtime.platform === "string" &&
|
|
487
|
+
typeof runtime.architecture === "string");
|
|
488
|
+
}
|
|
489
|
+
export async function withWorkspaceLock(root, operation, callback) {
|
|
490
|
+
await requireCurrentRuntimeLock(root);
|
|
491
|
+
const paths = workspacePaths(root);
|
|
492
|
+
const release = await acquireFileLock(join(paths.locks, "workspace.lock"), {
|
|
493
|
+
pid: process.pid,
|
|
494
|
+
operation,
|
|
495
|
+
acquiredAt: new Date().toISOString(),
|
|
496
|
+
});
|
|
497
|
+
try {
|
|
498
|
+
return await callback();
|
|
499
|
+
}
|
|
500
|
+
finally {
|
|
501
|
+
await release();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
export async function requireCurrentRuntimeLock(root, knownMarker) {
|
|
505
|
+
const paths = workspacePaths(root);
|
|
506
|
+
const marker = knownMarker ?? (await loadWorkspaceMarker(root));
|
|
507
|
+
const lock = await readJsonFile(paths.runtimeLock, "Research runtime lock");
|
|
508
|
+
if (!isRuntimeLock(lock) || lock.workspaceId !== marker.workspaceId) {
|
|
509
|
+
throw new CliError("Research runtime lock does not match the current workspace.", {
|
|
510
|
+
code: "RESEARCH_RUNTIME_LOCK_INVALID",
|
|
511
|
+
exitCode: 3,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
if (lock.protocolVersion !== RESEARCH_PROTOCOL_VERSION) {
|
|
515
|
+
throw new CliError(`Unsupported research protocol version: ${lock.protocolVersion}.`, {
|
|
516
|
+
code: "RESEARCH_RUNTIME_LOCK_INVALID",
|
|
517
|
+
exitCode: 3,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
const currentVersion = packageVersion();
|
|
521
|
+
if (lock.packageVersion !== currentVersion) {
|
|
522
|
+
throw new CliError(`Research runtime lock requires ${lock.packageName}@${lock.packageVersion}; active CLI is ${currentVersion}.`, { code: "RESEARCH_RUNTIME_VERSION_MISMATCH", exitCode: 3 });
|
|
523
|
+
}
|
|
524
|
+
return lock;
|
|
525
|
+
}
|
|
526
|
+
async function inspectCredentialEnvironment(path, declaredIds) {
|
|
527
|
+
if (!(await pathExists(path)))
|
|
528
|
+
return "not configured; no credentials loaded";
|
|
529
|
+
const info = await stat(path);
|
|
530
|
+
if (process.platform !== "win32" && (info.mode & 0o077) !== 0) {
|
|
531
|
+
throw new Error("credential environment must have owner-only permissions");
|
|
532
|
+
}
|
|
533
|
+
const lines = (await readFile(path, "utf8")).split(/\r?\n/);
|
|
534
|
+
let found = false;
|
|
535
|
+
for (const sourceLine of lines) {
|
|
536
|
+
const line = sourceLine.trim();
|
|
537
|
+
if (!line || line.startsWith("#"))
|
|
538
|
+
continue;
|
|
539
|
+
const equals = line.indexOf("=");
|
|
540
|
+
if (equals < 1)
|
|
541
|
+
throw new Error("credential environment contains a malformed line");
|
|
542
|
+
const key = line.slice(0, equals).trim();
|
|
543
|
+
if (key !== "TIANGONG_RESEARCH_CAPABILITY_CREDENTIALS_JSON" || found) {
|
|
544
|
+
throw new Error(`credential environment contains an unsupported key: ${key}`);
|
|
545
|
+
}
|
|
546
|
+
const raw = line.slice(equals + 1).trim();
|
|
547
|
+
const value = JSON.parse(raw || "{}");
|
|
548
|
+
if (!isObject(value) ||
|
|
549
|
+
Object.entries(value).some(([credentialId, credentialValue]) => !isLogicalCredentialId(credentialId) ||
|
|
550
|
+
!declaredIds.has(credentialId) ||
|
|
551
|
+
typeof credentialValue !== "string" ||
|
|
552
|
+
Buffer.byteLength(credentialValue, "utf8") < 8)) {
|
|
553
|
+
throw new Error("credential map must contain logical IDs and non-trivial string values");
|
|
554
|
+
}
|
|
555
|
+
found = true;
|
|
556
|
+
}
|
|
557
|
+
return found ? "configured with owner-only permissions" : "configured without credentials";
|
|
558
|
+
}
|
|
559
|
+
async function readProjectStates(projectsPath) {
|
|
560
|
+
const entries = await readdir(projectsPath, { withFileTypes: true });
|
|
561
|
+
const states = [];
|
|
562
|
+
for (const entry of entries) {
|
|
563
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
564
|
+
continue;
|
|
565
|
+
const state = await readJsonFile(join(projectsPath, entry.name, "project.json"), `Project ${entry.name}`);
|
|
566
|
+
if (!isProjectStateShape(state) || state.id !== entry.name) {
|
|
567
|
+
throw new Error(`project ${entry.name} has an unsupported state shape`);
|
|
568
|
+
}
|
|
569
|
+
states.push(state);
|
|
570
|
+
}
|
|
571
|
+
return states;
|
|
572
|
+
}
|
|
573
|
+
function isWorkspaceConfig(value) {
|
|
574
|
+
if (!isObject(value) || value.schemaVersion !== 1)
|
|
575
|
+
return false;
|
|
576
|
+
if (value.mode !== "smoke-test" && value.mode !== "production-research")
|
|
577
|
+
return false;
|
|
578
|
+
if (!isAgentRoute(value.producer) || !isAgentRoute(value.reviewer))
|
|
579
|
+
return false;
|
|
580
|
+
const budget = value.budget;
|
|
581
|
+
return (isObject(budget) &&
|
|
582
|
+
positiveInteger(budget.maxTokens) &&
|
|
583
|
+
positiveNumber(budget.maxCostUsd) &&
|
|
584
|
+
positiveInteger(budget.maxWallSeconds) &&
|
|
585
|
+
positiveInteger(budget.maxFilesPerPackage) &&
|
|
586
|
+
positiveInteger(budget.maxBytesPerPackage) &&
|
|
587
|
+
positiveInteger(budget.maxAttemptsPerPackage) &&
|
|
588
|
+
positiveNumber(budget.confirmationCostUsd) &&
|
|
589
|
+
isObject(budget.packageMaxTokens) &&
|
|
590
|
+
positiveInteger(budget.packageMaxTokens.discover) &&
|
|
591
|
+
positiveInteger(budget.packageMaxTokens.analyze) &&
|
|
592
|
+
positiveInteger(budget.packageMaxTokens.synthesize) &&
|
|
593
|
+
positiveInteger(budget.packageMaxTokens.review) &&
|
|
594
|
+
isObject(budget.packageMaxWallSeconds) &&
|
|
595
|
+
positiveInteger(budget.packageMaxWallSeconds.discover) &&
|
|
596
|
+
positiveInteger(budget.packageMaxWallSeconds.analyze) &&
|
|
597
|
+
positiveInteger(budget.packageMaxWallSeconds.synthesize) &&
|
|
598
|
+
positiveInteger(budget.packageMaxWallSeconds.review) &&
|
|
599
|
+
positiveInteger(budget.maxOutputTokens) &&
|
|
600
|
+
positiveInteger(budget.maxRepairTokens) &&
|
|
601
|
+
budget.maxRepairTokens <= budget.maxOutputTokens &&
|
|
602
|
+
positiveInteger(budget.maxBrokerResponseBytes) &&
|
|
603
|
+
positiveInteger(budget.maxBrokerContextTokens) &&
|
|
604
|
+
budget.maxBrokerContextTokens >= 16 &&
|
|
605
|
+
positiveInteger(budget.maxBrokerItems) &&
|
|
606
|
+
positiveInteger(budget.maxInputContextTokens));
|
|
607
|
+
}
|
|
608
|
+
function isAgentRoute(value) {
|
|
609
|
+
if (!(isObject(value) &&
|
|
610
|
+
(value.agent === "codex" || value.agent === "claude") &&
|
|
611
|
+
typeof value.binary === "string" &&
|
|
612
|
+
value.binary.length > 0 &&
|
|
613
|
+
(value.wrapperTargetBinary === undefined ||
|
|
614
|
+
(typeof value.wrapperTargetBinary === "string" &&
|
|
615
|
+
isAbsolute(value.wrapperTargetBinary) &&
|
|
616
|
+
isAbsolute(value.binary) &&
|
|
617
|
+
value.wrapperTargetBinary !== value.binary)) &&
|
|
618
|
+
(value.model === null || (typeof value.model === "string" && value.model.length > 0)) &&
|
|
619
|
+
(value.pricing === undefined || isAgentPricing(value.pricing)))) {
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
const effort = value.effort;
|
|
623
|
+
if (effort !== undefined &&
|
|
624
|
+
!((value.agent === "codex" &&
|
|
625
|
+
["minimal", "low", "medium", "high", "xhigh"].includes(String(effort))) ||
|
|
626
|
+
(value.agent === "claude" &&
|
|
627
|
+
["low", "medium", "high", "xhigh", "max"].includes(String(effort))))) {
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
630
|
+
if (value.verbosity !== undefined &&
|
|
631
|
+
(value.agent !== "codex" || !["low", "medium", "high"].includes(String(value.verbosity)))) {
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
return true;
|
|
635
|
+
}
|
|
636
|
+
function isAgentPricing(value) {
|
|
637
|
+
return (isObject(value) &&
|
|
638
|
+
nonNegativeNumber(value.inputUsdPerMillionTokens) &&
|
|
639
|
+
nonNegativeNumber(value.cachedInputUsdPerMillionTokens) &&
|
|
640
|
+
nonNegativeNumber(value.outputUsdPerMillionTokens));
|
|
641
|
+
}
|
|
642
|
+
function isRuntimeLock(value) {
|
|
643
|
+
return (isObject(value) &&
|
|
644
|
+
value.schemaVersion === 1 &&
|
|
645
|
+
value.protocolVersion === 1 &&
|
|
646
|
+
value.packageName === RESEARCH_PACKAGE_NAME &&
|
|
647
|
+
typeof value.packageVersion === "string" &&
|
|
648
|
+
typeof value.workspaceId === "string");
|
|
649
|
+
}
|
|
650
|
+
function isProjectStateShape(value) {
|
|
651
|
+
return (isObject(value) &&
|
|
652
|
+
value.schemaVersion === 1 &&
|
|
653
|
+
typeof value.id === "string" &&
|
|
654
|
+
typeof value.question === "string" &&
|
|
655
|
+
(value.budgetConfirmedAt === null || typeof value.budgetConfirmedAt === "string") &&
|
|
656
|
+
["ready", "running", "blocked", "complete"].includes(String(value.status)) &&
|
|
657
|
+
Array.isArray(value.inputs) &&
|
|
658
|
+
isObject(value.evidenceRequirements) &&
|
|
659
|
+
Array.isArray(value.packages) &&
|
|
660
|
+
isObject(value.usage) &&
|
|
661
|
+
typeof value.usage.inputTokens === "number" &&
|
|
662
|
+
typeof value.usage.cachedInputTokens === "number" &&
|
|
663
|
+
typeof value.usage.outputTokens === "number");
|
|
664
|
+
}
|
|
665
|
+
async function checked(checks, id, callback) {
|
|
666
|
+
try {
|
|
667
|
+
const result = await callback();
|
|
668
|
+
checks.push({ id, status: "pass", detail: result.detail });
|
|
669
|
+
return result.value;
|
|
670
|
+
}
|
|
671
|
+
catch (error) {
|
|
672
|
+
checks.push({
|
|
673
|
+
id,
|
|
674
|
+
status: "fail",
|
|
675
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
676
|
+
});
|
|
677
|
+
return undefined;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
function normalizeWorkspaceName(value) {
|
|
681
|
+
const normalized = value.trim();
|
|
682
|
+
if (!normalized || normalized.length > 100 || /[\u0000-\u001f]/.test(normalized)) {
|
|
683
|
+
throw new CliError("Workspace name must contain 1-100 printable characters.", {
|
|
684
|
+
code: "RESEARCH_WORKSPACE_NAME_INVALID",
|
|
685
|
+
exitCode: 2,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
return normalized;
|
|
689
|
+
}
|
|
690
|
+
function positiveInteger(value) {
|
|
691
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
692
|
+
}
|
|
693
|
+
function positiveNumber(value) {
|
|
694
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
695
|
+
}
|
|
696
|
+
function nonNegativeNumber(value) {
|
|
697
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
698
|
+
}
|
|
699
|
+
function isLogicalCredentialId(value) {
|
|
700
|
+
return /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/.test(value);
|
|
701
|
+
}
|
|
702
|
+
//# sourceMappingURL=workspace.js.map
|