@open-agent-toolkit/cli 0.2.25 → 0.2.27

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 (87) hide show
  1. package/assets/NOTICES.md +156 -0
  2. package/assets/docs/cli-utilities/configuration.md +42 -11
  3. package/assets/docs/contributing/explainer-kit-verification.md +125 -0
  4. package/assets/docs/contributing/index.md +1 -0
  5. package/assets/docs/reference/troubleshooting.md +47 -0
  6. package/assets/docs/workflows/projects/artifacts.md +24 -6
  7. package/assets/docs/workflows/projects/dispatch-ceiling.md +67 -16
  8. package/assets/docs/workflows/skills/explainer-kit-providers.md +144 -0
  9. package/assets/docs/workflows/skills/explainer-kit.md +121 -69
  10. package/assets/docs/workflows/skills/index.md +1 -0
  11. package/assets/public-package-versions.json +4 -4
  12. package/assets/skills/explainer-kit/SKILL.md +18 -3
  13. package/assets/skills/explainer-kit/recipes/project-recap.json +43 -16
  14. package/assets/skills/explainer-kit/references/contracts.md +167 -20
  15. package/assets/skills/explainer-kit/references/golden-conformance.md +80 -0
  16. package/assets/skills/explainer-kit/references/visual-authoring.md +92 -0
  17. package/assets/skills/explainer-kit/references/visual-review.md +57 -0
  18. package/assets/skills/explainer-kit/schemas/author-request.v2.schema.json +172 -1
  19. package/assets/skills/explainer-kit/schemas/build-record.schema.json +7 -1
  20. package/assets/skills/explainer-kit/schemas/fact-base.schema.json +38 -2
  21. package/assets/skills/explainer-kit/schemas/manifest.schema.json +25 -1
  22. package/assets/skills/explainer-kit/schemas/run-request.schema.json +4 -0
  23. package/assets/skills/explainer-kit/schemas/set-plan.v1.schema.json +149 -0
  24. package/assets/skills/explainer-kit/schemas/visual-review-request.v1.schema.json +117 -0
  25. package/assets/skills/explainer-kit/schemas/visual-review-result.v1.schema.json +80 -0
  26. package/assets/skills/explainer-kit/scripts/lib/browser-runtime.mjs +148 -4
  27. package/assets/skills/explainer-kit/scripts/lib/catalog.mjs +243 -0
  28. package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +586 -8
  29. package/assets/skills/explainer-kit/scripts/lib/diagram.mjs +285 -8
  30. package/assets/skills/explainer-kit/scripts/lib/durability.mjs +35 -0
  31. package/assets/skills/explainer-kit/scripts/lib/fact-base.mjs +144 -8
  32. package/assets/skills/explainer-kit/scripts/lib/package-coverage.mjs +379 -0
  33. package/assets/skills/explainer-kit/scripts/lib/png.mjs +287 -0
  34. package/assets/skills/explainer-kit/scripts/lib/qa.mjs +280 -8
  35. package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +132 -3
  36. package/assets/skills/explainer-kit/scripts/lib/records.mjs +513 -21
  37. package/assets/skills/explainer-kit/scripts/lib/render.mjs +67 -3
  38. package/assets/skills/explainer-kit/scripts/lib/s3-static.mjs +43 -1
  39. package/assets/skills/explainer-kit/scripts/lib/set-plan.mjs +208 -0
  40. package/assets/skills/explainer-kit/scripts/lib/source-backlinks.mjs +218 -0
  41. package/assets/skills/explainer-kit/scripts/lib/visual-review.mjs +380 -0
  42. package/assets/skills/explainer-kit/scripts/render-qa.mjs +48 -10
  43. package/assets/skills/explainer-kit/scripts/run.mjs +859 -134
  44. package/assets/skills/oat-explainer-kit/SKILL.md +40 -12
  45. package/assets/skills/oat-explainer-kit/references/author-callback.md +12 -10
  46. package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +40 -2
  47. package/assets/skills/oat-explainer-kit/references/visual-review-callback.md +72 -0
  48. package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +167 -5
  49. package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +92 -5
  50. package/assets/skills/oat-explainer-kit/scripts/run.mjs +324 -2
  51. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +1 -1
  52. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +1 -1
  53. package/assets/skills/oat-project-implement/SKILL.md +9 -11
  54. package/assets/skills/oat-project-implement/references/dispatch-and-dry-run.md +18 -9
  55. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +1 -1
  56. package/assets/skills/oat-project-implement/references/phase-execution.md +13 -4
  57. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +1 -1
  58. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +1 -1
  59. package/dist/commands/config/index.d.ts.map +1 -1
  60. package/dist/commands/config/index.js +27 -3
  61. package/dist/commands/project/archive/archive-utils.d.ts +1 -0
  62. package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
  63. package/dist/commands/project/archive/archive-utils.js +109 -42
  64. package/dist/commands/project/archive/explainer-package-coverage.d.ts +14 -0
  65. package/dist/commands/project/archive/explainer-package-coverage.d.ts.map +1 -0
  66. package/dist/commands/project/archive/explainer-package-coverage.js +27 -0
  67. package/dist/commands/project/archive/explainer-source-backlinks.d.ts +18 -0
  68. package/dist/commands/project/archive/explainer-source-backlinks.d.ts.map +1 -0
  69. package/dist/commands/project/archive/explainer-source-backlinks.js +27 -0
  70. package/dist/commands/project/archive/push-runner.d.ts +2 -1
  71. package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
  72. package/dist/commands/project/archive/push-runner.js +5 -1
  73. package/dist/commands/project/dispatch-ceiling/index.d.ts.map +1 -1
  74. package/dist/commands/project/dispatch-ceiling/index.js +90 -0
  75. package/dist/config/dispatch-notices.d.ts +8 -0
  76. package/dist/config/dispatch-notices.d.ts.map +1 -0
  77. package/dist/config/dispatch-notices.js +79 -0
  78. package/dist/config/dispatch-policy-options.d.ts +2 -0
  79. package/dist/config/dispatch-policy-options.d.ts.map +1 -1
  80. package/dist/config/dispatch-policy-options.js +14 -2
  81. package/dist/providers/identity/dispatch-report.d.ts +17 -0
  82. package/dist/providers/identity/dispatch-report.d.ts.map +1 -1
  83. package/dist/providers/identity/dispatch-report.js +30 -0
  84. package/dist/release/public-package-contract.d.ts +6 -0
  85. package/dist/release/public-package-contract.d.ts.map +1 -1
  86. package/dist/release/public-package-contract.js +75 -0
  87. package/package.json +2 -2
@@ -68,11 +68,14 @@ export function parseDiagram(source) {
68
68
  return degraded('Diagram requires at least one node declaration or edge.');
69
69
  }
70
70
 
71
+ const topology = analyzeTopology([...nodes.values()], edges);
71
72
  return {
72
73
  valid: true,
73
74
  direction: headerMatch[1],
74
75
  nodes: [...nodes.values()],
75
76
  edges,
77
+ inlineSupported: topology.kind === 'linear',
78
+ topology,
76
79
  warnings: [],
77
80
  };
78
81
  }
@@ -86,14 +89,39 @@ export function renderDiagram(source, { theme } = {}) {
86
89
  degraded: true,
87
90
  };
88
91
  }
92
+ if (!parsed.inlineSupported) {
93
+ const features = parsed.topology.features.join(', ');
94
+ return {
95
+ html: `<div class="diagram-fallback" role="note"><p class="diagram-warning">${escapeHtml(`Non-linear diagram topology (${features}) requires artistic composition.`)}</p><pre><code>${escapeHtml(source)}</code></pre></div>`,
96
+ warnings: [
97
+ {
98
+ code: 'non-linear-diagram-reroute',
99
+ message: `Non-linear diagram topology (${features}) requires artistic composition.`,
100
+ },
101
+ ],
102
+ degraded: true,
103
+ reroute: {
104
+ target: 'artistic',
105
+ source,
106
+ graph: {
107
+ direction: parsed.direction,
108
+ nodes: structuredClone(parsed.nodes),
109
+ edges: structuredClone(parsed.edges),
110
+ },
111
+ topology: structuredClone(parsed.topology),
112
+ },
113
+ };
114
+ }
89
115
 
90
116
  const horizontal = parsed.direction === 'LR';
91
117
  const nodeWidth = 180;
92
118
  const nodeHeight = 72;
93
119
  const gap = 90;
94
120
  const margin = 50;
121
+ const nodeById = new Map(parsed.nodes.map((node) => [node.id, node]));
122
+ const orderedNodes = parsed.topology.order.map((id) => nodeById.get(id));
95
123
  const positions = new Map(
96
- parsed.nodes.map((node, index) => [
124
+ orderedNodes.map((node, index) => [
97
125
  node.id,
98
126
  horizontal
99
127
  ? { x: margin + index * (nodeWidth + gap), y: margin }
@@ -102,14 +130,14 @@ export function renderDiagram(source, { theme } = {}) {
102
130
  );
103
131
  const width = horizontal
104
132
  ? margin * 2 +
105
- parsed.nodes.length * nodeWidth +
106
- (parsed.nodes.length - 1) * gap
133
+ orderedNodes.length * nodeWidth +
134
+ (orderedNodes.length - 1) * gap
107
135
  : margin * 2 + nodeWidth;
108
136
  const height = horizontal
109
137
  ? margin * 2 + nodeHeight
110
138
  : margin * 2 +
111
- parsed.nodes.length * nodeHeight +
112
- (parsed.nodes.length - 1) * gap;
139
+ orderedNodes.length * nodeHeight +
140
+ (orderedNodes.length - 1) * gap;
113
141
  const markerId = `diagram-arrow-${stableHash(source)}`;
114
142
  const mode = theme?.modes?.[theme.defaultMode];
115
143
  const panel = mode?.surface?.panel ?? '#ffffff';
@@ -126,7 +154,7 @@ export function renderDiagram(source, { theme } = {}) {
126
154
  }),
127
155
  )
128
156
  .join('');
129
- const nodes = parsed.nodes
157
+ const nodes = orderedNodes
130
158
  .map((node) =>
131
159
  renderNode(node, positions.get(node.id), { nodeWidth, nodeHeight }),
132
160
  )
@@ -144,6 +172,206 @@ export function renderDiagram(source, { theme } = {}) {
144
172
  };
145
173
  }
146
174
 
175
+ export function graphSemanticsForArtisticAuthor(diagrams) {
176
+ if (
177
+ !Array.isArray(diagrams) ||
178
+ diagrams.some(({ valid }) => valid !== true)
179
+ ) {
180
+ throw topologyError(
181
+ 'Planner-owned artistic diagrams must use the supported graph grammar.',
182
+ );
183
+ }
184
+ return diagrams
185
+ .filter(({ inlineSupported }) => inlineSupported === false)
186
+ .map(({ direction, nodes, edges, topology }) =>
187
+ deepFreeze({
188
+ direction,
189
+ nodes: structuredClone(nodes),
190
+ edges: structuredClone(edges),
191
+ topology: structuredClone(topology),
192
+ }),
193
+ );
194
+ }
195
+
196
+ export function assertAuthoredGraphSemantics(html, graphSemantics) {
197
+ if (
198
+ typeof html !== 'string' ||
199
+ !Array.isArray(graphSemantics) ||
200
+ graphSemantics.length !== 1
201
+ ) {
202
+ throw topologyError(
203
+ 'Artistic graph validation requires one unambiguous planned graph.',
204
+ );
205
+ }
206
+ const [planned] = graphSemantics;
207
+ const tags = html.match(/<[^>]+>/g) ?? [];
208
+ const directionTags = tags.filter((tag) => tag.includes('data-direction'));
209
+ const observedDirections = directionTags.flatMap((tag) =>
210
+ attributeValues(tag, 'data-direction'),
211
+ );
212
+ if (
213
+ directionTags.length !== 1 ||
214
+ observedDirections.length !== 1 ||
215
+ observedDirections[0] !== planned.direction
216
+ ) {
217
+ throw topologyError(
218
+ 'Authored graph direction does not exactly match the planned graph.',
219
+ );
220
+ }
221
+
222
+ const nodeTags = tags.filter((tag) => tag.includes('data-node'));
223
+ const observedNodes = nodeTags.map((tag) => {
224
+ const id = canonicalAttribute(tag, 'data-node', {
225
+ pattern: new RegExp(`^${ID_PATTERN}$`),
226
+ });
227
+ const label = canonicalAttribute(tag, 'data-node-label');
228
+ const shape = canonicalAttribute(tag, 'data-node-shape', {
229
+ allowed: ['rectangle', 'rounded', 'diamond'],
230
+ });
231
+ const explicit = canonicalAttribute(tag, 'data-node-explicit', {
232
+ allowed: ['true', 'false'],
233
+ });
234
+ return canonicalTuple([id, label, shape, explicit]);
235
+ });
236
+ const edgeTags = tags.filter((tag) =>
237
+ ['data-from', 'data-to', 'data-edge-kind', 'data-edge-label'].some((name) =>
238
+ tag.includes(name),
239
+ ),
240
+ );
241
+ const observedEdges = edgeTags.map((tag) => {
242
+ const from = canonicalAttribute(tag, 'data-from', {
243
+ pattern: new RegExp(`^${ID_PATTERN}$`),
244
+ });
245
+ const to = canonicalAttribute(tag, 'data-to', {
246
+ pattern: new RegExp(`^${ID_PATTERN}$`),
247
+ });
248
+ const kind = canonicalAttribute(tag, 'data-edge-kind', {
249
+ allowed: ['arrow', 'line'],
250
+ });
251
+ const label = canonicalAttribute(tag, 'data-edge-label');
252
+ return canonicalTuple([from, to, kind, label]);
253
+ });
254
+
255
+ const expectedNodes = planned.nodes.map(({ id, label, shape, explicit }) =>
256
+ canonicalTuple([id, escapeAttribute(label), shape, String(explicit)]),
257
+ );
258
+ const expectedEdges = planned.edges.map(({ from, to, kind, label }) =>
259
+ canonicalTuple([from, to, kind, escapeAttribute(label)]),
260
+ );
261
+ if (
262
+ !sameMultiset(observedNodes, expectedNodes) ||
263
+ !sameMultiset(observedEdges, expectedEdges)
264
+ ) {
265
+ throw topologyError(
266
+ 'Authored graph node or edge multiset does not exactly match the planned graph.',
267
+ );
268
+ }
269
+ }
270
+
271
+ function analyzeTopology(nodes, edges) {
272
+ const nodeIds = nodes.map(({ id }) => id);
273
+ const incoming = new Map(nodeIds.map((id) => [id, []]));
274
+ const outgoing = new Map(nodeIds.map((id) => [id, []]));
275
+ for (const edge of edges) {
276
+ outgoing.get(edge.from).push(edge.to);
277
+ incoming.get(edge.to).push(edge.from);
278
+ }
279
+
280
+ const branchNodes = nodeIds.filter((id) => outgoing.get(id).length > 1);
281
+ const fanInNodes = nodeIds.filter((id) => incoming.get(id).length > 1);
282
+ const cycle = hasDirectedCycle(nodeIds, outgoing);
283
+ const connected = isWeaklyConnected(nodeIds, incoming, outgoing);
284
+ const features = [
285
+ ...(branchNodes.length > 0 ? ['branch'] : []),
286
+ ...(fanInNodes.length > 0 ? ['fan-in'] : []),
287
+ ...(cycle ? ['cycle'] : []),
288
+ ...(!connected ? ['disconnected'] : []),
289
+ ];
290
+ const order =
291
+ features.length === 0 ? linearOrder(nodeIds, incoming, outgoing) : [];
292
+ if (features.length === 0 && order.length !== nodeIds.length) {
293
+ features.push('non-linear');
294
+ }
295
+
296
+ return {
297
+ kind: features.length === 0 ? 'linear' : 'non-linear',
298
+ features,
299
+ branchNodes,
300
+ fanInNodes,
301
+ cycle,
302
+ order: features.length === 0 ? order : [],
303
+ };
304
+ }
305
+
306
+ function hasDirectedCycle(nodeIds, outgoing) {
307
+ const visiting = new Set();
308
+ const visited = new Set();
309
+ const visit = (id) => {
310
+ if (visiting.has(id)) return true;
311
+ if (visited.has(id)) return false;
312
+ visiting.add(id);
313
+ for (const next of outgoing.get(id)) {
314
+ if (visit(next)) return true;
315
+ }
316
+ visiting.delete(id);
317
+ visited.add(id);
318
+ return false;
319
+ };
320
+ return nodeIds.some((id) => visit(id));
321
+ }
322
+
323
+ function isWeaklyConnected(nodeIds, incoming, outgoing) {
324
+ if (nodeIds.length <= 1) return true;
325
+ const seen = new Set();
326
+ const pending = [nodeIds[0]];
327
+ while (pending.length > 0) {
328
+ const id = pending.pop();
329
+ if (seen.has(id)) continue;
330
+ seen.add(id);
331
+ pending.push(...incoming.get(id), ...outgoing.get(id));
332
+ }
333
+ return seen.size === nodeIds.length;
334
+ }
335
+
336
+ function linearOrder(nodeIds, incoming, outgoing) {
337
+ if (nodeIds.length === 1 && incoming.get(nodeIds[0]).length === 0) {
338
+ return [...nodeIds];
339
+ }
340
+ const starts = nodeIds.filter(
341
+ (id) => incoming.get(id).length === 0 && outgoing.get(id).length === 1,
342
+ );
343
+ const ends = nodeIds.filter(
344
+ (id) => incoming.get(id).length === 1 && outgoing.get(id).length === 0,
345
+ );
346
+ const middleIsLinear = nodeIds
347
+ .filter((id) => !starts.includes(id) && !ends.includes(id))
348
+ .every(
349
+ (id) => incoming.get(id).length === 1 && outgoing.get(id).length === 1,
350
+ );
351
+ if (
352
+ starts.length !== 1 ||
353
+ ends.length !== 1 ||
354
+ !middleIsLinear ||
355
+ edgesFor(outgoing) !== nodeIds.length - 1
356
+ ) {
357
+ return [];
358
+ }
359
+ const order = [];
360
+ let current = starts[0];
361
+ while (current !== undefined && !order.includes(current)) {
362
+ order.push(current);
363
+ current = outgoing.get(current)[0];
364
+ }
365
+ return order;
366
+ }
367
+
368
+ function edgesFor(outgoing) {
369
+ return [...outgoing.values()].reduce(
370
+ (total, targets) => total + targets.length,
371
+ 0,
372
+ );
373
+ }
374
+
147
375
  function parseNode(value) {
148
376
  const match = value.match(NODE_PATTERN);
149
377
  if (!match) return null;
@@ -176,7 +404,7 @@ function renderNode(node, position, { nodeWidth, nodeHeight }) {
176
404
  } else {
177
405
  shape = `<rect class="diagram-node-shape" x="${position.x}" y="${position.y}" width="${nodeWidth}" height="${nodeHeight}"${node.shape === 'rounded' ? ' rx="24"' : ''}></rect>`;
178
406
  }
179
- return `<g class="diagram-node" data-node="${escapeAttribute(node.id)}">${shape}<text class="diagram-node-label" x="${centerX}" y="${centerY}">${escapeHtml(node.label)}</text></g>`;
407
+ return `<g class="diagram-node" data-node="${escapeAttribute(node.id)}" data-node-label="${escapeAttribute(node.label)}" data-node-shape="${node.shape}" data-node-explicit="${String(node.explicit)}">${shape}<text class="diagram-node-label" x="${centerX}" y="${centerY}">${escapeHtml(node.label)}</text></g>`;
180
408
  }
181
409
 
182
410
  function renderEdge(
@@ -195,7 +423,7 @@ function renderEdge(
195
423
  const label = edge.label
196
424
  ? `<text class="diagram-edge-label" x="${(start.x + end.x) / 2}" y="${(start.y + end.y) / 2 - 8}">${escapeHtml(edge.label)}</text>`
197
425
  : '';
198
- return `<g class="diagram-connection" data-from="${escapeAttribute(edge.from)}" data-to="${escapeAttribute(edge.to)}"><path class="diagram-edge" d="M ${start.x} ${start.y} L ${end.x} ${end.y}"${edge.kind === 'arrow' ? ` marker-end="url(#${markerId})"` : ''}></path>${label}</g>`;
426
+ return `<g class="diagram-connection" data-from="${escapeAttribute(edge.from)}" data-to="${escapeAttribute(edge.to)}" data-edge-kind="${edge.kind}" data-edge-label="${escapeAttribute(edge.label)}"><path class="diagram-edge" d="M ${start.x} ${start.y} L ${end.x} ${end.y}"${edge.kind === 'arrow' ? ` marker-end="url(#${markerId})"` : ''}></path>${label}</g>`;
199
427
  }
200
428
 
201
429
  function degraded(message) {
@@ -223,6 +451,55 @@ function stableHash(value) {
223
451
  return (hash >>> 0).toString(16).padStart(8, '0');
224
452
  }
225
453
 
454
+ function attributeValues(tag, name) {
455
+ return [
456
+ ...tag.matchAll(
457
+ new RegExp(`(?:^|\\s)${name}\\s*=\\s*(["'])([^"'<>]*)\\1`, 'g'),
458
+ ),
459
+ ].map((match) => match[2]);
460
+ }
461
+
462
+ function canonicalAttribute(tag, name, { pattern, allowed } = {}) {
463
+ const values = attributeValues(tag, name);
464
+ if (
465
+ values.length !== 1 ||
466
+ (pattern && !pattern.test(values[0])) ||
467
+ (allowed && !allowed.includes(values[0]))
468
+ ) {
469
+ throw topologyError(
470
+ `Authored graph contains a malformed or ambiguous ${name} observation.`,
471
+ );
472
+ }
473
+ return values[0];
474
+ }
475
+
476
+ function canonicalTuple(values) {
477
+ return JSON.stringify(values);
478
+ }
479
+
480
+ function sameMultiset(actual, expected) {
481
+ return (
482
+ actual.length === expected.length &&
483
+ [...actual]
484
+ .sort()
485
+ .every((value, index) => value === [...expected].sort()[index])
486
+ );
487
+ }
488
+
489
+ function topologyError(message) {
490
+ const error = new Error(message);
491
+ error.code = 'E_DIAGRAM_TOPOLOGY';
492
+ return error;
493
+ }
494
+
495
+ function deepFreeze(value) {
496
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
497
+ Object.freeze(value);
498
+ for (const child of Object.values(value)) deepFreeze(child);
499
+ }
500
+ return value;
501
+ }
502
+
226
503
  function escapeHtml(value) {
227
504
  return String(value)
228
505
  .replaceAll('&', '&amp;')
@@ -27,6 +27,11 @@ export async function recordDurability(request, options = {}) {
27
27
  const buildRecord = await readJson(buildRecordPath);
28
28
  assertValid('build-record', buildRecord);
29
29
  assertValid('manifest', manifest, { buildRecord });
30
+ if (manifest.outcome === 'built-needs-review') {
31
+ throw new Error(
32
+ 'built-needs-review requires a passing visual review before durability attestation.',
33
+ );
34
+ }
30
35
  if (
31
36
  manifest.outcome !== 'built-not-durable' &&
32
37
  manifest.outcome !== 'built-durable'
@@ -175,6 +180,13 @@ export async function verifyRebuildability(artifact, runRoot) {
175
180
  }
176
181
 
177
182
  async function verifyEvidence(evidence, context) {
183
+ const immutableErrors = await verifyImmutablePackage(
184
+ context.runRoot,
185
+ context.manifest,
186
+ );
187
+ if (immutableErrors.length > 0) {
188
+ return { verified: false, errors: immutableErrors };
189
+ }
178
190
  const replayErrors = [];
179
191
  for (const artifact of context.manifest.artifacts) {
180
192
  if (artifact.status === 'built' && artifact.rebuildable === true) {
@@ -193,6 +205,29 @@ async function verifyEvidence(evidence, context) {
193
205
  : verifyPublishEvidence(evidence, context);
194
206
  }
195
207
 
208
+ async function verifyImmutablePackage(runRoot, manifest) {
209
+ const errors = [];
210
+ for (const [path, expectedHash] of Object.entries(manifest.immutableHashes)) {
211
+ try {
212
+ const bytes = await readFile(joinWithin(runRoot, path));
213
+ const actualHash = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
214
+ if (actualHash !== expectedHash) {
215
+ errors.push(
216
+ error('hash-mismatch', `Immutable package hash changed for ${path}.`),
217
+ );
218
+ }
219
+ } catch (caught) {
220
+ errors.push(
221
+ error(
222
+ 'missing-path',
223
+ `Immutable package evidence ${path} is unavailable: ${errorMessage(caught)}`,
224
+ ),
225
+ );
226
+ }
227
+ }
228
+ return errors;
229
+ }
230
+
196
231
  async function verifyCommitEvidence(evidence, { runRoot, manifest }) {
197
232
  const errors = [];
198
233
  let repoRoot;
@@ -1,4 +1,8 @@
1
1
  import { canonicalHash, validateContract } from './contracts.mjs';
2
+ import {
3
+ canonicalGithubBlobBacklink,
4
+ validateCanonicalGithubBlobTuple,
5
+ } from './source-backlinks.mjs';
2
6
 
3
7
  const FACT_BASE_VERSION = 'explainer-kit.fact-base/v1';
4
8
  const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
@@ -31,7 +35,9 @@ export async function processFactBase(binding, options = {}) {
31
35
  }
32
36
 
33
37
  function processSupplied(binding, { now, maxAgeMs }) {
34
- const factBase = structuredClone(binding.factBase);
38
+ const factBase = normalizeFactBaseBacklinks(
39
+ structuredClone(binding.factBase),
40
+ );
35
41
  if (!factBase || typeof factBase !== 'object') {
36
42
  throw new Error('Supplied mode requires a factBase object.');
37
43
  }
@@ -87,7 +93,9 @@ async function processFederated(binding, { critic, now }) {
87
93
  );
88
94
  }
89
95
 
90
- const sources = documents.map(({ source }) => structuredClone(source));
96
+ const sources = documents.map(({ source }) =>
97
+ normalizeSourceBacklink(structuredClone(source)),
98
+ );
91
99
  assertUniqueNonEmptyIds(sources, 'source');
92
100
  const sourceById = new Map(sources.map((source) => [source.id, source]));
93
101
  const observations = collectObservations(documents, sourceById);
@@ -266,6 +274,7 @@ function collectObservations(documents, sourceById) {
266
274
  text: claim.text,
267
275
  source: sourceById.get(document.source.id),
268
276
  locator: claim.locator ?? document.source.locator,
277
+ ...(claim.lineRange && { lineRange: structuredClone(claim.lineRange) }),
269
278
  ...(claim.sections && { sections: [...claim.sections] }),
270
279
  });
271
280
  observations.set(claim.id, entries);
@@ -366,10 +375,9 @@ function resolveObservations(claimId, entries) {
366
375
  }
367
376
 
368
377
  function citationsFor(entries) {
369
- const citations = entries.map(({ source, locator }) => ({
370
- sourceId: source.id,
371
- locator,
372
- }));
378
+ const citations = entries.map(({ source, locator, lineRange }) =>
379
+ citationForSource(source, { locator, lineRange }),
380
+ );
373
381
  return uniqueCitations(citations);
374
382
  }
375
383
 
@@ -432,8 +440,7 @@ function integrateCriticFindings({
432
440
  locator: `critic-finding:${finding.claimId}`,
433
441
  },
434
442
  ...finding.sourceIds.map((sourceId) => ({
435
- sourceId,
436
- locator: sourceById.get(sourceId).locator,
443
+ ...citationForSource(sourceById.get(sourceId)),
437
444
  })),
438
445
  ]);
439
446
  const existingUnresolved = unresolvedClaims.find(
@@ -500,6 +507,135 @@ function uniqueCitations(citations) {
500
507
  ];
501
508
  }
502
509
 
510
+ function normalizeFactBaseBacklinks(factBase) {
511
+ if (!factBase || typeof factBase !== 'object') return factBase;
512
+ if (
513
+ !Array.isArray(factBase.sources) ||
514
+ !Array.isArray(factBase.claims) ||
515
+ !Array.isArray(factBase.unresolvedClaims)
516
+ ) {
517
+ return factBase;
518
+ }
519
+ factBase.sources = factBase.sources.map((source) =>
520
+ source && typeof source === 'object'
521
+ ? normalizeSourceBacklink(source)
522
+ : source,
523
+ );
524
+ const sourceById = new Map(
525
+ factBase.sources
526
+ .filter((source) => source && typeof source === 'object')
527
+ .map((source) => [source.id, source]),
528
+ );
529
+ for (const claim of [...factBase.claims, ...factBase.unresolvedClaims]) {
530
+ if (
531
+ !claim ||
532
+ typeof claim !== 'object' ||
533
+ !Array.isArray(claim.citations)
534
+ ) {
535
+ continue;
536
+ }
537
+ claim.citations = claim.citations.map((citation) => {
538
+ if (!citation || typeof citation !== 'object') return citation;
539
+ const source = sourceById.get(citation.sourceId);
540
+ return source ? citationForSource(source, citation) : citation;
541
+ });
542
+ }
543
+ return factBase;
544
+ }
545
+
546
+ function normalizeSourceBacklink(source) {
547
+ const declaresBacklink = declaresBacklinkTuple(source);
548
+ if (!declaresBacklink) return source;
549
+ const url = canonicalGithubBlobBacklink(source);
550
+ if (source.url !== undefined && source.url !== url) {
551
+ throw new Error(
552
+ 'GitHub backlink provenance URL does not match its canonical tuple.',
553
+ );
554
+ }
555
+ return {
556
+ ...source,
557
+ kind: 'github',
558
+ locator: url,
559
+ url,
560
+ };
561
+ }
562
+
563
+ function citationForSource(source, citation = {}) {
564
+ if (source.url === undefined && !declaresBacklinkTuple(citation)) {
565
+ return {
566
+ sourceId: source.id,
567
+ locator: citation.locator ?? source.locator,
568
+ };
569
+ }
570
+ if (source.url === undefined) {
571
+ if (!validateCanonicalGithubBlobTuple(citation)) {
572
+ throw new Error(
573
+ 'GitHub citation backlink requires one complete canonical provenance tuple.',
574
+ );
575
+ }
576
+ return {
577
+ sourceId: source.id,
578
+ locator: citation.url,
579
+ repository: citation.repository,
580
+ revision: citation.revision,
581
+ path: citation.path,
582
+ lineRange: structuredClone(citation.lineRange),
583
+ url: citation.url,
584
+ };
585
+ }
586
+ if (
587
+ declaresBacklinkTuple(citation) &&
588
+ ['repository', 'revision', 'path', 'url'].some(
589
+ (field) => citation[field] !== undefined,
590
+ )
591
+ ) {
592
+ const supplied = {
593
+ repository: citation.repository,
594
+ revision: citation.revision,
595
+ path: citation.path,
596
+ lineRange: citation.lineRange,
597
+ url: citation.url,
598
+ };
599
+ if (
600
+ !validateCanonicalGithubBlobTuple(supplied) ||
601
+ supplied.repository !== source.repository ||
602
+ supplied.revision !== source.revision ||
603
+ supplied.path !== source.path
604
+ ) {
605
+ throw new Error(
606
+ 'GitHub citation backlink does not match its canonical source tuple.',
607
+ );
608
+ }
609
+ }
610
+ const range = citation.lineRange ?? source.lineRange;
611
+ const tuple = {
612
+ repository: source.repository,
613
+ revision: source.revision,
614
+ path: source.path,
615
+ lineRange: range,
616
+ };
617
+ const url = canonicalGithubBlobBacklink(tuple);
618
+ return {
619
+ sourceId: source.id,
620
+ locator: url,
621
+ repository: source.repository,
622
+ revision: source.revision,
623
+ path: source.path,
624
+ lineRange: structuredClone(range),
625
+ url,
626
+ };
627
+ }
628
+
629
+ function declaresBacklinkTuple(value) {
630
+ return (
631
+ value &&
632
+ typeof value === 'object' &&
633
+ ['repository', 'path', 'lineRange', 'url'].some(
634
+ (field) => value[field] !== undefined,
635
+ )
636
+ );
637
+ }
638
+
503
639
  function validSections(sections) {
504
640
  return (
505
641
  sections === undefined ||