role-os 2.3.1 → 2.6.0

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.
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Evidence Persistence Bridge — Optional connection to dogfood-labs.
2
+ * Evidence Persistence Bridge — Optional connection to dogfood-lab/testing-os.
3
3
  *
4
4
  * Converts swarm wave results into dogfood submission format and audit DB
5
5
  * payloads. The core swarm mission works without this — it's activated by
6
6
  * the --persist-evidence flag on `roleos swarm`.
7
7
  *
8
- * This mirrors the logic from dogfood-labs/tools/swarm/persist-results.js
9
- * but produces the payloads without requiring dogfood-labs to be present.
8
+ * This mirrors the logic from dogfood-lab/testing-os/packages/dogfood-swarm/persist-results.js
9
+ * but produces the payloads without requiring testing-os to be present.
10
10
  */
11
11
 
12
12
  // ── Surface mapping ─────────────────────────────────────────────────────────
@@ -104,7 +104,7 @@ export function computeOverallVerdict(scenarioResults) {
104
104
  // ── Dogfood submission payload ──────────────────────────────────────────────
105
105
 
106
106
  /**
107
- * Build a dogfood-labs-compatible submission payload.
107
+ * Build a testing-os-compatible submission payload.
108
108
  * @param {object} manifest - Swarm manifest
109
109
  * @param {object[]} waveReports - All wave reports from the run
110
110
  * @param {object} meta - { commitSha, branch, startedAt, completedAt }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * `roleos verify-citations <dispatch.md|.json>` — run the citation gate and report.
3
+ *
4
+ * Exit codes: 0 accept · 20 blocking (a cited paper did not resolve — likely fabricated) · 30
5
+ * escalate (verifier unreachable / low-confidence — a closed gate; NEVER accept) · 10 revise ·
6
+ * 2 no resolvable citations found. Non-zero = needs attention, so a mission step, CI job, or
7
+ * operator can branch on it.
8
+ */
9
+
10
+ import { writeFileSync } from "node:fs";
11
+ import { resolve, dirname, basename } from "node:path";
12
+ import { runCitationGate } from "./verify-citations.mjs";
13
+
14
+ export async function verifyCitationsCommand(args) {
15
+ const { flags, positional } = parseArgs(args);
16
+ const dispatch = positional[0];
17
+
18
+ if (!dispatch) {
19
+ const err = new Error(
20
+ "Usage: roleos verify-citations <dispatch.md|.json> [--provider ollama] [--intent <text>] [--local-panel] [--json] [--receipt <path>]",
21
+ );
22
+ err.exitCode = 1;
23
+ err.hint =
24
+ "Provide a research dispatch — markdown with a Research-grounding section, or a citations JSON array.";
25
+ throw err;
26
+ }
27
+
28
+ const result = runCitationGate(dispatch, {
29
+ provider: flags.provider || "ollama",
30
+ ...(typeof flags.intent === "string" ? { intent: flags.intent } : {}),
31
+ // --local-panel: add the family-different offload entailment panel as a second seat (local,
32
+ // zero-cost). Re-checks prism's `supported` citations; monotone-tightening (escalates on
33
+ // disagreement, never loosens). Needs llama-swap up + offload.py on the rig.
34
+ localPanel: flags["local-panel"] === true,
35
+ ...(typeof flags["offload-script"] === "string" ? { offloadScript: flags["offload-script"] } : {}),
36
+ ...(typeof flags["llamaswap-base"] === "string" ? { llamaswapBase: flags["llamaswap-base"] } : {}),
37
+ });
38
+
39
+ // Persist the chained receipt (audit trail) unless --no-receipt.
40
+ if (result.receipt && flags["no-receipt"] !== true) {
41
+ const out =
42
+ typeof flags.receipt === "string"
43
+ ? resolve(flags.receipt)
44
+ : resolve(dirname(dispatch), `${basename(dispatch).replace(/\.[^.]+$/, "")}.citation-receipt.json`);
45
+ try {
46
+ writeFileSync(out, JSON.stringify(result.receipt, null, 2));
47
+ result.receipt_path = out;
48
+ } catch (err) {
49
+ console.error(`warning: could not write the citation receipt to ${out}: ${err.message}`);
50
+ }
51
+ }
52
+
53
+ if (flags.json === true) {
54
+ console.log(JSON.stringify(result, null, 2));
55
+ } else {
56
+ printReport(dispatch, result);
57
+ }
58
+
59
+ process.exit(exitCodeFor(result));
60
+ }
61
+
62
+ /**
63
+ * Map a gate result to the shell exit code (the gate's machine contract). Blocking is checked
64
+ * FIRST so a (contradictory) accept can never shadow the hard halt.
65
+ * 20 = blocking (a fabricated citation) · 0 = accept · 2 = no citations ·
66
+ * 30 = escalate (verifier unreachable / low-confidence — a closed gate) · 10 = revise.
67
+ */
68
+ export function exitCodeFor(result) {
69
+ if (result.blocking) return 20;
70
+ if (result.pass) return 0;
71
+ if (result.reason === "no_citations") return 2;
72
+ if (result.verdict === "escalate") return 30;
73
+ return 10;
74
+ }
75
+
76
+ function parseArgs(args) {
77
+ const flags = {};
78
+ const positional = [];
79
+ for (let i = 0; i < args.length; i++) {
80
+ const a = args[i];
81
+ if (a.startsWith("--")) {
82
+ const name = a.slice(2);
83
+ const next = args[i + 1];
84
+ if (next !== undefined && !next.startsWith("--")) {
85
+ flags[name] = next;
86
+ i += 1;
87
+ } else {
88
+ flags[name] = true;
89
+ }
90
+ } else {
91
+ positional.push(a);
92
+ }
93
+ }
94
+ return { flags, positional };
95
+ }
96
+
97
+ function printReport(dispatch, r) {
98
+ const tag = r.pass ? "ACCEPT" : r.blocking ? "REFUSE (blocking)" : "NEEDS REVIEW";
99
+ console.log(`\nroleos verify-citations — ${tag}\n`);
100
+ console.log(`Dispatch: ${dispatch}`);
101
+ console.log(`Verdict: ${r.verdict} (${r.citations.length} citation(s) checked, ${r.duration}ms)`);
102
+ if (r.reason) console.log(`Reason: ${r.reason} — ${r.detail || ""}`);
103
+
104
+ for (const c of r.citations) {
105
+ if (c.verdict === "accept") continue;
106
+ const mark = c.existence === "fabricated" ? "DROP" : (c.action || c.verdict);
107
+ console.log(` - [${mark}] ${c.identifier || c.id} — ${c.detail || c.finding_match || c.existence}`);
108
+ if (c.span) console.log(` source span: ${c.span}`);
109
+ }
110
+
111
+ if (r.unparsed && r.unparsed.length) {
112
+ console.log(
113
+ `\n ${r.unparsed.length} item(s) looked like citations but had no resolvable arXiv/DOI id (verify manually):`,
114
+ );
115
+ for (const u of r.unparsed.slice(0, 10)) console.log(` ? ${u.slice(0, 120)}`);
116
+ }
117
+
118
+ if (r.local_panel) {
119
+ const p = r.local_panel;
120
+ const seats = p.seats && p.seats.length ? p.seats.join(", ") : "(none reached)";
121
+ console.log(`\nLocal panel: ${p.checked} citation(s) re-checked by [${seats}]${p.reachable ? "" : " — UNREACHABLE"}`);
122
+ for (const d of p.disagreements || []) {
123
+ console.log(` - [DISAGREE] ${d.identifier || d.id} — prism: ${d.prism}, panel: ${d.panel}`);
124
+ }
125
+ }
126
+
127
+ if (r.receipt_path) console.log(`\nReceipt: ${r.receipt_path}`);
128
+
129
+ if (r.blocking) {
130
+ console.log(
131
+ `\nBLOCKING: a cited paper did not resolve in arXiv/Crossref (likely fabricated). Reject the dispatch.`,
132
+ );
133
+ } else if (r.advisory) {
134
+ console.log(
135
+ `\nADVISORY: citations resolved but need revision / human review — accept-with-notes or escalate per the items above.`,
136
+ );
137
+ }
138
+ }
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Citation-Verification Gate — verifies a research dispatch's citations via the external
3
+ * `prism verify` CLI (a family-different, reasoning-stripped verifier) and gates the dispatch
4
+ * on the verdict. role-os is the GENERATOR; prism is the sound external CRITIC (LLM-Modulo,
5
+ * Kambhampati 2024 arXiv:2402.01817) — role-os never grades its own homework.
6
+ *
7
+ * Three tiers keyed to the FAILURE SOURCE (study-swarm wf_20651368-297):
8
+ * - existence `fabricated` (deterministic arXiv/Crossref miss) -> BLOCKING hard halt
9
+ * - metadata / numeric / groundedness `contradicted` -> advisory: revise
10
+ * - `unresolvable` / `not_addressed` / verifier-low-confidence -> advisory: escalate (human)
11
+ * - all `accept` -> pass
12
+ * An unreachable verifier ESCALATES, never default-accepts ("an unreachable gate is a closed
13
+ * gate"). See design/citation-verification-runner.md. Peer to src/swarm/build-gate.mjs.
14
+ */
15
+
16
+ import { execFileSync } from "node:child_process";
17
+ import { mkdtempSync, writeFileSync, rmSync, readFileSync, existsSync } from "node:fs";
18
+ import { join, extname } from "node:path";
19
+ import { tmpdir } from "node:os";
20
+ import { createHash } from "node:crypto";
21
+ import { runOffloadPanel, applyLocalPanel, buildEvidence } from "./citation-panel.mjs";
22
+
23
+ // ── Identifier patterns (copy-only — the extractor never invents an identifier) ──────────────
24
+ // Matches `arXiv:2402.01817`, `arXiv 2402.01817`, AND URL forms `arxiv.org/abs/2402.01817`,
25
+ // `.../pdf/...`, versioned, and old-style `hep-th/9901001` (the most common real citation format).
26
+ const ARXIV =
27
+ /arxiv(?:\.org)?[:\s/]+(?:(?:abs|pdf)\/)?(\d{4}\.\d{4,5}(?:v\d+)?|[a-z-]+(?:\.[A-Z]{2})?\/\d{7}(?:v\d+)?)/i;
28
+ const DOI = /\b(10\.\d{4,9}\/[^\s)\]}"'<>]+)/;
29
+
30
+ /**
31
+ * @typedef {{ id: string|null, identifier: string, claim: string, authors: string, year: string }} Citation
32
+ */
33
+
34
+ // ── Extraction ───────────────────────────────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * Extract citation records from a research dispatch (our Step-3 template prose).
38
+ * Deterministic and COPY-ONLY: it lifts the identifier + claim + authors verbatim and NEVER
39
+ * completes a missing field from memory (Khraisha 2024 — LLM-generated citations are fabricated
40
+ * at extreme rates). Only arXiv / DOI identifiers (what prism can resolve) become citations;
41
+ * items that look like a citation but carry no resolvable id are returned in `unparsed` so misses
42
+ * are visible, never silently dropped.
43
+ *
44
+ * @param {string} markdown
45
+ * @returns {{ citations: Citation[], unparsed: string[] }}
46
+ */
47
+ export function extractCitations(markdown) {
48
+ const citations = [];
49
+ const unparsed = [];
50
+ let n = 0;
51
+ for (const raw of splitItems(String(markdown))) {
52
+ const identifier = matchIdentifier(raw);
53
+ if (!identifier) {
54
+ if (looksLikeCitation(raw)) unparsed.push(oneLine(raw));
55
+ continue;
56
+ }
57
+ n += 1;
58
+ citations.push({
59
+ id: `c${n}`,
60
+ identifier,
61
+ claim: extractClaim(raw),
62
+ authors: extractAuthors(raw),
63
+ year: extractYear(raw),
64
+ });
65
+ // Surface a multi-citation item (only the first identifier is verified) so the miss is visible.
66
+ if (countIdentifiers(raw) > 1) {
67
+ unparsed.push(`(item cites multiple sources — only the first was verified) ${oneLine(raw).slice(0, 120)}`);
68
+ }
69
+ }
70
+ return { citations, unparsed };
71
+ }
72
+
73
+ /** Count distinct arXiv/DOI identifiers in an item — flags multi-cite items that drop extras. */
74
+ function countIdentifiers(text) {
75
+ const ax = text.match(new RegExp(ARXIV.source, "ig")) || [];
76
+ const di = text.match(new RegExp(DOI.source, "ig")) || [];
77
+ return ax.length + di.length;
78
+ }
79
+
80
+ /** Match the first resolvable identifier (arXiv first, then DOI). Returns null if none. */
81
+ function matchIdentifier(text) {
82
+ const a = text.match(ARXIV);
83
+ if (a) return `arXiv:${a[1]}`;
84
+ const d = text.match(DOI);
85
+ if (d) return d[1].replace(/[.,;]+$/, "");
86
+ return null;
87
+ }
88
+
89
+ /** Group lines into logical items: break at list markers and blank lines. */
90
+ function splitItems(markdown) {
91
+ const lines = markdown.split(/\r?\n/);
92
+ const isMarker = (l) => /^\s*(?:\d+[.)]|[-*+])\s+/.test(l);
93
+ const items = [];
94
+ let cur = [];
95
+ const flush = () => {
96
+ if (cur.length) items.push(cur.join(" ").trim());
97
+ cur = [];
98
+ };
99
+ for (const line of lines) {
100
+ if (line.trim() === "") flush();
101
+ else if (isMarker(line)) {
102
+ flush();
103
+ cur.push(line);
104
+ } else cur.push(line);
105
+ }
106
+ flush();
107
+ return items.filter(Boolean);
108
+ }
109
+
110
+ function stripMarkers(raw) {
111
+ return raw
112
+ .replace(/^\s*(?:\d+[.)]|[-*+])\s+/, "")
113
+ .replace(/[*`_]/g, "")
114
+ .trim();
115
+ }
116
+
117
+ /** Collapse an item to a single display line (drops the list marker, keeps the text). */
118
+ function oneLine(raw) {
119
+ return raw.replace(/^\s*(?:\d+[.)]|[-*+])\s+/, "").replace(/\s+/g, " ").trim();
120
+ }
121
+
122
+ /** Claim = the bold finding if present, else the first sentence (capped). Always non-empty. */
123
+ function extractClaim(raw) {
124
+ const bold = raw.match(/\*\*(.+?)\*\*/);
125
+ if (bold && bold[1].trim()) return bold[1].trim().slice(0, 500);
126
+ const cleaned = stripMarkers(raw);
127
+ const sentence = cleaned.match(/^(.{8,260}?[.!?])(?:\s|$)/);
128
+ const claim = (sentence ? sentence[1] : cleaned).slice(0, 500).trim();
129
+ return claim || cleaned.slice(0, 120).trim() || "(no claim text)";
130
+ }
131
+
132
+ function extractYear(raw) {
133
+ const m = raw.match(/\b(?:19|20)\d{2}\b/);
134
+ return m ? m[0] : "";
135
+ }
136
+
137
+ /** Best-effort authors (metadata only — prism resolves by identifier, not by this). */
138
+ function extractAuthors(raw) {
139
+ const beforeYear = stripMarkers(raw)
140
+ .replace(/\*\*(.+?)\*\*/g, "")
141
+ .split(/\b(?:19|20)\d{2}\b/)[0] || "";
142
+ const m = beforeYear.match(/([A-Z][\w.'-]+(?:[\s,]+(?:et al\.?|&|and|[A-Z][\w.'-]+)){0,6})\s*$/);
143
+ return (m ? m[1] : "").replace(/[,\s]+$/, "").trim().slice(0, 120);
144
+ }
145
+
146
+ function looksLikeCitation(raw) {
147
+ return (
148
+ /\b(?:19|20)\d{2}\b/.test(raw) &&
149
+ /(et al\.?|\bdoi\b|\brfc\b|arxiv|https?:\/\/|[A-Z][a-z]+\s+(?:&|and|et al))/i.test(raw)
150
+ );
151
+ }
152
+
153
+ // ── Gate (maps prism's verdict to the three tiers) ─────────────────────────────────────────────
154
+
155
+ /**
156
+ * @typedef {object} GateResult
157
+ * @property {string} verdict accept | revise | refuse | escalate
158
+ * @property {boolean} pass true iff verdict === "accept"
159
+ * @property {boolean} blocking true iff any citation failed the deterministic existence floor
160
+ * @property {boolean} advisory needs attention but not a fabrication (revise/escalate)
161
+ * @property {object[]} citations per-citation result
162
+ * @property {string} [reason]
163
+ * @property {string} [detail]
164
+ */
165
+
166
+ /**
167
+ * Map a parsed `prism verify` response to the three-tier gate. role-os enforces the existence
168
+ * floor itself: blocking dominates accept, and an accept with no adjudicated results is not trusted.
169
+ * @param {object} prismResponse parsed VerifyResponse JSON, or `{ error: {...} }`
170
+ * @returns {GateResult}
171
+ */
172
+ export function gateCitations(prismResponse) {
173
+ if (!prismResponse || typeof prismResponse !== "object") {
174
+ return blockedResult("escalate", "malformed_verifier_output", "verifier returned no object");
175
+ }
176
+ if (prismResponse.error) {
177
+ // prism refused to verify at all (e.g. INVALID_ARTIFACT, VERIFIER_UNAVAILABLE) -> escalate.
178
+ return {
179
+ verdict: "escalate",
180
+ pass: false,
181
+ blocking: false,
182
+ advisory: true,
183
+ reason: prismResponse.error.reason || "verifier_error",
184
+ detail: prismResponse.error.detail || "",
185
+ citations: [],
186
+ };
187
+ }
188
+ const rawVerdict = prismResponse.verdict || "escalate";
189
+ const citations = (prismResponse.citation_results || []).map((cr) => ({
190
+ id: cr.citation_id ?? null,
191
+ identifier: cr.identifier ?? null,
192
+ existence: cr.existence,
193
+ finding_match: cr.finding_match,
194
+ verdict: cr.verdict,
195
+ action: cr.action,
196
+ detail: cr.detail,
197
+ span: cr.supporting_span ?? null,
198
+ source_title: cr.source_title ?? null,
199
+ source_abstract: cr.source_abstract ?? null,
200
+ }));
201
+ // role-os enforces the deterministic floor ITSELF (it does not delegate it to prism's top-level
202
+ // aggregation): any fabricated-existence citation BLOCKS and dominates a top-level "accept", so a
203
+ // contradictory or drifted prism response can never shadow the hard halt.
204
+ const blocking = citations.some((c) => c.existence === "fabricated");
205
+ if (blocking) {
206
+ return { verdict: "refuse", pass: false, blocking: true, advisory: false, citations };
207
+ }
208
+ // A clean accept must actually adjudicate citations — an "accept" carrying ZERO results is not
209
+ // trusted (prism stdout is an untrusted boundary input). (Exact submitted-vs-adjudicated count
210
+ // cross-check is a v2 hardening.)
211
+ if (rawVerdict === "accept" && citations.length === 0) {
212
+ return {
213
+ verdict: "escalate",
214
+ pass: false,
215
+ blocking: false,
216
+ advisory: true,
217
+ reason: "incomplete_adjudication",
218
+ detail: "prism returned no adjudicated citations for an accept verdict",
219
+ citations,
220
+ };
221
+ }
222
+ const pass = rawVerdict === "accept";
223
+ return { verdict: rawVerdict, pass, blocking: false, advisory: !pass, citations };
224
+ }
225
+
226
+ function blockedResult(verdict, reason, detail) {
227
+ return { verdict, pass: false, blocking: false, advisory: true, reason, detail, citations: [] };
228
+ }
229
+
230
+ // ── Runner (orchestrates extract -> shell prism -> gate -> receipt) ────────────────────────────
231
+
232
+ /**
233
+ * Run the citation gate over a dispatch (file path .md/.json, or a markdown string).
234
+ *
235
+ * @param {string} input
236
+ * @param {object} [options]
237
+ * @param {string} [options.prismCmd] default: env PRISM_CMD || "prism"
238
+ * @param {string} [options.provider] default: "ollama" (local, zero-cost)
239
+ * @param {string} [options.callerFamily] default: "anthropic" (excluded from the verifier)
240
+ * @param {string} [options.intent]
241
+ * @param {number} [options.timeout] per-call ms (default 120000)
242
+ * @param {number} [options.retries] transient retries (default 1)
243
+ * @param {Function} [options.exec] injectable (cmd, args, {timeout, cwd}) -> {status, stdout, stderr}
244
+ * @param {string} [options.cwd]
245
+ * @returns {GateResult & { unparsed: string[], receipt?: object, duration: number }}
246
+ */
247
+ export function runCitationGate(input, options = {}) {
248
+ const {
249
+ prismCmd = process.env.PRISM_CMD || "prism",
250
+ provider = "ollama",
251
+ callerFamily = "anthropic",
252
+ intent = "verify each citation exists and the finding matches the source",
253
+ timeout = 120_000,
254
+ retries = 1,
255
+ exec = defaultExec,
256
+ cwd = process.cwd(),
257
+ // Local-panel seat (opt-in): a family-different entailment panel (offload, on local models)
258
+ // re-checks prism's `supported` citations. Monotone-tightening; off by default.
259
+ localPanel = false,
260
+ offloadExec,
261
+ offloadPython,
262
+ offloadScript,
263
+ llamaswapBase,
264
+ } = options;
265
+
266
+ const start = Date.now();
267
+ const { citations, unparsed } = loadCitations(input);
268
+
269
+ if (citations.length === 0) {
270
+ return {
271
+ ...blockedResult("escalate", "no_citations", "no resolvable arXiv/DOI citations were extracted"),
272
+ unparsed,
273
+ duration: Date.now() - start,
274
+ };
275
+ }
276
+
277
+ const artifact = citations.map((c) => ({
278
+ id: c.id,
279
+ identifier: c.identifier,
280
+ claim: c.claim,
281
+ authors: c.authors || "",
282
+ year: c.year || "",
283
+ }));
284
+
285
+ const dir = mkdtempSync(join(tmpdir(), "roleos-cite-"));
286
+ const file = join(dir, "citations.json");
287
+ let result;
288
+ try {
289
+ writeFileSync(file, JSON.stringify(artifact));
290
+ result = shellPrism({ prismCmd, file, intent, callerFamily, provider, timeout, retries, exec, cwd });
291
+ } finally {
292
+ rmSync(dir, { recursive: true, force: true });
293
+ }
294
+
295
+ if (!result.ok) {
296
+ // An unreachable gate is a closed gate -> escalate, NEVER default-accept.
297
+ return {
298
+ ...blockedResult("escalate", "verifier_unreachable", result.detail),
299
+ unparsed,
300
+ duration: Date.now() - start,
301
+ };
302
+ }
303
+
304
+ let gate = gateCitations(result.response);
305
+ // A clean accept also requires that extraction left NO citation-like item unverified — a non-empty
306
+ // `unparsed` (e.g. a citation format the extractor missed) must not pass as fully verified.
307
+ if (gate.pass && unparsed.length > 0) {
308
+ gate = {
309
+ ...gate,
310
+ verdict: "escalate",
311
+ pass: false,
312
+ advisory: true,
313
+ reason: "unparsed_citations",
314
+ detail: `${unparsed.length} citation-like item(s) could not be parsed/resolved`,
315
+ };
316
+ }
317
+ // Local-panel seat: re-check prism's `supported` citations with a family-different entailment
318
+ // panel on local models. Runs only when requested AND the gate is still passing — it can only
319
+ // tighten, so there is nothing to challenge on an already-blocking/advisory gate.
320
+ let panel = null;
321
+ if (localPanel && gate.pass) {
322
+ const supported = buildPanelInput(citations, gate.citations);
323
+ if (supported.length > 0) {
324
+ panel = runOffloadPanel(supported, {
325
+ ...(offloadExec ? { exec: offloadExec } : {}),
326
+ ...(offloadPython ? { python: offloadPython } : {}),
327
+ ...(offloadScript ? { script: offloadScript } : {}),
328
+ ...(llamaswapBase ? { base: llamaswapBase } : {}),
329
+ cwd,
330
+ });
331
+ gate = applyLocalPanel(gate, panel);
332
+ }
333
+ }
334
+
335
+ const receipt = buildReceipt({ input, artifact, response: result.response, gate, panel });
336
+ return { ...gate, unparsed, receipt, duration: Date.now() - start };
337
+ }
338
+
339
+ /**
340
+ * Build the local-panel input: prism's `supported` citations only (the panel can only challenge an
341
+ * accept), joined to their claim (from the artifact) + the evidence prism retrieved (title + span).
342
+ */
343
+ function buildPanelInput(artifactCitations, gateCitations) {
344
+ const claimById = new Map();
345
+ const claimByIdent = new Map();
346
+ for (const c of artifactCitations) {
347
+ if (c.id) claimById.set(c.id, c.claim);
348
+ if (c.identifier) claimByIdent.set(c.identifier, c.claim);
349
+ }
350
+ const out = [];
351
+ for (const gc of gateCitations) {
352
+ if (gc.finding_match !== "supported") continue;
353
+ const claim = claimById.get(gc.id) ?? claimByIdent.get(gc.identifier) ?? "";
354
+ if (!claim) continue;
355
+ out.push({ id: gc.id, identifier: gc.identifier, claim, evidence: buildEvidence(gc) });
356
+ }
357
+ return out;
358
+ }
359
+
360
+ function loadCitations(input) {
361
+ if (
362
+ typeof input === "string" &&
363
+ (input.endsWith(".md") || input.endsWith(".json")) &&
364
+ existsSync(input)
365
+ ) {
366
+ const content = readFileSync(input, "utf8");
367
+ if (extname(input) === ".json") {
368
+ let arr;
369
+ try {
370
+ arr = JSON.parse(content);
371
+ } catch {
372
+ return { citations: [], unparsed: ["(.json file was not valid JSON)"] };
373
+ }
374
+ const citations = [];
375
+ const unparsed = [];
376
+ for (const item of Array.isArray(arr) ? arr : []) {
377
+ const norm = normalizeJsonCitation(item);
378
+ if (norm) citations.push(norm);
379
+ else if (item && typeof item === "object" && (item.claim || item.finding)) {
380
+ unparsed.push(oneLine(JSON.stringify(item)).slice(0, 160)); // a claim with no resolvable id
381
+ }
382
+ }
383
+ return { citations, unparsed };
384
+ }
385
+ return extractCitations(content);
386
+ }
387
+ return extractCitations(String(input));
388
+ }
389
+
390
+ function normalizeJsonCitation(c) {
391
+ if (!c || typeof c !== "object") return null;
392
+ const claim = (c.claim || c.finding || "").toString().trim();
393
+ if (!claim) return null;
394
+ const idText = [c.identifier, c.url, c.doi, c.arxiv].filter(Boolean).join(" ");
395
+ const identifier = matchIdentifier(idText) || matchIdentifier(claim);
396
+ if (!identifier) return null;
397
+ return {
398
+ id: (c.id || null),
399
+ identifier,
400
+ claim: claim.slice(0, 500),
401
+ authors: (c.authors || "").toString(),
402
+ year: (c.year || "").toString(),
403
+ };
404
+ }
405
+
406
+ function shellPrism({ prismCmd, file, intent, callerFamily, provider, timeout, retries, exec, cwd }) {
407
+ const args = [
408
+ "verify", "-a", `@${file}`, "--type", "citations",
409
+ "-i", intent, "--caller-family", callerFamily, "--provider", provider,
410
+ ];
411
+ let detail = "";
412
+ for (let attempt = 0; attempt <= retries; attempt++) {
413
+ let res;
414
+ try {
415
+ res = exec(prismCmd, args, { timeout, cwd });
416
+ } catch (err) {
417
+ detail = `failed to run ${prismCmd}: ${err.code || err.message}`;
418
+ if (err.code === "ENOENT") break; // missing binary -> escalate, do not retry
419
+ continue;
420
+ }
421
+ const parsed = tryParseJson((res.stdout || "").toString());
422
+ if (parsed) return { ok: true, response: parsed, exitCode: res.status ?? 0 };
423
+ detail = `prism produced no parseable JSON (exit ${res.status}): ${(res.stderr || res.stdout || "").toString().slice(0, 300)}`;
424
+ }
425
+ return { ok: false, detail };
426
+ }
427
+
428
+ /** Default exec — execFileSync, capturing stdout even when prism exits non-zero (refuse/error). */
429
+ function defaultExec(cmd, args, { timeout, cwd }) {
430
+ try {
431
+ // No shell: execFileSync passes args verbatim (the intent string contains spaces).
432
+ // `cmd` must be an executable, not a shell builtin — on Windows set PRISM_CMD to the full
433
+ // path of prism.exe (a real PE shim, not a .cmd), or use a POSIX bare name on PATH.
434
+ const stdout = execFileSync(cmd, args, {
435
+ cwd,
436
+ timeout,
437
+ encoding: "utf8",
438
+ stdio: ["ignore", "pipe", "pipe"],
439
+ maxBuffer: 16 * 1024 * 1024,
440
+ });
441
+ return { status: 0, stdout, stderr: "" };
442
+ } catch (err) {
443
+ if (err.code === "ENOENT") throw err; // missing binary -> shellPrism escalates
444
+ return {
445
+ status: err.status ?? 1,
446
+ stdout: (err.stdout || "").toString(),
447
+ stderr: (err.stderr || "").toString(),
448
+ };
449
+ }
450
+ }
451
+
452
+ function tryParseJson(text) {
453
+ const s = (text || "").trim();
454
+ if (!s) return null;
455
+ try {
456
+ return JSON.parse(s);
457
+ } catch {
458
+ const start = s.indexOf("{");
459
+ const end = s.lastIndexOf("}");
460
+ if (start !== -1 && end > start) {
461
+ try {
462
+ return JSON.parse(s.slice(start, end + 1));
463
+ } catch {
464
+ return null;
465
+ }
466
+ }
467
+ return null;
468
+ }
469
+ }
470
+
471
+ function buildReceipt({ input, artifact, response, gate, panel = null }) {
472
+ const citationsHash = sha256(JSON.stringify(artifact));
473
+ const prismReceipt = response.receipt || {};
474
+ const pins = Array.isArray(prismReceipt.retrieval_pins) ? prismReceipt.retrieval_pins : [];
475
+ // The local-panel seat folds into the chain via gate.verdict (a disagreement downgrades it to
476
+ // escalate) AND via its own digest, so neither prism's verdict nor the panel's can be altered
477
+ // without breaking the chain.
478
+ const panelDigest = panel
479
+ ? sha256(JSON.stringify({ seats: panel.seats, perCitation: panel.perCitation }))
480
+ : "";
481
+ const chain = sha256([citationsHash, prismReceipt.signature || "", gate.verdict, panelDigest].join("|"));
482
+ return {
483
+ schema: "roleos-citation-receipt/v1",
484
+ kind: "citation-verification",
485
+ tool: "roleos verify-citations",
486
+ input: typeof input === "string" && input.length < 256 ? input : "(inline)",
487
+ verdict: gate.verdict,
488
+ blocking: gate.blocking,
489
+ advisory: gate.advisory,
490
+ citations_sha256: citationsHash,
491
+ // Chain to prism's inner HMAC receipt: verifying prism's signature is what lets role-os
492
+ // trust a verdict it did not itself compute (separate keys; Haber-Stornetta 1991).
493
+ prism_receipt: {
494
+ id: prismReceipt.id || null,
495
+ signature: prismReceipt.signature || null,
496
+ verdict: response.verdict || null,
497
+ },
498
+ // Per-citation retrieval pins enable drift detection on re-run (compare source_sha256).
499
+ retrieval_pins: pins.map((p) => ({
500
+ id: p.id,
501
+ identifier: p.identifier,
502
+ query: p.query,
503
+ source_sha256: p.source_sha256,
504
+ existence: p.existence,
505
+ })),
506
+ // Local-panel seat (when run): the actual seat models (PIN_PER_STEP), what each citation got,
507
+ // and any disagreement with prism that downgraded the gate.
508
+ local_panel: panel
509
+ ? {
510
+ seats: panel.seats,
511
+ reachable: panel.reachable,
512
+ checked: panel.checked,
513
+ per_citation: panel.perCitation,
514
+ disagreements: panel.disagreements,
515
+ }
516
+ : null,
517
+ chain_sha256: chain,
518
+ };
519
+ }
520
+
521
+ function sha256(s) {
522
+ return createHash("sha256").update(s).digest("hex");
523
+ }