@tiangong-ai/cli 0.0.42 → 0.0.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +45 -18
  3. package/dist/research/orchestration.js +198 -7
  4. package/dist/research/orchestration.js.map +1 -1
  5. package/dist/research/workspace/acquisition.d.ts +7 -0
  6. package/dist/research/workspace/acquisition.js +37 -10
  7. package/dist/research/workspace/acquisition.js.map +1 -1
  8. package/dist/research/workspace/audit-bundle.d.ts +18 -0
  9. package/dist/research/workspace/audit-bundle.js +99 -3
  10. package/dist/research/workspace/audit-bundle.js.map +1 -1
  11. package/dist/research/workspace/content-evidence.d.ts +98 -0
  12. package/dist/research/workspace/content-evidence.js +715 -0
  13. package/dist/research/workspace/content-evidence.js.map +1 -0
  14. package/dist/research/workspace/evidence-ledger.d.ts +1 -1
  15. package/dist/research/workspace/evidence-ledger.js +2 -0
  16. package/dist/research/workspace/evidence-ledger.js.map +1 -1
  17. package/dist/research/workspace/inference.d.ts +69 -0
  18. package/dist/research/workspace/inference.js +360 -0
  19. package/dist/research/workspace/inference.js.map +1 -0
  20. package/dist/research/workspace/native-activity.js +14 -5
  21. package/dist/research/workspace/native-activity.js.map +1 -1
  22. package/dist/research/workspace/projects.js +16 -0
  23. package/dist/research/workspace/projects.js.map +1 -1
  24. package/dist/research/workspace/publication-workflow.d.ts +26 -0
  25. package/dist/research/workspace/publication-workflow.js +313 -2
  26. package/dist/research/workspace/publication-workflow.js.map +1 -1
  27. package/dist/research/workspace/runtime.js +131 -30
  28. package/dist/research/workspace/runtime.js.map +1 -1
  29. package/dist/research/workspace/schemas.js +83 -4
  30. package/dist/research/workspace/schemas.js.map +1 -1
  31. package/dist/research/workspace/scientific-review.js +57 -0
  32. package/dist/research/workspace/scientific-review.js.map +1 -1
  33. package/dist/research/workspace/setup-catalog.js +2 -2
  34. package/dist/research/workspace/setup.js +1 -1
  35. package/dist/research/workspace/setup.js.map +1 -1
  36. package/package.json +1 -1
@@ -0,0 +1,715 @@
1
+ import { chmod, readFile, readdir } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { CliError } from "../../errors.js";
4
+ import { loadCurrentEvidenceSnapshot } from "./acquisition.js";
5
+ import { loadEvidenceArtifactRecords } from "./artifacts.js";
6
+ import { loadBoundAcquisitionDesign } from "./acquisition-routes.js";
7
+ import { appendEvidenceLedgerEvent, evidenceLedgerPath } from "./evidence-ledger.js";
8
+ import { readJournal } from "./journal.js";
9
+ import { loadProject } from "./projects.js";
10
+ import { configuredResearchSecrets, sanitizeResearchText } from "./sanitization.js";
11
+ import { canonicalJson, ensureDirectory, isObject, pathExists, resolveContained, sha256File, sha256Text, workspacePaths, writeJsonAtomic, writeTextAtomic, } from "./storage.js";
12
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
13
+ const SHA256 = /^[a-f0-9]{64}$/;
14
+ const CONTENT_CLASSES = new Set([
15
+ "fulltext",
16
+ "table-data",
17
+ "supplementary-data",
18
+ "structured-data",
19
+ "metadata",
20
+ "figure-text",
21
+ "code",
22
+ "container-index",
23
+ ]);
24
+ const EVIDENCE_FUNCTIONS = new Set([
25
+ "support",
26
+ "counterevidence",
27
+ "definition",
28
+ "method",
29
+ "limitation",
30
+ "context",
31
+ ]);
32
+ const PRODUCER_VISIBLE_MEDIA_TYPES = new Set([
33
+ "application/json",
34
+ "text/plain",
35
+ "text/markdown",
36
+ "text/csv",
37
+ ]);
38
+ const MAX_EXCERPT_BYTES = 8_000;
39
+ export async function recordArtifactDecomposition(input) {
40
+ await assertContentPreparationWindow(input.root, input.projectId);
41
+ const value = parseDecompositionInput(input.value);
42
+ const acquisition = await loadCurrentEvidenceSnapshot(input.root, input.projectId);
43
+ const artifacts = new Map((await loadEvidenceArtifactRecords(input.root, input.projectId)).map((artifact) => [
44
+ artifact.artifactId,
45
+ artifact,
46
+ ]));
47
+ const source = artifacts.get(value.sourceArtifactId);
48
+ if (!source ||
49
+ !acquisition.artifacts.some((artifact) => artifact.artifactId === source.artifactId)) {
50
+ throw contentError("Decomposition source must be an artifact in the current acquisition snapshot.", "RESEARCH_DECOMPOSITION_ARTIFACT_INVALID");
51
+ }
52
+ const outputs = value.outputArtifactIds.map((artifactId) => {
53
+ const output = artifacts.get(artifactId);
54
+ if (!output ||
55
+ output.candidateId !== source.candidateId ||
56
+ !artifactDescendsFrom(output, source.artifactId, artifacts)) {
57
+ throw contentError("Every decomposition output must be an exact derived descendant of its source artifact.", "RESEARCH_DECOMPOSITION_LINEAGE_INVALID");
58
+ }
59
+ return output;
60
+ });
61
+ if (value.status === "complete" && outputs.length === 0) {
62
+ throw contentError("A complete decomposition requires at least one derived output artifact.", "RESEARCH_DECOMPOSITION_OUTPUT_REQUIRED");
63
+ }
64
+ const stable = {
65
+ schemaVersion: 1,
66
+ projectId: input.projectId,
67
+ candidateId: source.candidateId,
68
+ sourceArtifactId: source.artifactId,
69
+ sourceArtifactSha256: source.sha256,
70
+ status: value.status,
71
+ parser: value.parser,
72
+ outputArtifactIds: outputs.map((artifact) => artifact.artifactId),
73
+ outputArtifactSha256s: outputs.map((artifact) => artifact.sha256),
74
+ contentClasses: value.contentClasses,
75
+ limitations: value.limitations,
76
+ };
77
+ const decompositionSha256 = sha256Text(canonicalJson(stable));
78
+ const record = {
79
+ ...stable,
80
+ decompositionId: `decomposition-${decompositionSha256.slice(0, 24)}`,
81
+ decompositionSha256,
82
+ recordedAt: new Date().toISOString(),
83
+ };
84
+ const destination = decompositionRecordPath(input.root, input.projectId, source.artifactId);
85
+ if (await pathExists(destination)) {
86
+ const existing = parseDecompositionRecord(JSON.parse(await readFile(destination, "utf8")));
87
+ if (existing.decompositionSha256 !== decompositionSha256) {
88
+ throw contentError("This source artifact already has a different decomposition disposition.", "RESEARCH_DECOMPOSITION_CONFLICT");
89
+ }
90
+ return existing;
91
+ }
92
+ await writeJsonAtomic(destination, record, 0o444);
93
+ await chmod(destination, 0o444).catch(() => undefined);
94
+ await appendEvidenceLedgerEvent(input.root, input.projectId, "decomposition.recorded", {
95
+ decompositionId: record.decompositionId,
96
+ decompositionSha256,
97
+ sourceArtifactId: source.artifactId,
98
+ sourceArtifactSha256: source.sha256,
99
+ candidateId: source.candidateId,
100
+ status: record.status,
101
+ outputArtifactIds: record.outputArtifactIds,
102
+ outputArtifactSha256s: record.outputArtifactSha256s,
103
+ contentClasses: record.contentClasses,
104
+ });
105
+ return record;
106
+ }
107
+ export async function registerEvidenceAtom(input) {
108
+ await assertContentPreparationWindow(input.root, input.projectId);
109
+ const value = parseAtomInput(input.value);
110
+ const acquisition = await loadCurrentEvidenceSnapshot(input.root, input.projectId);
111
+ const source = acquisition.sources.find((candidate) => candidate.id === value.sourceId);
112
+ if (!source ||
113
+ !Array.isArray(source.artifactIds) ||
114
+ !source.artifactIds.includes(value.artifactId)) {
115
+ throw contentError("Evidence atom source and artifact must belong to the same frozen acquisition source.", "RESEARCH_EVIDENCE_ATOM_SOURCE_INVALID");
116
+ }
117
+ const artifact = (await loadEvidenceArtifactRecords(input.root, input.projectId)).find((candidate) => candidate.artifactId === value.artifactId);
118
+ if (!artifact ||
119
+ artifact.candidateId !== value.candidateId ||
120
+ !PRODUCER_VISIBLE_MEDIA_TYPES.has(artifact.mediaType)) {
121
+ throw contentError("Evidence atoms may reference only producer-readable artifacts bound to the declared candidate.", "RESEARCH_EVIDENCE_ATOM_ARTIFACT_INVALID");
122
+ }
123
+ await validateAtomTaxonomy(input.root, input.projectId, source, value);
124
+ const artifactPath = resolveContained(workspacePaths(input.root).control, artifact.locator);
125
+ const excerpt = await extractAtomExcerpt(artifactPath, artifact.mediaType, value.locator);
126
+ assertSafeContent(excerpt, "Evidence atom excerpt contains sensitive material.");
127
+ const stable = {
128
+ schemaVersion: 1,
129
+ projectId: input.projectId,
130
+ atomId: value.atomId,
131
+ sourceId: value.sourceId,
132
+ candidateId: value.candidateId,
133
+ artifactId: artifact.artifactId,
134
+ artifactSha256: artifact.sha256,
135
+ locator: value.locator,
136
+ excerpt,
137
+ excerptSha256: sha256Text(excerpt),
138
+ statement: value.statement,
139
+ evidenceRoleIds: value.evidenceRoleIds,
140
+ coverageDimensionIds: value.coverageDimensionIds,
141
+ evidenceFunction: value.evidenceFunction,
142
+ scope: value.scope,
143
+ limitations: value.limitations,
144
+ };
145
+ const atomSha256 = sha256Text(canonicalJson(stable));
146
+ const record = {
147
+ ...stable,
148
+ atomSha256,
149
+ registeredAt: new Date().toISOString(),
150
+ };
151
+ const destination = atomRecordPath(input.root, input.projectId, value.atomId);
152
+ if (await pathExists(destination)) {
153
+ const existing = parseAtomRecord(JSON.parse(await readFile(destination, "utf8")));
154
+ if (existing.atomSha256 !== atomSha256) {
155
+ throw contentError("Evidence atom ID already exists with different content.", "RESEARCH_EVIDENCE_ATOM_CONFLICT");
156
+ }
157
+ return existing;
158
+ }
159
+ await writeJsonAtomic(destination, record, 0o444);
160
+ await chmod(destination, 0o444).catch(() => undefined);
161
+ await appendEvidenceLedgerEvent(input.root, input.projectId, "atom.registered", {
162
+ atomId: record.atomId,
163
+ atomSha256,
164
+ sourceId: record.sourceId,
165
+ candidateId: record.candidateId,
166
+ artifactId: record.artifactId,
167
+ artifactSha256: record.artifactSha256,
168
+ excerptSha256: record.excerptSha256,
169
+ evidenceRoleIds: record.evidenceRoleIds,
170
+ coverageDimensionIds: record.coverageDimensionIds,
171
+ evidenceFunction: record.evidenceFunction,
172
+ });
173
+ return record;
174
+ }
175
+ export async function freezeEvidenceContentSnapshot(root, projectId) {
176
+ await assertContentPreparationWindow(root, projectId);
177
+ const [project, acquisition, artifacts, decompositions, atoms, ledgerEvents] = await Promise.all([
178
+ loadProject(root, projectId),
179
+ loadCurrentEvidenceSnapshot(root, projectId),
180
+ loadEvidenceArtifactRecords(root, projectId),
181
+ loadDecompositionRecords(root, projectId),
182
+ loadEvidenceAtomRecords(root, projectId),
183
+ readJournal(evidenceLedgerPath(root, projectId)),
184
+ ]);
185
+ const selectedArtifactIds = new Set(acquisition.artifacts.map((artifact) => artifact.artifactId));
186
+ const selectedArtifacts = artifacts.filter((artifact) => selectedArtifactIds.has(artifact.artifactId));
187
+ const childParents = new Set(selectedArtifacts.flatMap((artifact) => artifact.derivedFromArtifactId ? [artifact.derivedFromArtifactId] : []));
188
+ const requiredDecompositionArtifactIds = selectedArtifacts
189
+ .filter((artifact) => !PRODUCER_VISIBLE_MEDIA_TYPES.has(artifact.mediaType) &&
190
+ (artifact.downloadBinding !== null || childParents.has(artifact.artifactId)))
191
+ .map((artifact) => artifact.artifactId)
192
+ .sort();
193
+ const decompositionByArtifact = new Map(decompositions.map((decomposition) => [decomposition.sourceArtifactId, decomposition]));
194
+ const missingDecompositionArtifactIds = requiredDecompositionArtifactIds.filter((artifactId) => !decompositionByArtifact.has(artifactId));
195
+ const reasons = missingDecompositionArtifactIds.map((artifactId) => `missing decomposition disposition for artifact ${artifactId}`);
196
+ for (const decomposition of decompositions) {
197
+ if (!selectedArtifactIds.has(decomposition.sourceArtifactId))
198
+ continue;
199
+ if (decomposition.status === "failed") {
200
+ reasons.push(`decomposition failed for artifact ${decomposition.sourceArtifactId}`);
201
+ }
202
+ }
203
+ const atomsBySource = new Map();
204
+ for (const atom of atoms) {
205
+ const values = atomsBySource.get(atom.sourceId) ?? [];
206
+ values.push(atom);
207
+ atomsBySource.set(atom.sourceId, values);
208
+ }
209
+ const acceptedFullTextSourceIds = acquisition.sources
210
+ .filter((source) => source.acquisitionStatus === "accepted" && source.fullTextAvailable === true)
211
+ .map((source) => String(source.id))
212
+ .sort();
213
+ const sourcesWithoutAtoms = acceptedFullTextSourceIds.filter((sourceId) => !atomsBySource.get(sourceId)?.length);
214
+ reasons.push(...sourcesWithoutAtoms.map((sourceId) => `accepted full-text source has no evidence atom: ${sourceId}`));
215
+ const sourceCoverage = acquisition.sources.map((source) => {
216
+ const sourceAtoms = (atomsBySource.get(String(source.id)) ?? []).sort((left, right) => left.atomId.localeCompare(right.atomId));
217
+ return {
218
+ sourceId: String(source.id),
219
+ atomIds: sourceAtoms.map((atom) => atom.atomId),
220
+ evidenceRoleIds: sortedUnique(sourceAtoms.flatMap((atom) => atom.evidenceRoleIds)),
221
+ coverageDimensionIds: sortedUnique(sourceAtoms.flatMap((atom) => atom.coverageDimensionIds)),
222
+ evidenceFunctions: sortedUnique(sourceAtoms.map((atom) => atom.evidenceFunction)),
223
+ };
224
+ });
225
+ const roleCoverage = project.scientificDesign
226
+ ? computeRoleCoverage((await loadBoundAcquisitionDesign(root, project)).evidenceRoles, acquisition.sources, atoms)
227
+ : [];
228
+ reasons.push(...roleCoverage.flatMap((coverage) => coverage.gaps));
229
+ const uniqueReasons = sortedUnique(reasons);
230
+ const ledgerHead = ledgerEvents.at(-1)?.hash ?? "0".repeat(64);
231
+ const core = {
232
+ schemaVersion: 1,
233
+ kind: "tiangong-evidence-content-snapshot",
234
+ projectId,
235
+ acquisitionSnapshotId: acquisition.snapshotId,
236
+ acquisitionSnapshotSha256: acquisition.snapshotSha256,
237
+ createdAt: new Date().toISOString(),
238
+ ledgerHead,
239
+ decompositions: decompositions
240
+ .filter((record) => selectedArtifactIds.has(record.sourceArtifactId))
241
+ .sort((left, right) => left.decompositionId.localeCompare(right.decompositionId)),
242
+ atoms: atoms.sort((left, right) => left.atomId.localeCompare(right.atomId)),
243
+ sourceCoverage,
244
+ roleCoverage,
245
+ gate: {
246
+ decision: (uniqueReasons.length ? "stop" : "pass"),
247
+ reasons: uniqueReasons,
248
+ requiredDecompositionArtifactIds,
249
+ missingDecompositionArtifactIds,
250
+ acceptedFullTextSourceIds,
251
+ sourcesWithoutAtoms,
252
+ },
253
+ };
254
+ const snapshotId = `content-snapshot-${sha256Text(canonicalJson(core)).slice(0, 24)}`;
255
+ const withoutHash = { ...core, snapshotId };
256
+ const snapshot = {
257
+ ...withoutHash,
258
+ snapshotSha256: sha256Text(canonicalJson(withoutHash)),
259
+ };
260
+ const projectRoot = join(workspacePaths(root).projects, projectId);
261
+ const logicalPath = `evidence/content-snapshots/${snapshot.snapshotSha256}.json`;
262
+ const immutablePath = resolveContained(projectRoot, logicalPath);
263
+ const content = `${JSON.stringify(snapshot, null, 2)}\n`;
264
+ if (await pathExists(immutablePath)) {
265
+ if ((await sha256File(immutablePath)) !== sha256Text(content)) {
266
+ throw contentError("Content-addressed evidence content snapshot drifted.", "RESEARCH_EVIDENCE_CONTENT_SNAPSHOT_INVALID");
267
+ }
268
+ }
269
+ else {
270
+ await ensureDirectory(dirname(immutablePath));
271
+ await writeTextAtomic(immutablePath, content, 0o444);
272
+ await chmod(immutablePath, 0o444).catch(() => undefined);
273
+ }
274
+ await writeTextAtomic(join(projectRoot, "outputs", "content-snapshot.json"), content);
275
+ await appendEvidenceLedgerEvent(root, projectId, "content.snapshot.frozen", {
276
+ snapshotId,
277
+ snapshotSha256: snapshot.snapshotSha256,
278
+ acquisitionSnapshotId: snapshot.acquisitionSnapshotId,
279
+ acquisitionSnapshotSha256: snapshot.acquisitionSnapshotSha256,
280
+ path: logicalPath,
281
+ decompositionCount: snapshot.decompositions.length,
282
+ atomCount: snapshot.atoms.length,
283
+ gate: snapshot.gate,
284
+ });
285
+ return snapshot;
286
+ }
287
+ export async function loadCurrentEvidenceContentSnapshot(root, projectId) {
288
+ const projectRoot = join(workspacePaths(root).projects, projectId);
289
+ const currentPath = join(projectRoot, "outputs", "content-snapshot.json");
290
+ if (!(await pathExists(currentPath))) {
291
+ throw contentError("Evidence content snapshot has not been frozen.", "RESEARCH_EVIDENCE_CONTENT_SNAPSHOT_REQUIRED");
292
+ }
293
+ const snapshot = parseContentSnapshot(JSON.parse(await readFile(currentPath, "utf8")));
294
+ const { snapshotSha256, ...withoutHash } = snapshot;
295
+ if (sha256Text(canonicalJson(withoutHash)) !== snapshotSha256) {
296
+ throw contentError("Evidence content snapshot hash binding is invalid.", "RESEARCH_EVIDENCE_CONTENT_SNAPSHOT_INVALID");
297
+ }
298
+ const immutablePath = resolveContained(projectRoot, `evidence/content-snapshots/${snapshotSha256}.json`);
299
+ if (!(await pathExists(immutablePath)) ||
300
+ (await sha256File(immutablePath)) !== (await sha256File(currentPath))) {
301
+ throw contentError("Evidence content snapshot is not bound to its immutable copy.", "RESEARCH_EVIDENCE_CONTENT_SNAPSHOT_INVALID");
302
+ }
303
+ const acquisition = await loadCurrentEvidenceSnapshot(root, projectId);
304
+ if (acquisition.snapshotId !== snapshot.acquisitionSnapshotId ||
305
+ acquisition.snapshotSha256 !== snapshot.acquisitionSnapshotSha256) {
306
+ throw contentError("Evidence content snapshot belongs to a different acquisition snapshot.", "RESEARCH_EVIDENCE_CONTENT_SNAPSHOT_STALE");
307
+ }
308
+ const decompositions = new Map((await loadDecompositionRecords(root, projectId)).map((record) => [
309
+ record.decompositionId,
310
+ record,
311
+ ]));
312
+ for (const record of snapshot.decompositions) {
313
+ if (canonicalJson(decompositions.get(record.decompositionId)) !== canonicalJson(record)) {
314
+ throw contentError(`Evidence decomposition binding drifted: ${record.decompositionId}.`, "RESEARCH_EVIDENCE_CONTENT_SNAPSHOT_INVALID");
315
+ }
316
+ }
317
+ const atoms = new Map((await loadEvidenceAtomRecords(root, projectId)).map((record) => [record.atomId, record]));
318
+ for (const record of snapshot.atoms) {
319
+ if (canonicalJson(atoms.get(record.atomId)) !== canonicalJson(record)) {
320
+ throw contentError(`Evidence atom binding drifted: ${record.atomId}.`, "RESEARCH_EVIDENCE_CONTENT_SNAPSHOT_INVALID");
321
+ }
322
+ }
323
+ return snapshot;
324
+ }
325
+ export async function loadDecompositionRecords(root, projectId) {
326
+ const directory = resolveContained(workspacePaths(root).projects, `${projectId}/evidence/decompositions`);
327
+ if (!(await pathExists(directory)))
328
+ return [];
329
+ const entries = await readdir(directory, { withFileTypes: true });
330
+ const records = [];
331
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
332
+ if (!entry.isFile() || entry.isSymbolicLink() || !entry.name.endsWith(".json"))
333
+ continue;
334
+ const record = parseDecompositionRecord(JSON.parse(await readFile(resolve(directory, entry.name), "utf8")));
335
+ if (entry.name !== `${record.sourceArtifactId}.json` || record.projectId !== projectId) {
336
+ throw contentError("Evidence decomposition identity does not match its path.", "RESEARCH_DECOMPOSITION_STORE_INVALID");
337
+ }
338
+ records.push(record);
339
+ }
340
+ return records;
341
+ }
342
+ export async function loadEvidenceAtomRecords(root, projectId) {
343
+ const directory = resolveContained(workspacePaths(root).projects, `${projectId}/evidence/atoms`);
344
+ if (!(await pathExists(directory)))
345
+ return [];
346
+ const entries = await readdir(directory, { withFileTypes: true });
347
+ const records = [];
348
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
349
+ if (!entry.isFile() || entry.isSymbolicLink() || !entry.name.endsWith(".json"))
350
+ continue;
351
+ const record = parseAtomRecord(JSON.parse(await readFile(resolve(directory, entry.name), "utf8")));
352
+ if (entry.name !== `${record.atomId}.json` || record.projectId !== projectId) {
353
+ throw contentError("Evidence atom identity does not match its path.", "RESEARCH_EVIDENCE_ATOM_STORE_INVALID");
354
+ }
355
+ records.push(record);
356
+ }
357
+ return records;
358
+ }
359
+ async function assertContentPreparationWindow(root, projectId) {
360
+ const project = await loadProject(root, projectId);
361
+ const acquire = project.packages.find((workPackage) => workPackage.stage === "acquire");
362
+ const analyze = project.packages.find((workPackage) => workPackage.stage === "analyze");
363
+ if (acquire?.status !== "complete" ||
364
+ !analyze ||
365
+ !["pending", "ready"].includes(analyze.status) ||
366
+ analyze.attempts !== 0) {
367
+ throw contentError("Evidence content preparation is allowed only after acquisition and before analysis starts.", "RESEARCH_EVIDENCE_CONTENT_STAGE_REQUIRED");
368
+ }
369
+ }
370
+ function parseDecompositionInput(value) {
371
+ const allowed = new Set([
372
+ "schemaVersion",
373
+ "sourceArtifactId",
374
+ "status",
375
+ "parser",
376
+ "outputArtifactIds",
377
+ "contentClasses",
378
+ "limitations",
379
+ ]);
380
+ if (Object.keys(value).some((key) => !allowed.has(key)) ||
381
+ value.schemaVersion !== 1 ||
382
+ typeof value.sourceArtifactId !== "string" ||
383
+ !IDENTIFIER.test(value.sourceArtifactId) ||
384
+ !["complete", "limited", "failed"].includes(String(value.status)) ||
385
+ !isObject(value.parser) ||
386
+ typeof value.parser.id !== "string" ||
387
+ !IDENTIFIER.test(value.parser.id) ||
388
+ typeof value.parser.version !== "string" ||
389
+ value.parser.version.length < 1 ||
390
+ value.parser.version.length > 100 ||
391
+ !Array.isArray(value.outputArtifactIds) ||
392
+ value.outputArtifactIds.length > 100 ||
393
+ value.outputArtifactIds.some((artifactId) => typeof artifactId !== "string" || !IDENTIFIER.test(artifactId)) ||
394
+ new Set(value.outputArtifactIds).size !== value.outputArtifactIds.length ||
395
+ !Array.isArray(value.contentClasses) ||
396
+ value.contentClasses.length < 1 ||
397
+ value.contentClasses.length > CONTENT_CLASSES.size ||
398
+ value.contentClasses.some((contentClass) => typeof contentClass !== "string" || !CONTENT_CLASSES.has(contentClass)) ||
399
+ new Set(value.contentClasses).size !== value.contentClasses.length ||
400
+ !safeStringArray(value.limitations, 100, 2_000)) {
401
+ throw contentError("Artifact decomposition record failed validation.", "RESEARCH_DECOMPOSITION_INVALID");
402
+ }
403
+ for (const text of [value.parser.version, ...value.limitations]) {
404
+ assertSafeContent(text, "Artifact decomposition contains sensitive material.");
405
+ }
406
+ return value;
407
+ }
408
+ function parseAtomInput(value) {
409
+ const allowed = new Set([
410
+ "schemaVersion",
411
+ "atomId",
412
+ "sourceId",
413
+ "candidateId",
414
+ "artifactId",
415
+ "locator",
416
+ "statement",
417
+ "evidenceRoleIds",
418
+ "coverageDimensionIds",
419
+ "evidenceFunction",
420
+ "scope",
421
+ "limitations",
422
+ ]);
423
+ if (Object.keys(value).some((key) => !allowed.has(key)) ||
424
+ value.schemaVersion !== 1 ||
425
+ !identifierValue(value.atomId) ||
426
+ !identifierValue(value.sourceId) ||
427
+ !identifierValue(value.candidateId) ||
428
+ !identifierValue(value.artifactId) ||
429
+ !validAtomLocator(value.locator) ||
430
+ !boundedString(value.statement, 8, 2_000) ||
431
+ !safeIdentifierArray(value.evidenceRoleIds, 100) ||
432
+ !safeIdentifierArray(value.coverageDimensionIds, 100) ||
433
+ typeof value.evidenceFunction !== "string" ||
434
+ !EVIDENCE_FUNCTIONS.has(value.evidenceFunction) ||
435
+ !boundedString(value.scope, 8, 1_000) ||
436
+ !safeStringArray(value.limitations, 100, 2_000)) {
437
+ throw contentError("Evidence atom failed validation.", "RESEARCH_EVIDENCE_ATOM_INVALID");
438
+ }
439
+ for (const text of [value.statement, value.scope, ...value.limitations]) {
440
+ assertSafeContent(text, "Evidence atom contains sensitive material.");
441
+ }
442
+ return value;
443
+ }
444
+ async function validateAtomTaxonomy(root, projectId, source, value) {
445
+ const sourceDimensions = new Set(Array.isArray(source.coverageDimensions)
446
+ ? source.coverageDimensions.filter((item) => typeof item === "string")
447
+ : []);
448
+ if (value.coverageDimensionIds.some((dimension) => !sourceDimensions.has(dimension))) {
449
+ throw contentError("Evidence atom dimensions must be declared by its frozen acquisition source.", "RESEARCH_EVIDENCE_ATOM_TAXONOMY_INVALID");
450
+ }
451
+ const project = await loadProject(root, projectId);
452
+ if (!project.scientificDesign) {
453
+ if (value.evidenceRoleIds.length) {
454
+ throw contentError("A project without a scientific design cannot declare evidence-role IDs.", "RESEARCH_EVIDENCE_ATOM_TAXONOMY_INVALID");
455
+ }
456
+ return;
457
+ }
458
+ const design = await loadBoundAcquisitionDesign(root, project);
459
+ const knownRoleIds = new Set(design.evidenceRoles.map((role) => role.id));
460
+ if (value.evidenceRoleIds.length < 1 ||
461
+ value.evidenceRoleIds.some((roleId) => !knownRoleIds.has(roleId))) {
462
+ throw contentError("Scientific evidence atoms must bind only declared evidence-role IDs.", "RESEARCH_EVIDENCE_ATOM_TAXONOMY_INVALID");
463
+ }
464
+ }
465
+ async function extractAtomExcerpt(path, mediaType, locator) {
466
+ const text = await readFile(path, "utf8");
467
+ let excerpt;
468
+ if (locator.kind === "line-range") {
469
+ if (!["text/plain", "text/markdown", "text/csv"].includes(mediaType)) {
470
+ throw contentError("Line-range evidence atoms require a text or CSV artifact.", "RESEARCH_EVIDENCE_ATOM_LOCATOR_INVALID");
471
+ }
472
+ const lines = text.split(/\r\n|\n|\r/u);
473
+ if (locator.endLine > lines.length) {
474
+ throw contentError("Evidence atom line range exceeds the artifact.", "RESEARCH_EVIDENCE_ATOM_LOCATOR_INVALID");
475
+ }
476
+ excerpt = lines.slice(locator.startLine - 1, locator.endLine).join("\n");
477
+ }
478
+ else {
479
+ if (mediaType !== "application/json") {
480
+ throw contentError("JSON Pointer evidence atoms require an application/json artifact.", "RESEARCH_EVIDENCE_ATOM_LOCATOR_INVALID");
481
+ }
482
+ let selected = JSON.parse(text);
483
+ for (const segment of locator.pointer
484
+ .slice(1)
485
+ .split("/")
486
+ .map((item) => item.replaceAll("~1", "/").replaceAll("~0", "~"))) {
487
+ if (Array.isArray(selected) && /^(?:0|[1-9][0-9]*)$/u.test(segment)) {
488
+ selected = selected[Number(segment)];
489
+ }
490
+ else if (isObject(selected) && Object.prototype.hasOwnProperty.call(selected, segment)) {
491
+ selected = selected[segment];
492
+ }
493
+ else {
494
+ throw contentError("Evidence atom JSON Pointer does not exist.", "RESEARCH_EVIDENCE_ATOM_LOCATOR_INVALID");
495
+ }
496
+ }
497
+ excerpt = typeof selected === "string" ? selected : JSON.stringify(selected);
498
+ }
499
+ if (!excerpt.trim() || Buffer.byteLength(excerpt, "utf8") > MAX_EXCERPT_BYTES) {
500
+ throw contentError("Evidence atom excerpt must be non-empty and within the byte bound.", "RESEARCH_EVIDENCE_ATOM_EXCERPT_INVALID");
501
+ }
502
+ return excerpt;
503
+ }
504
+ function computeRoleCoverage(roles, sources, atoms) {
505
+ const sourcesById = new Map(sources.map((source) => [String(source.id), source]));
506
+ return roles
507
+ .filter((role) => role.required)
508
+ .map((role) => {
509
+ const roleAtoms = atoms.filter((atom) => atom.evidenceRoleIds.includes(role.id));
510
+ const sourceIds = sortedUnique(roleAtoms.map((atom) => atom.sourceId)).filter((sourceId) => sourcesById.has(sourceId));
511
+ const fullTextSourceIds = sourceIds.filter((sourceId) => sourcesById.get(sourceId)?.fullTextAvailable === true);
512
+ const datedSourceIds = sourceIds.filter((sourceId) => typeof sourcesById.get(sourceId)?.publicationDate === "string");
513
+ const coverageDimensionIds = sortedUnique(roleAtoms.flatMap((atom) => atom.coverageDimensionIds));
514
+ const sourceTypes = sortedUnique(sourceIds.flatMap((sourceId) => {
515
+ const sourceType = sourcesById.get(sourceId)?.sourceType;
516
+ return typeof sourceType === "string" ? [sourceType] : [];
517
+ }));
518
+ const gaps = [];
519
+ if (sourceIds.length < role.minimumIndependentSources) {
520
+ gaps.push(`evidence role ${role.id} requires ${role.minimumIndependentSources} independent source(s), found ${sourceIds.length}`);
521
+ }
522
+ if (fullTextSourceIds.length < role.minimumFullText) {
523
+ gaps.push(`evidence role ${role.id} requires ${role.minimumFullText} full-text source(s), found ${fullTextSourceIds.length}`);
524
+ }
525
+ if (datedSourceIds.length < role.minimumDatedSources) {
526
+ gaps.push(`evidence role ${role.id} requires ${role.minimumDatedSources} dated source(s), found ${datedSourceIds.length}`);
527
+ }
528
+ for (const dimension of role.coverageDimensionIds) {
529
+ if (!coverageDimensionIds.includes(dimension)) {
530
+ gaps.push(`evidence role ${role.id} lacks atom coverage for dimension ${dimension}`);
531
+ }
532
+ }
533
+ for (const sourceType of role.sourceTypeRequirements) {
534
+ if (!sourceTypes.includes(sourceType)) {
535
+ gaps.push(`evidence role ${role.id} lacks source type ${sourceType}`);
536
+ }
537
+ }
538
+ return {
539
+ roleId: role.id,
540
+ sourceIds,
541
+ fullTextSourceIds,
542
+ datedSourceIds,
543
+ coverageDimensionIds,
544
+ sourceTypes,
545
+ decision: (gaps.length ? "insufficient" : "pass"),
546
+ gaps,
547
+ };
548
+ });
549
+ }
550
+ function artifactDescendsFrom(artifact, ancestorId, artifacts) {
551
+ const visited = new Set();
552
+ let current = artifact;
553
+ while (current?.derivedFromArtifactId) {
554
+ if (current.derivedFromArtifactId === ancestorId)
555
+ return true;
556
+ if (visited.has(current.artifactId))
557
+ return false;
558
+ visited.add(current.artifactId);
559
+ current = artifacts.get(current.derivedFromArtifactId);
560
+ }
561
+ return false;
562
+ }
563
+ function parseDecompositionRecord(value) {
564
+ if (!isObject(value) ||
565
+ value.schemaVersion !== 1 ||
566
+ !identifierValue(value.decompositionId) ||
567
+ typeof value.decompositionSha256 !== "string" ||
568
+ !SHA256.test(value.decompositionSha256) ||
569
+ !identifierValue(value.projectId) ||
570
+ !identifierValue(value.candidateId) ||
571
+ !identifierValue(value.sourceArtifactId) ||
572
+ typeof value.sourceArtifactSha256 !== "string" ||
573
+ !SHA256.test(value.sourceArtifactSha256) ||
574
+ !["complete", "limited", "failed"].includes(String(value.status)) ||
575
+ !isObject(value.parser) ||
576
+ !identifierValue(value.parser.id) ||
577
+ typeof value.parser.version !== "string" ||
578
+ !safeIdentifierArray(value.outputArtifactIds, 100) ||
579
+ !Array.isArray(value.outputArtifactSha256s) ||
580
+ value.outputArtifactSha256s.some((sha256) => typeof sha256 !== "string" || !SHA256.test(sha256)) ||
581
+ value.outputArtifactSha256s.length !== value.outputArtifactIds.length ||
582
+ !Array.isArray(value.contentClasses) ||
583
+ value.contentClasses.some((contentClass) => typeof contentClass !== "string" || !CONTENT_CLASSES.has(contentClass)) ||
584
+ !safeStringArray(value.limitations, 100, 2_000) ||
585
+ typeof value.recordedAt !== "string" ||
586
+ !Number.isFinite(Date.parse(value.recordedAt))) {
587
+ throw contentError("Stored artifact decomposition is invalid.", "RESEARCH_DECOMPOSITION_STORE_INVALID");
588
+ }
589
+ const record = value;
590
+ const { decompositionId: _id, decompositionSha256, recordedAt: _time, ...stable } = record;
591
+ if (sha256Text(canonicalJson(stable)) !== decompositionSha256) {
592
+ throw contentError("Stored artifact decomposition hash binding is invalid.", "RESEARCH_DECOMPOSITION_STORE_INVALID");
593
+ }
594
+ return record;
595
+ }
596
+ function parseAtomRecord(value) {
597
+ if (!isObject(value) ||
598
+ value.schemaVersion !== 1 ||
599
+ !identifierValue(value.atomId) ||
600
+ typeof value.atomSha256 !== "string" ||
601
+ !SHA256.test(value.atomSha256) ||
602
+ !identifierValue(value.projectId) ||
603
+ !identifierValue(value.sourceId) ||
604
+ !identifierValue(value.candidateId) ||
605
+ !identifierValue(value.artifactId) ||
606
+ typeof value.artifactSha256 !== "string" ||
607
+ !SHA256.test(value.artifactSha256) ||
608
+ !validAtomLocator(value.locator) ||
609
+ typeof value.excerpt !== "string" ||
610
+ typeof value.excerptSha256 !== "string" ||
611
+ sha256Text(value.excerpt) !== value.excerptSha256 ||
612
+ !boundedString(value.statement, 8, 2_000) ||
613
+ !safeIdentifierArray(value.evidenceRoleIds, 100) ||
614
+ !safeIdentifierArray(value.coverageDimensionIds, 100) ||
615
+ typeof value.evidenceFunction !== "string" ||
616
+ !EVIDENCE_FUNCTIONS.has(value.evidenceFunction) ||
617
+ !boundedString(value.scope, 8, 1_000) ||
618
+ !safeStringArray(value.limitations, 100, 2_000) ||
619
+ typeof value.registeredAt !== "string" ||
620
+ !Number.isFinite(Date.parse(value.registeredAt))) {
621
+ throw contentError("Stored evidence atom is invalid.", "RESEARCH_EVIDENCE_ATOM_STORE_INVALID");
622
+ }
623
+ const record = value;
624
+ const { atomSha256, registeredAt: _time, ...stable } = record;
625
+ if (sha256Text(canonicalJson(stable)) !== atomSha256) {
626
+ throw contentError("Stored evidence atom hash binding is invalid.", "RESEARCH_EVIDENCE_ATOM_STORE_INVALID");
627
+ }
628
+ return record;
629
+ }
630
+ function parseContentSnapshot(value) {
631
+ if (!isObject(value) ||
632
+ value.schemaVersion !== 1 ||
633
+ value.kind !== "tiangong-evidence-content-snapshot" ||
634
+ !identifierValue(value.snapshotId) ||
635
+ typeof value.snapshotSha256 !== "string" ||
636
+ !SHA256.test(value.snapshotSha256) ||
637
+ !identifierValue(value.projectId) ||
638
+ !identifierValue(value.acquisitionSnapshotId) ||
639
+ typeof value.acquisitionSnapshotSha256 !== "string" ||
640
+ !SHA256.test(value.acquisitionSnapshotSha256) ||
641
+ typeof value.createdAt !== "string" ||
642
+ !Number.isFinite(Date.parse(value.createdAt)) ||
643
+ typeof value.ledgerHead !== "string" ||
644
+ !SHA256.test(value.ledgerHead) ||
645
+ !Array.isArray(value.decompositions) ||
646
+ !Array.isArray(value.atoms) ||
647
+ !Array.isArray(value.sourceCoverage) ||
648
+ !Array.isArray(value.roleCoverage) ||
649
+ !isObject(value.gate) ||
650
+ !["pass", "stop"].includes(String(value.gate.decision)) ||
651
+ !safeStringArray(value.gate.reasons, 10_000, 4_000) ||
652
+ !safeIdentifierArray(value.gate.requiredDecompositionArtifactIds, 10_000) ||
653
+ !safeIdentifierArray(value.gate.missingDecompositionArtifactIds, 10_000) ||
654
+ !safeIdentifierArray(value.gate.acceptedFullTextSourceIds, 10_000) ||
655
+ !safeIdentifierArray(value.gate.sourcesWithoutAtoms, 10_000)) {
656
+ throw contentError("Evidence content snapshot is malformed.", "RESEARCH_EVIDENCE_CONTENT_SNAPSHOT_INVALID");
657
+ }
658
+ for (const record of value.decompositions)
659
+ parseDecompositionRecord(record);
660
+ for (const record of value.atoms)
661
+ parseAtomRecord(record);
662
+ return value;
663
+ }
664
+ function validAtomLocator(value) {
665
+ if (!isObject(value) || typeof value.kind !== "string")
666
+ return false;
667
+ if (value.kind === "line-range") {
668
+ return (Object.keys(value).every((key) => ["kind", "startLine", "endLine"].includes(key)) &&
669
+ Number.isInteger(value.startLine) &&
670
+ Number(value.startLine) >= 1 &&
671
+ Number.isInteger(value.endLine) &&
672
+ Number(value.endLine) >= Number(value.startLine) &&
673
+ Number(value.endLine) - Number(value.startLine) < 20);
674
+ }
675
+ return (value.kind === "json-pointer" &&
676
+ Object.keys(value).every((key) => ["kind", "pointer"].includes(key)) &&
677
+ typeof value.pointer === "string" &&
678
+ value.pointer.startsWith("/") &&
679
+ value.pointer.length <= 1_000);
680
+ }
681
+ function decompositionRecordPath(root, projectId, artifactId) {
682
+ return resolveContained(workspacePaths(root).projects, `${projectId}/evidence/decompositions/${artifactId}.json`);
683
+ }
684
+ function atomRecordPath(root, projectId, atomId) {
685
+ return resolveContained(workspacePaths(root).projects, `${projectId}/evidence/atoms/${atomId}.json`);
686
+ }
687
+ function identifierValue(value) {
688
+ return typeof value === "string" && IDENTIFIER.test(value);
689
+ }
690
+ function boundedString(value, minimum, maximum) {
691
+ return typeof value === "string" && value.trim().length >= minimum && value.length <= maximum;
692
+ }
693
+ function safeIdentifierArray(value, maximum) {
694
+ return (Array.isArray(value) &&
695
+ value.length <= maximum &&
696
+ value.every(identifierValue) &&
697
+ new Set(value).size === value.length);
698
+ }
699
+ function safeStringArray(value, maximumItems, maximumLength) {
700
+ return (Array.isArray(value) &&
701
+ value.length <= maximumItems &&
702
+ value.every((item) => typeof item === "string" && item.length <= maximumLength));
703
+ }
704
+ function assertSafeContent(value, message) {
705
+ if (sanitizeResearchText(value, configuredResearchSecrets(process.env)) !== value) {
706
+ throw contentError(message, "RESEARCH_EVIDENCE_CONTENT_SENSITIVE");
707
+ }
708
+ }
709
+ function sortedUnique(values) {
710
+ return [...new Set(values)].sort();
711
+ }
712
+ function contentError(message, code) {
713
+ return new CliError(message, { code, exitCode: 3 });
714
+ }
715
+ //# sourceMappingURL=content-evidence.js.map