@tiangong-ai/cli 0.0.57 → 0.0.58

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 (59) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +106 -3
  3. package/dist/research/orchestration.js +96 -26
  4. package/dist/research/orchestration.js.map +1 -1
  5. package/dist/research/workspace/acquisition-forecast.d.ts +18 -0
  6. package/dist/research/workspace/acquisition-forecast.js +116 -0
  7. package/dist/research/workspace/acquisition-forecast.js.map +1 -0
  8. package/dist/research/workspace/acquisition.d.ts +13 -1
  9. package/dist/research/workspace/acquisition.js +71 -62
  10. package/dist/research/workspace/acquisition.js.map +1 -1
  11. package/dist/research/workspace/artifacts.d.ts +18 -0
  12. package/dist/research/workspace/artifacts.js +40 -2
  13. package/dist/research/workspace/artifacts.js.map +1 -1
  14. package/dist/research/workspace/content-evidence.d.ts +37 -0
  15. package/dist/research/workspace/content-evidence.js +287 -137
  16. package/dist/research/workspace/content-evidence.js.map +1 -1
  17. package/dist/research/workspace/downloads.js +2 -2
  18. package/dist/research/workspace/downloads.js.map +1 -1
  19. package/dist/research/workspace/evidence-content-schema.d.ts +14 -0
  20. package/dist/research/workspace/evidence-content-schema.js +123 -0
  21. package/dist/research/workspace/evidence-content-schema.js.map +1 -0
  22. package/dist/research/workspace/evidence-ledger.d.ts +1 -1
  23. package/dist/research/workspace/evidence-ledger.js.map +1 -1
  24. package/dist/research/workspace/evidence-role-coverage.d.ts +35 -0
  25. package/dist/research/workspace/evidence-role-coverage.js +81 -0
  26. package/dist/research/workspace/evidence-role-coverage.js.map +1 -0
  27. package/dist/research/workspace/journal.d.ts +2 -0
  28. package/dist/research/workspace/journal.js +6 -0
  29. package/dist/research/workspace/journal.js.map +1 -1
  30. package/dist/research/workspace/preflight.d.ts +2 -0
  31. package/dist/research/workspace/preflight.js +14 -5
  32. package/dist/research/workspace/preflight.js.map +1 -1
  33. package/dist/research/workspace/projects.d.ts +7 -0
  34. package/dist/research/workspace/projects.js +65 -19
  35. package/dist/research/workspace/projects.js.map +1 -1
  36. package/dist/research/workspace/review-executor.d.ts +2 -0
  37. package/dist/research/workspace/review-executor.js +69 -1
  38. package/dist/research/workspace/review-executor.js.map +1 -1
  39. package/dist/research/workspace/runtime.d.ts +8 -1
  40. package/dist/research/workspace/runtime.js +54 -28
  41. package/dist/research/workspace/runtime.js.map +1 -1
  42. package/dist/research/workspace/schemas.js +10 -2
  43. package/dist/research/workspace/schemas.js.map +1 -1
  44. package/dist/research/workspace/scientific-design.d.ts +2 -1
  45. package/dist/research/workspace/scientific-design.js +39 -2
  46. package/dist/research/workspace/scientific-design.js.map +1 -1
  47. package/dist/research/workspace/scientific-review-execution.d.ts +19 -0
  48. package/dist/research/workspace/scientific-review-execution.js +445 -0
  49. package/dist/research/workspace/scientific-review-execution.js.map +1 -0
  50. package/dist/research/workspace/scientific-review.d.ts +110 -3
  51. package/dist/research/workspace/scientific-review.js +113 -20
  52. package/dist/research/workspace/scientific-review.js.map +1 -1
  53. package/dist/research/workspace/setup-catalog.js +2 -2
  54. package/dist/research/workspace/types.d.ts +4 -1
  55. package/dist/research/workspace/types.js +14 -1
  56. package/dist/research/workspace/types.js.map +1 -1
  57. package/dist/research/workspace/workspace.js +13 -1
  58. package/dist/research/workspace/workspace.js.map +1 -1
  59. package/package.json +1 -1
@@ -1,49 +1,56 @@
1
- import { chmod, readFile, readdir } from "node:fs/promises";
1
+ import { chmod, lstat, readFile, readdir } from "node:fs/promises";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { CliError } from "../../errors.js";
4
- import { loadCurrentEvidenceSnapshot } from "./acquisition.js";
4
+ import { loadCurrentEvidenceSnapshot, loadVerifiedEvidencePreparationView } from "./acquisition.js";
5
5
  import { loadEvidenceArtifactRecords } from "./artifacts.js";
6
6
  import { loadBoundAcquisitionDesign } from "./acquisition-routes.js";
7
+ import { EVIDENCE_CONTENT_CLASSES, EVIDENCE_CONTENT_FUNCTIONS, EVIDENCE_CONTENT_IDENTIFIER, EVIDENCE_CONTENT_LIMITS, isEvidenceContentInputShape, } from "./evidence-content-schema.js";
7
8
  import { appendEvidenceLedgerEvent, evidenceLedgerPath } from "./evidence-ledger.js";
8
- import { readJournal } from "./journal.js";
9
+ import { computeRoleCoverage } from "./evidence-role-coverage.js";
10
+ import { readJournal, readVerifiedJournal } from "./journal.js";
9
11
  import { loadProject } from "./projects.js";
10
12
  import { configuredResearchSecrets, sanitizeResearchText } from "./sanitization.js";
11
13
  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}$/;
14
+ const IDENTIFIER = new RegExp(EVIDENCE_CONTENT_IDENTIFIER);
13
15
  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
- ]);
16
+ const CONTENT_CLASSES = new Set(EVIDENCE_CONTENT_CLASSES);
17
+ const EVIDENCE_FUNCTIONS = new Set(EVIDENCE_CONTENT_FUNCTIONS);
32
18
  const PRODUCER_VISIBLE_MEDIA_TYPES = new Set([
33
19
  "application/json",
34
20
  "text/plain",
35
21
  "text/markdown",
36
22
  "text/csv",
37
23
  ]);
38
- const MAX_EXCERPT_BYTES = 8_000;
24
+ const MAX_EXCERPT_BYTES = EVIDENCE_CONTENT_LIMITS.maxExcerptBytes;
25
+ const MAX_BATCH_RECORDS = EVIDENCE_CONTENT_LIMITS.maxBatchRecords;
26
+ const MAX_BATCH_INPUT_BYTES = EVIDENCE_CONTENT_LIMITS.maxBatchInputBytes;
39
27
  export async function recordArtifactDecomposition(input) {
40
- await assertContentPreparationWindow(input.root, input.projectId);
28
+ const view = await loadContentPreparationView(input.root, input.projectId);
29
+ const record = prepareDecompositionRecord(input, view);
30
+ const existing = (await loadDecompositionRecords(input.root, input.projectId)).find((item) => item.sourceArtifactId === record.sourceArtifactId);
31
+ if (existing) {
32
+ assertSameContentRecord(existing.decompositionSha256, record.decompositionSha256, "decomposition");
33
+ return existing;
34
+ }
35
+ const destination = decompositionRecordPath(input.root, input.projectId, record.sourceArtifactId);
36
+ await writeJsonAtomic(destination, record, 0o444);
37
+ await chmod(destination, 0o444).catch(() => undefined);
38
+ await appendEvidenceLedgerEvent(input.root, input.projectId, "decomposition.recorded", {
39
+ decompositionId: record.decompositionId,
40
+ decompositionSha256: record.decompositionSha256,
41
+ sourceArtifactId: record.sourceArtifactId,
42
+ sourceArtifactSha256: record.sourceArtifactSha256,
43
+ candidateId: record.candidateId,
44
+ status: record.status,
45
+ outputArtifactIds: record.outputArtifactIds,
46
+ outputArtifactSha256s: record.outputArtifactSha256s,
47
+ contentClasses: record.contentClasses,
48
+ });
49
+ return record;
50
+ }
51
+ function prepareDecompositionRecord(input, view) {
41
52
  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
- ]));
53
+ const { acquisition, artifacts } = view;
47
54
  const source = artifacts.get(value.sourceArtifactId);
48
55
  if (!source ||
49
56
  !acquisition.artifacts.some((artifact) => artifact.artifactId === source.artifactId)) {
@@ -81,48 +88,61 @@ export async function recordArtifactDecomposition(input) {
81
88
  decompositionSha256,
82
89
  recordedAt: new Date().toISOString(),
83
90
  };
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
- }
91
+ return record;
92
+ }
93
+ export async function registerEvidenceAtom(input) {
94
+ const view = await loadContentPreparationView(input.root, input.projectId);
95
+ const record = await prepareEvidenceAtom(input, view);
96
+ const existing = (await loadEvidenceAtomRecords(input.root, input.projectId)).find((item) => item.atomId === record.atomId);
97
+ if (existing) {
98
+ assertSameContentRecord(existing.atomSha256, record.atomSha256, "atom");
90
99
  return existing;
91
100
  }
101
+ const destination = atomRecordPath(input.root, input.projectId, record.atomId);
92
102
  await writeJsonAtomic(destination, record, 0o444);
93
103
  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
+ await appendEvidenceLedgerEvent(input.root, input.projectId, "atom.registered", {
105
+ atomId: record.atomId,
106
+ atomSha256: record.atomSha256,
107
+ sourceId: record.sourceId,
108
+ candidateId: record.candidateId,
109
+ artifactId: record.artifactId,
110
+ artifactSha256: record.artifactSha256,
111
+ excerptSha256: record.excerptSha256,
112
+ evidenceRoleIds: record.evidenceRoleIds,
113
+ coverageDimensionIds: record.coverageDimensionIds,
114
+ evidenceFunction: record.evidenceFunction,
104
115
  });
105
116
  return record;
106
117
  }
107
- export async function registerEvidenceAtom(input) {
108
- await assertContentPreparationWindow(input.root, input.projectId);
118
+ async function prepareEvidenceAtom(input, view) {
109
119
  const value = parseAtomInput(input.value);
110
- const acquisition = await loadCurrentEvidenceSnapshot(input.root, input.projectId);
120
+ const { acquisition } = view;
111
121
  const source = acquisition.sources.find((candidate) => candidate.id === value.sourceId);
112
122
  if (!source ||
113
123
  !Array.isArray(source.artifactIds) ||
114
124
  !source.artifactIds.includes(value.artifactId)) {
115
125
  throw contentError("Evidence atom source and artifact must belong to the same frozen acquisition source.", "RESEARCH_EVIDENCE_ATOM_SOURCE_INVALID");
116
126
  }
117
- const artifact = (await loadEvidenceArtifactRecords(input.root, input.projectId)).find((candidate) => candidate.artifactId === value.artifactId);
127
+ const artifact = view.artifacts.get(value.artifactId);
118
128
  if (!artifact ||
119
129
  artifact.candidateId !== value.candidateId ||
120
130
  !PRODUCER_VISIBLE_MEDIA_TYPES.has(artifact.mediaType)) {
121
131
  throw contentError("Evidence atoms may reference only producer-readable artifacts bound to the declared candidate.", "RESEARCH_EVIDENCE_ATOM_ARTIFACT_INVALID");
122
132
  }
123
- await validateAtomTaxonomy(input.root, input.projectId, source, value);
133
+ validateAtomTaxonomy(source, value, view.roleIds);
124
134
  const artifactPath = resolveContained(workspacePaths(input.root).control, artifact.locator);
125
- const excerpt = await extractAtomExcerpt(artifactPath, artifact.mediaType, value.locator);
135
+ let document = view.texts.get(artifact.artifactId);
136
+ if (document === undefined) {
137
+ const text = await readFile(artifactPath, "utf8");
138
+ if (sha256Text(text) !== artifact.sha256) {
139
+ throw contentError("Evidence atom artifact changed during preparation.", "RESEARCH_ARTIFACT_DRIFT");
140
+ }
141
+ view.work.excerptFileReads += 1;
142
+ document = { text };
143
+ view.texts.set(artifact.artifactId, document);
144
+ }
145
+ const excerpt = extractAtomExcerptFromDocument(document, artifact.mediaType, value.locator);
126
146
  assertSafeContent(excerpt, "Evidence atom excerpt contains sensitive material.");
127
147
  const stable = {
128
148
  schemaVersion: 1,
@@ -148,29 +168,202 @@ export async function registerEvidenceAtom(input) {
148
168
  atomSha256,
149
169
  registeredAt: new Date().toISOString(),
150
170
  };
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");
171
+ return record;
172
+ }
173
+ /** Caller holds one workspace mutation lock for the whole operation. */
174
+ export async function registerEvidenceContentBatch(input) {
175
+ const values = input.value.records;
176
+ if (input.value.schemaVersion !== 1 ||
177
+ Object.keys(input.value).some((key) => !["schemaVersion", "records"].includes(key)) ||
178
+ !Array.isArray(values) ||
179
+ values.length < 1 ||
180
+ values.length > MAX_BATCH_RECORDS ||
181
+ values.some((value) => !isObject(value)) ||
182
+ Buffer.byteLength(canonicalJson(input.value)) > MAX_BATCH_INPUT_BYTES ||
183
+ !isEvidenceContentInputShape(input.kind === "atom" ? "evidence-atom-batch" : "artifact-decomposition-batch", input.value)) {
184
+ throw contentError(`Evidence batch requires 1-${MAX_BATCH_RECORDS} records within ${MAX_BATCH_INPUT_BYTES} bytes.`, "RESEARCH_EVIDENCE_BATCH_INVALID");
185
+ }
186
+ const view = await loadContentPreparationView(input.root, input.projectId);
187
+ const existingRecords = input.kind === "atom"
188
+ ? await loadEvidenceAtomRecords(input.root, input.projectId)
189
+ : await loadDecompositionRecords(input.root, input.projectId);
190
+ const existing = new Map(existingRecords.map((record) => [contentRecordId(record), record]));
191
+ const seen = new Set();
192
+ const records = [];
193
+ let added = 0;
194
+ // Group extraction by artifact, then restore caller order. At most one parsed
195
+ // document stays live, and each referenced artifact is read/parsed once.
196
+ const ordered = values.map((value, index) => ({
197
+ value: value,
198
+ index,
199
+ }));
200
+ if (input.kind === "atom")
201
+ ordered.sort((left, right) => String(left.value.artifactId).localeCompare(String(right.value.artifactId)));
202
+ let currentArtifact;
203
+ for (const { value, index } of ordered) {
204
+ if (value.artifactId !== currentArtifact) {
205
+ view.texts.clear();
206
+ currentArtifact = value.artifactId;
156
207
  }
157
- return existing;
208
+ const prepared = input.kind === "atom"
209
+ ? await prepareEvidenceAtom({ ...input, value: value }, view)
210
+ : prepareDecompositionRecord({ ...input, value: value }, view);
211
+ const id = contentRecordId(prepared);
212
+ if (seen.has(id)) {
213
+ throw contentError("Evidence batch contains duplicate record identities.", "RESEARCH_EVIDENCE_BATCH_INVALID");
214
+ }
215
+ seen.add(id);
216
+ const previous = existing.get(id);
217
+ if (previous)
218
+ assertSameContentRecord(contentRecordSha256(previous), contentRecordSha256(prepared), input.kind);
219
+ else
220
+ added += 1;
221
+ records[index] = previous ?? prepared;
158
222
  }
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;
223
+ const stable = {
224
+ schemaVersion: 1,
225
+ projectId: input.projectId,
226
+ kind: input.kind,
227
+ records,
228
+ };
229
+ const batchSha256 = contentBatchHash(stable);
230
+ let batch = { ...stable, batchSha256 };
231
+ const committed = added === 0 &&
232
+ (await readVerifiedJournal(evidenceLedgerPath(input.root, input.projectId))).some((event) => event.type === "content.batch.registered" && event.payload.batchSha256 === batchSha256);
233
+ if (!committed) {
234
+ const path = contentBatchPath(input.root, input.projectId, batchSha256);
235
+ if (await pathExists(path)) {
236
+ // A crash before the commit event can leave a complete invisible envelope.
237
+ // Reuse only its verified bytes, preserving the original timestamps.
238
+ batch = await readContentBatch(path, input.projectId, batchSha256, input.kind);
239
+ }
240
+ else {
241
+ await writeJsonAtomic(path, batch, 0o444);
242
+ }
243
+ await appendEvidenceLedgerEvent(input.root, input.projectId, "content.batch.registered", {
244
+ batchSha256,
245
+ envelopeSha256: sha256Text(canonicalJson(batch)),
246
+ kind: input.kind,
247
+ recordCount: records.length,
248
+ });
249
+ view.work.ledgerAppends += 1;
250
+ }
251
+ return { ...batch, work: view.work };
252
+ }
253
+ async function loadContentPreparationView(root, projectId) {
254
+ const project = await assertContentPreparationWindow(root, projectId);
255
+ const work = {
256
+ acquisitionVerifications: 0,
257
+ artifactStoreScans: 0,
258
+ excerptFileReads: 0,
259
+ ledgerAppends: 0,
260
+ };
261
+ const verified = await loadVerifiedEvidencePreparationView(root, projectId);
262
+ work.acquisitionVerifications += 1;
263
+ work.artifactStoreScans += 1;
264
+ const roleIds = project.scientificDesign
265
+ ? new Set((await loadBoundAcquisitionDesign(root, project)).evidenceRoles.map((role) => role.id))
266
+ : null;
267
+ return {
268
+ acquisition: verified.snapshot,
269
+ artifacts: new Map(verified.artifacts.map((artifact) => [artifact.artifactId, artifact])),
270
+ roleIds,
271
+ texts: new Map(),
272
+ work,
273
+ };
274
+ }
275
+ function contentRecordId(record) {
276
+ return "atomId" in record ? record.atomId : record.sourceArtifactId;
277
+ }
278
+ function contentRecordSha256(record) {
279
+ return "atomSha256" in record ? record.atomSha256 : record.decompositionSha256;
280
+ }
281
+ function assertSameContentRecord(previous, next, kind) {
282
+ if (previous !== next) {
283
+ throw contentError(`Evidence ${kind} identity already exists with different content.`, kind === "atom" ? "RESEARCH_EVIDENCE_ATOM_CONFLICT" : "RESEARCH_DECOMPOSITION_CONFLICT");
284
+ }
285
+ }
286
+ function contentBatchHash(batch) {
287
+ return sha256Text(canonicalJson({
288
+ schemaVersion: batch.schemaVersion,
289
+ projectId: batch.projectId,
290
+ kind: batch.kind,
291
+ records: batch.records.map((record) => ({
292
+ id: contentRecordId(record),
293
+ sha256: contentRecordSha256(record),
294
+ })),
295
+ }));
296
+ }
297
+ function contentBatchPath(root, projectId, sha256) {
298
+ return resolveContained(workspacePaths(root).projects, `${projectId}/evidence/content-batches/${sha256}.json`);
299
+ }
300
+ async function readContentBatch(path, projectId, sha256, kind) {
301
+ const info = await lstat(path).catch(() => undefined);
302
+ if (!info?.isFile() || info.isSymbolicLink() || info.size > 16 * 1024 * 1024) {
303
+ throw contentError("Committed evidence batch file is absent or invalid.", "RESEARCH_EVIDENCE_BATCH_INVALID");
304
+ }
305
+ let value;
306
+ try {
307
+ value = JSON.parse(await readFile(path, "utf8"));
308
+ }
309
+ catch {
310
+ throw contentError("Stored evidence batch is not valid JSON.", "RESEARCH_EVIDENCE_BATCH_INVALID");
311
+ }
312
+ if (!isObject(value) ||
313
+ value.schemaVersion !== 1 ||
314
+ value.projectId !== projectId ||
315
+ value.kind !== kind ||
316
+ value.batchSha256 !== sha256 ||
317
+ !Array.isArray(value.records) ||
318
+ value.records.length < 1 ||
319
+ value.records.length > MAX_BATCH_RECORDS) {
320
+ throw contentError("Stored evidence batch identity is invalid.", "RESEARCH_EVIDENCE_BATCH_INVALID");
321
+ }
322
+ const records = value.records.map((record) => kind === "atom" ? parseAtomRecord(record) : parseDecompositionRecord(record));
323
+ const batch = { schemaVersion: 1, projectId, kind, batchSha256: sha256, records };
324
+ if (records.some((record) => record.projectId !== projectId) ||
325
+ new Set(records.map(contentRecordId)).size !== records.length ||
326
+ contentBatchHash(batch) !== sha256) {
327
+ throw contentError("Stored evidence batch hash binding is invalid.", "RESEARCH_EVIDENCE_BATCH_INVALID");
328
+ }
329
+ return batch;
330
+ }
331
+ async function loadCommittedContentBatchRecords(root, projectId, kind) {
332
+ const ledger = evidenceLedgerPath(root, projectId);
333
+ const events = await readVerifiedJournal(ledger);
334
+ const records = [];
335
+ const seen = new Set();
336
+ for (const event of events) {
337
+ if (event.type !== "content.batch.registered" || event.payload.kind !== kind)
338
+ continue;
339
+ const hash = event.payload.batchSha256;
340
+ if (event.scope !== projectId ||
341
+ typeof hash !== "string" ||
342
+ !SHA256.test(hash) ||
343
+ seen.has(hash)) {
344
+ throw contentError("Evidence batch commit event is invalid.", "RESEARCH_EVIDENCE_BATCH_INVALID");
345
+ }
346
+ seen.add(hash);
347
+ const batch = await readContentBatch(contentBatchPath(root, projectId, hash), projectId, hash, kind);
348
+ if (event.payload.recordCount !== batch.records.length ||
349
+ event.payload.envelopeSha256 !== sha256Text(canonicalJson(batch))) {
350
+ throw contentError("Evidence batch commit count is invalid.", "RESEARCH_EVIDENCE_BATCH_INVALID");
351
+ }
352
+ records.push(...batch.records);
353
+ }
354
+ return records;
355
+ }
356
+ function mergeContentRecords(records, kind) {
357
+ const merged = new Map();
358
+ for (const record of records) {
359
+ const id = contentRecordId(record);
360
+ const previous = merged.get(id);
361
+ if (previous)
362
+ assertSameContentRecord(contentRecordSha256(previous), contentRecordSha256(record), kind);
363
+ else
364
+ merged.set(id, record);
365
+ }
366
+ return [...merged.values()].sort((left, right) => contentRecordId(left).localeCompare(contentRecordId(right)));
174
367
  }
175
368
  export async function freezeEvidenceContentSnapshot(root, projectId) {
176
369
  await assertContentPreparationWindow(root, projectId);
@@ -324,9 +517,9 @@ export async function loadCurrentEvidenceContentSnapshot(root, projectId) {
324
517
  }
325
518
  export async function loadDecompositionRecords(root, projectId) {
326
519
  const directory = resolveContained(workspacePaths(root).projects, `${projectId}/evidence/decompositions`);
327
- if (!(await pathExists(directory)))
328
- return [];
329
- const entries = await readdir(directory, { withFileTypes: true });
520
+ const entries = (await pathExists(directory))
521
+ ? await readdir(directory, { withFileTypes: true })
522
+ : [];
330
523
  const records = [];
331
524
  for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
332
525
  if (!entry.isFile() || entry.isSymbolicLink() || !entry.name.endsWith(".json"))
@@ -337,13 +530,14 @@ export async function loadDecompositionRecords(root, projectId) {
337
530
  }
338
531
  records.push(record);
339
532
  }
340
- return records;
533
+ const batched = await loadCommittedContentBatchRecords(root, projectId, "decomposition");
534
+ return mergeContentRecords([...records, ...batched], "decomposition");
341
535
  }
342
536
  export async function loadEvidenceAtomRecords(root, projectId) {
343
537
  const directory = resolveContained(workspacePaths(root).projects, `${projectId}/evidence/atoms`);
344
- if (!(await pathExists(directory)))
345
- return [];
346
- const entries = await readdir(directory, { withFileTypes: true });
538
+ const entries = (await pathExists(directory))
539
+ ? await readdir(directory, { withFileTypes: true })
540
+ : [];
347
541
  const records = [];
348
542
  for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
349
543
  if (!entry.isFile() || entry.isSymbolicLink() || !entry.name.endsWith(".json"))
@@ -354,7 +548,8 @@ export async function loadEvidenceAtomRecords(root, projectId) {
354
548
  }
355
549
  records.push(record);
356
550
  }
357
- return records;
551
+ const batched = await loadCommittedContentBatchRecords(root, projectId, "atom");
552
+ return mergeContentRecords([...records, ...batched], "atom");
358
553
  }
359
554
  async function assertContentPreparationWindow(root, projectId) {
360
555
  const project = await loadProject(root, projectId);
@@ -366,6 +561,7 @@ async function assertContentPreparationWindow(root, projectId) {
366
561
  analyze.attempts !== 0) {
367
562
  throw contentError("Evidence content preparation is allowed only after acquisition and before analysis starts.", "RESEARCH_EVIDENCE_CONTENT_STAGE_REQUIRED");
368
563
  }
564
+ return project;
369
565
  }
370
566
  function parseDecompositionInput(value) {
371
567
  const allowed = new Set([
@@ -377,7 +573,8 @@ function parseDecompositionInput(value) {
377
573
  "contentClasses",
378
574
  "limitations",
379
575
  ]);
380
- if (Object.keys(value).some((key) => !allowed.has(key)) ||
576
+ if (!isEvidenceContentInputShape("artifact-decomposition", value) ||
577
+ Object.keys(value).some((key) => !allowed.has(key)) ||
381
578
  value.schemaVersion !== 1 ||
382
579
  typeof value.sourceArtifactId !== "string" ||
383
580
  !IDENTIFIER.test(value.sourceArtifactId) ||
@@ -420,7 +617,8 @@ function parseAtomInput(value) {
420
617
  "scope",
421
618
  "limitations",
422
619
  ]);
423
- if (Object.keys(value).some((key) => !allowed.has(key)) ||
620
+ if (!isEvidenceContentInputShape("evidence-atom", value) ||
621
+ Object.keys(value).some((key) => !allowed.has(key)) ||
424
622
  value.schemaVersion !== 1 ||
425
623
  !identifierValue(value.atomId) ||
426
624
  !identifierValue(value.sourceId) ||
@@ -441,35 +639,31 @@ function parseAtomInput(value) {
441
639
  }
442
640
  return value;
443
641
  }
444
- async function validateAtomTaxonomy(root, projectId, source, value) {
642
+ function validateAtomTaxonomy(source, value, knownRoleIds) {
445
643
  const sourceDimensions = new Set(Array.isArray(source.coverageDimensions)
446
644
  ? source.coverageDimensions.filter((item) => typeof item === "string")
447
645
  : []);
448
646
  if (value.coverageDimensionIds.some((dimension) => !sourceDimensions.has(dimension))) {
449
647
  throw contentError("Evidence atom dimensions must be declared by its frozen acquisition source.", "RESEARCH_EVIDENCE_ATOM_TAXONOMY_INVALID");
450
648
  }
451
- const project = await loadProject(root, projectId);
452
- if (!project.scientificDesign) {
649
+ if (!knownRoleIds) {
453
650
  if (value.evidenceRoleIds.length) {
454
651
  throw contentError("A project without a scientific design cannot declare evidence-role IDs.", "RESEARCH_EVIDENCE_ATOM_TAXONOMY_INVALID");
455
652
  }
456
653
  return;
457
654
  }
458
- const design = await loadBoundAcquisitionDesign(root, project);
459
- const knownRoleIds = new Set(design.evidenceRoles.map((role) => role.id));
460
655
  if (value.evidenceRoleIds.length < 1 ||
461
656
  value.evidenceRoleIds.some((roleId) => !knownRoleIds.has(roleId))) {
462
657
  throw contentError("Scientific evidence atoms must bind only declared evidence-role IDs.", "RESEARCH_EVIDENCE_ATOM_TAXONOMY_INVALID");
463
658
  }
464
659
  }
465
- async function extractAtomExcerpt(path, mediaType, locator) {
466
- const text = await readFile(path, "utf8");
660
+ function extractAtomExcerptFromDocument(document, mediaType, locator) {
467
661
  let excerpt;
468
662
  if (locator.kind === "line-range") {
469
663
  if (!["text/plain", "text/markdown", "text/csv"].includes(mediaType)) {
470
664
  throw contentError("Line-range evidence atoms require a text or CSV artifact.", "RESEARCH_EVIDENCE_ATOM_LOCATOR_INVALID");
471
665
  }
472
- const lines = text.split(/\r\n|\n|\r/u);
666
+ const lines = (document.lines ??= document.text.split(/\r\n|\n|\r/u));
473
667
  if (locator.endLine > lines.length) {
474
668
  throw contentError("Evidence atom line range exceeds the artifact.", "RESEARCH_EVIDENCE_ATOM_LOCATOR_INVALID");
475
669
  }
@@ -479,7 +673,9 @@ async function extractAtomExcerpt(path, mediaType, locator) {
479
673
  if (mediaType !== "application/json") {
480
674
  throw contentError("JSON Pointer evidence atoms require an application/json artifact.", "RESEARCH_EVIDENCE_ATOM_LOCATOR_INVALID");
481
675
  }
482
- let selected = JSON.parse(text);
676
+ if (!("json" in document))
677
+ document.json = JSON.parse(document.text);
678
+ let selected = document.json;
483
679
  for (const segment of locator.pointer
484
680
  .slice(1)
485
681
  .split("/")
@@ -501,52 +697,6 @@ async function extractAtomExcerpt(path, mediaType, locator) {
501
697
  }
502
698
  return excerpt;
503
699
  }
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
700
  function artifactDescendsFrom(artifact, ancestorId, artifacts) {
551
701
  const visited = new Set();
552
702
  let current = artifact;