diff --git a/.claude/skills/xray-capture/SKILL.md b/.claude/skills/xray-capture/SKILL.md index faf6e9e..183ac7a 100644 --- a/.claude/skills/xray-capture/SKILL.md +++ b/.claude/skills/xray-capture/SKILL.md @@ -1,6 +1,6 @@ --- name: xray-capture -description: Capture articles into X-Ray by URL, enumerate an article's outbound-link frontier for corpus expansion, and run a scouting suggest pass. Use when the user asks to capture a URL into X-Ray, capture the links from an article, expand a case corpus, or scout what an article would yield. Requires the claude-in-chrome connector and the X-Ray extension loaded. +description: Capture articles into X-Ray by URL and enumerate an article's outbound-link frontier for corpus expansion, driving the loaded extension through the claude-in-chrome connector. Use when the user asks to capture a URL into X-Ray, capture the links from an article, or expand a case corpus. Requires the claude-in-chrome connector, the X-Ray extension loaded, and the Capture-automation flag ON. --- # X-Ray capture — drive the extension, expand the corpus @@ -12,165 +12,129 @@ never judge, and **nothing model-produced becomes durable X-Ray data through you** — proposals are accepted by the human in the reader, or not at all. +## Connector limits (verified 2026-07-16 — design around these, not against them) + +- The connector **cannot navigate to `chrome-extension://` URLs** (it + forces an `https://` prefix) and **cannot attach the debugger to + extension pages** ("Cannot attach debugger to chrome-extension:// + pages"). Everything that needed extension-page JS — IDB reads, + `chrome.tabs.sendMessage`, `xray:llm:suggest` — is unreachable. +- Synthetic keys do not fire extension command shortcuts, and OS-level + keystrokes into browsers are blocked by the computer-use tier. +- Therefore the ONLY verb you have against the extension is + **navigation of ordinary pages** — which is exactly what the + `#xray:capture` marker (Phase 27 K.4, flag-gated) exists for. + ## Hard rules (the project constitution — these bind every step) 1. **No auto-accept, ever.** Never write entities, claims, links, - facts, findings, or baselines into `chrome.storage.local` or the - extension's IndexedDB. The ONLY writes you may cause are the ones - the extension itself performs in response to its own capture flow - (`xray:capture` → reader auto-archive). If a suggest pass returns - proposals, you REPORT them; the human accepts in the reader. + facts, findings, or baselines anywhere. The ONLY writes you may + cause are the extension's own capture flow (the marker → reader + auto-archive). 2. **Never touch publish paths.** No relay messages, no signing. -3. **The extension's API key stays in its service worker.** Talk to - the LLM only via the extension's own `xray:llm:*` messages (which - gate on the user's flags + key). Never read `xray:llm:key`. -4. **Pace yourself.** Captures run sequentially — one tab, one URL at - a time, waiting for each load. Suggest passes are paid API calls: - state the count and get the user's go-ahead in chat before running - more than one. -5. **Capture only where directed.** The user names the seed URL(s) or +3. **Pace yourself.** Captures run sequentially — one URL at a time, + waiting for each load and verification before the next. +4. **Capture only where directed.** The user names the seed URL(s) or approves the frontier list before you fan out. Skip login-walled or paywalled pages you cannot render; report them as uncapturable rather than working around access controls. -6. **Forensic findings stay out of scope** for suggestion scouting - (maintainer decision 2026-07-16: not until the attribution fixes - are proven on real articles). +5. **Suggest-pass scouting is currently NOT runnable by this skill** + (it needs extension-context messaging the connector cannot reach). + The reader's own Suggest button is the human's path; recommend it + per-article instead of simulating it. ## Preflight (once per session) -1. Confirm the claude-in-chrome tools are available (load via - ToolSearch if deferred). -2. Find the X-Ray extension origin: call `tabs_context`. If no - `chrome-extension://` tab is open, ask the user to open the X-Ray - portal (extension icon → or `chrome://extensions` → X-Ray → - Details → open `src/portal/index.html`), then re-read - `tabs_context` and note the origin `chrome-extension://`. - Keep that portal tab open — it is your command surface. -3. Capability probe: run `javascript_tool` on the portal tab with - `typeof chrome.tabs.query` — expect `"function"`. If the tool - cannot execute JS on extension pages, STOP and tell the user this - skill needs that capability; do not improvise another write path. - -## Capture one URL (K.1) - -1. Open/navigate a NON-portal tab to the article URL. Wait for load - (screenshot or `get_page_text` sanity check — a real article body, - not a consent wall or error page). -2. From the **portal tab**, run `javascript_tool`: - - ```js - (async () => { - const tabs = await chrome.tabs.query({ url: '' }); - if (!tabs.length) return 'no-tab'; - const resp = await chrome.tabs.sendMessage(tabs[0].id, { type: 'xray:capture' }); - return resp && resp.ok ? 'capture-sent' : ('failed: ' + JSON.stringify(resp)); - })() - ``` - - (`url` patterns need a trailing `*` for query params; the message - lands in the content script — `src/content/index.js` — which opens - the reader, and the reader auto-archives on open.) -3. **PDFs:** skip steps 1–2 and instead open a tab at +1. Confirm the claude-in-chrome tools are loaded (ToolSearch if + deferred), then `tabs_context_mcp {createIfEmpty: true}`. +2. Ask the user to flip **Options → Advanced → Capture automation** ON + (the `#xray:capture` marker is default-off; it gates only the + marker). Remind them to turn it back off when the session ends. +3. Probe once: navigate a group tab to a harmless captureable page + with `#xray:capture` appended, wait ~3s, then `javascript_tool`: + `document.documentElement.dataset.xrayCaptured` — + - `"ok"` → capture fired; a reader tab opened (outside your group — + you won't see it; that's expected). + - `"flag-off"` → the flag isn't on; ask again. + - `undefined` → the extension may not be loaded, or the page blocked + the content script; try one reload, then report. + +## Capture one URL + +1. Navigate a group tab to `#xray:capture` (if the URL already + has a fragment, the marker replaces it — note that to the user). +2. Wait for load + ~5s (the marker waits 1.5s after init to let + dynamic pages settle, then opens the reader, which auto-archives). + **SPAs (Medium, some news sites) need ~10s** — a capture that fires + before the body renders archives an empty shell. +3. Verify on the SAME tab (ordinary page — attachable): + `document.documentElement.dataset.xrayCaptured` → expect `"ok"`. + `"error"`/`undefined` → report the URL as failed (paywall? consent + wall? script-blocked?); retry at most once. +4. **A stamp of `"ok"` means the capture FIRED, not that the content + was good.** Check `document.title` in the same probe: a title like + `"archive.is"`, `"Just a moment…"`, `"Medium"`, or a bare hostname + means an interstitial/loading state was live when the capture ran — + wait for the real title and re-capture (see the re-capture note + below). Batch the title into the verification expression: + `({stamp: document.documentElement.dataset.xrayCaptured, title: document.title, len: document.body.innerText.length})` + — `len` under ~2000 on a supposed article is another tell. +5. **Re-capturing:** navigate somewhere else first (`https://example.com`) + and back. Going from `` to `#xray:capture` used to be a + silent no-op (same-document navigation → no re-init); a `hashchange` + listener now covers it, but a full navigation is still the reliable + path, and re-capture is safe — the archive keys by URL and keeps the + prior version. +6. Report title (from the tab) and move to the next URL. +5. **Tab pileup is real:** every capture opens a reader tab the + connector cannot close (they're outside the group). Tell the user + at the end how many reader tabs they'll find and that each shows a + captured article ready for claim extraction. +6. **PDFs:** the reader's `?pdf=` path is an extension-page URL the + connector cannot open. Give the user the exact `chrome-extension:///src/reader/index.html?pdf=` - — the reader's tabless PDF path captures directly. -4. Verify the archive landed — from the portal tab: - - ```js - (async () => { - const db = await new Promise((res, rej) => { - const r = indexedDB.open('xray-archive'); - r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); - }); - const recs = await new Promise((res, rej) => { - const tx = db.transaction('articles').objectStore('articles').getAll(); - tx.onsuccess = () => res(tx.result); tx.onerror = () => rej(tx.error); - }); - db.close(); - const hit = recs.find((r) => r.url && r.url.includes('')); - return hit ? { ok: true, title: hit.article && hit.article.title, - links: (hit.article && hit.article.links || []).length } - : { ok: false, captured: recs.length }; - })() - ``` - - READ-ONLY: this is verification, never a write. If verification - fails, report it (paywall? consent wall? CSP?) and move on — do not - retry more than once. -5. Close the article tab if you opened it; report `title` + link - count to the user. - -## Enumerate the frontier (K.1) - -From the portal tab, read the seed article's outbound links and -subtract what's already captured: - -```js -(async () => { - const db = await new Promise((res, rej) => { - const r = indexedDB.open('xray-archive'); - r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); - }); - const recs = await new Promise((res, rej) => { - const tx = db.transaction('articles').objectStore('articles').getAll(); - tx.onsuccess = () => res(tx.result); tx.onerror = () => rej(tx.error); - }); - db.close(); - const seed = recs.find((r) => r.url && r.url.includes('')); - if (!seed) return 'seed-not-captured'; - const have = new Set(recs.map((r) => r.url)); - const links = ((seed.article && seed.article.links) || []) - .filter((l) => l && l.url && !l.internal) - .filter((l) => !have.has(l.url)); - return { frontier: links.map((l) => ({ url: l.url, text: l.text, count: l.count })), - truncated: !!(seed.article && seed.article.links_truncated) }; -})() -``` - -Present the frontier as a numbered list (url + anchor text + cite -count). **Wait for the user to pick** which to capture (or confirm -"all"), then loop the capture procedure over the picks, one at a -time, reporting progress. Notes: `internal` is same-host-approximate; -`links_truncated` means the capture capped at 100 links — say so. - -## Scouting suggest pass (K.2 — optional, costs API tokens) - -Purpose: tell the researcher what a captured article would yield -BEFORE they invest reader time. It does NOT feed the reader's review -panel — accepting still happens there (and the reader's own Suggest -run is a second paid pass; say so when recommending it). - -1. The pass scopes itself to the USER'S stored suggestion kinds - (`xray:llm:suggest_kinds` — the worker reads it; the request cannot - override it, by design). Read it first (portal tab, - `chrome.storage.local.get`) so you can tell the user what the pass - will cover. If `findings` or `baselines` are enabled there, note - that per rule 6 you will not elaborate those proposals in your - summary — review them in the reader. -2. Get the article text from the archive record - (`record.article.markdown` — fall back to `textContent` if absent). -3. From the portal tab: - - ```js - (async () => new Promise((res) => chrome.runtime.sendMessage({ - type: 'xray:llm:suggest', - request: { articleText: , articleUrl: , articleTitle: } - }, res)))() - ``` - - Gates (llmAssist flag + key + at-least-one-kind) are enforced - worker-side; if the response is `{ok:false}`, relay its error - verbatim (it names the Options toggle to flip). -4. Report a scouting summary in chat: counts per kind + the two or - three most load-bearing claims verbatim (skip findings/baselines - per rule 6). Recommend which articles deserve a reader pass. Do - not store anything. + link to open by hand instead. + +## Enumerate the frontier + +Extension IDB is unreachable, so the frontier comes from a **workspace +backup export** the user hands you: + +1. Ask the user: Options → Advanced → Workspace → download backup + (source bytes not needed), and give you the file path (usually + `~/Downloads/…json`). +2. Parse it in node (it is one JSON object): + `databases['xray-archive'].stores.articles` (explore the exact + nesting — `format` and `exportedAt` sit at the top level) → each + record has `url`, `articleHash`, and `article.links` (shape + `{url, text, count, internal}`, capped at 100 with + `article.links_truncated`). +3. Frontier = every non-`internal` link URL across the seed records, + minus every `url` already in the archive, deduped, with citation + counts and which seeds cite it. Disclose `links_truncated` seeds. +4. Present the frontier as a numbered pick list sorted by citation + count. **Wait for the user to pick** (or confirm "all"), then loop + the capture procedure sequentially over the picks. +5. The export is a snapshot — captures made after it won't be in it. + Re-request an export if you need a fresh frontier; don't guess. ## Failure modes | Symptom | Meaning | Do | |---|---|---| -| `javascript_tool` refuses extension pages | capability missing | Stop; tell the user (preflight step 3) | -| `no-tab` from capture | URL pattern mismatch | Add `*` for query strings; re-query | -| `sendMessage` throws "no receiving end" | content script not injected (chrome:// page, PDF viewer, race) | PDFs → the `?pdf=` reader path; else reload the tab once | -| Verification finds no record | paywall/consent wall/CSP ate the capture | Report as uncapturable; do not bypass | -| Suggest returns `ok:false` | flag off or no key | Relay the error; the user flips it in Options | +| `dataset.xrayCaptured === "flag-off"` | Capture automation off | Ask the user to enable it in Options → Advanced | +| `dataset.xrayCaptured` undefined after reload | content script absent (blocked page, chrome://, PDF viewer) | Report uncapturable; PDFs → hand the user the `?pdf=` reader link | +| `"error"` stamp | the marker branch threw | Read the console (`pattern: "X-Ray"`) — it logs the reason verbatim | +| Stamp `"ok"` but the title is `archive.is` / `Just a moment…` / `Medium` | captured an interstitial, not the article | Wait for the real title, then re-capture via a full navigation | +| Stamp never appears on one specific host | the content script may be throwing before the marker runs | Console first, guess never — this is how the archive.is `<base href>` bug was found (JOURNAL 2026-07-17) | +| Navigate mangles the URL | you passed a `chrome-extension://` URL | Don't — ordinary pages only (see Connector limits) | +| Frontier stale | export predates recent captures | Ask for a fresh export | + +## Reporting back + +Give the user a per-URL table (title + outcome), not just a count. +State plainly: how many reader tabs are now open (one per capture, all +outside the group — you cannot close them), which URLs failed and why, +and which captured content you are UNSURE about. An honest "this one +may have caught a loading page" is worth more than a green checkmark +they later discover was empty. diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 486103c..56468bc 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -19,6 +19,46 @@ or files, and the "so-what" for future readers. --- +## 2026-07-17 — A cosmetic replaceState aborted the capture it preceded (27 K.4) + +Tags: `bug`, `external`. + +Found in the first live agent-driven capture run, on `archive.is` +(several COVID-corpus frontier links are archive.is snapshots of +paywalled reporting). The `#xray:capture` marker fired once, then +never again on that host: + +``` +SecurityError: Failed to execute 'replaceState' on 'History': +A history state object with URL 'https://d8w6fmknl1l6hl.archive.is/7gYpy' +cannot be created in a document with origin 'https://archive.is' +``` + +archive.is serves snapshot content under a **randomized subdomain** and +sets a `<base href>` to it. `history.replaceState(null, '', pathname + +search)` resolves its RELATIVE argument against the document's **base +URI**, not its origin — so the rewrite targeted another host and threw. +The marker-strip sat before `UI.openReader()` inside the same `try`, so +a cosmetic address-bar tidy killed the capture. It "worked" on the very +first hit only because the base tag hadn't been applied yet — which is +also why that capture archived the interstitial rather than the article. + +Two fixes, both worth keeping in mind for any content-script URL work: +build the replacement **absolute from `location.origin`** (never +relative — `<base href>` is another site's to control), and give +best-effort cosmetics **their own try/catch** so they can never abort +the load-bearing work behind them. Also fixed alongside: a hash-only +navigation (`<url>` → `<url>#xray:capture`) is a same-document +navigation, so `init()` never re-runs and the marker silently no-oped; +a `hashchange` listener now covers it. + +No unit test would have caught either — both need a real page with a +cross-origin base tag. The lesson generalizes past this slice: a +try-block that wraps a nice-to-have around a must-have inherits the +nice-to-have's failure modes. + +--- + ## 2026-07-16 — The scope question never reached the LLM; proposals were never asked for (27 S.1/S.2) Tags: `bug`, `design`. diff --git a/src/content/index.js b/src/content/index.js index deedc71..c6faa85 100644 --- a/src/content/index.js +++ b/src/content/index.js @@ -13,6 +13,7 @@ import { Signer } from '../shared/signer.js'; import { NIP07Client } from './nip07-client.js'; import { UI } from './ui.js'; import { installBufferListener, configureInterceptor } from '../shared/api-hook-buffer.js'; +import { loadFlags, isEnabled } from '../shared/metadata/feature-flags.js'; async function init() { // Initialize storage (migrates from any legacy GM storage if present). @@ -130,6 +131,78 @@ async function init() { } Utils.log('Initialization complete'); + + // Phase 27 K.4 — the automation capture marker. A driving agent + // (the xray-capture skill) can neither reach extension pages nor + // fire the command shortcut through the browser connector, so + // NAVIGATION is its only verb: opening `<url>#xray:capture` + // triggers the same capture the toolbar button would. Flag-gated, + // default OFF (Options → Advanced → Capture automation). The + // marker is navigation plumbing, not page identity — it is + // stripped before capture (the URL normalizer would drop it from + // stored URLs regardless), and the outcome is stamped on the DOM + // (`data-xray-captured`) so the driver can verify from ordinary + // page context. Captures the page, nothing more. + await maybeAutomationCapture(); + // A hash-only navigation (same page, marker appended) does NOT + // re-run init — the driver's capture would silently no-op. Listen + // so the marker works from an already-loaded page too. + window.addEventListener('hashchange', () => { + maybeAutomationCapture().catch((err) => + Utils.error('Automation capture marker failed:', err)); + }); +} + +/** + * The `#xray:capture` automation marker (Phase 27 K.4). Flag-gated, + * default OFF. Triggers the same capture the toolbar button does, and + * stamps the outcome on the DOM so a driving agent can verify from + * ordinary page context. + */ +async function maybeAutomationCapture() { + if (window.location.hash !== '#xray:capture') return; + try { + await loadFlags(); + if (!isEnabled('captureAutomation')) { + document.documentElement.dataset.xrayCaptured = 'flag-off'; + Utils.log('#xray:capture marker present but captureAutomation is off'); + return; + } + stripCaptureMarker(); + // Give late-loading pages the settle time a human clicking the + // toolbar would naturally allow. + setTimeout(() => { + try { + UI.openReader(); + document.documentElement.dataset.xrayCaptured = 'ok'; + } catch (err) { + document.documentElement.dataset.xrayCaptured = 'error'; + Utils.error('Automation capture failed:', err); + } + }, 1500); + } catch (err) { + document.documentElement.dataset.xrayCaptured = 'error'; + Utils.error('Automation capture marker failed:', err); + } +} + +/** + * Drop the marker from the address bar. Cosmetic ONLY — the URL + * normalizer strips fragments from stored URLs regardless — so it must + * never block the capture it precedes. The URL is rebuilt ABSOLUTE + * from the origin: a relative argument resolves against the document's + * base URI, and a page whose `<base href>` points at another host + * (archive.is serves content under a randomized subdomain) makes + * replaceState throw SecurityError — which previously aborted the + * whole capture. + */ +function stripCaptureMarker() { + try { + history.replaceState(null, '', + window.location.origin + window.location.pathname + window.location.search); + } catch (err) { + Utils.log('Capture marker left in the address bar (harmless):', err && err.message); + } } /** diff --git a/src/options/index.js b/src/options/index.js index e91f1f0..00b9edb 100644 --- a/src/options/index.js +++ b/src/options/index.js @@ -757,6 +757,7 @@ async function loadAdvanced() { // Case synthesis (Phase 20.4) — requires llmAssist + the key on top. document.getElementById('pref-case-synthesis').checked = isEnabled('caseSynthesis'); + document.getElementById('pref-capture-automation').checked = isEnabled('captureAutomation'); // LLM assist (Phase 14.5). The flag lives in feature-flags; the key // + model live under their own chrome.storage.local keys. We never @@ -871,6 +872,10 @@ async function saveAdvanced() { const synthOn = document.getElementById('pref-case-synthesis').checked; await setOverride('caseSynthesis', synthOn ? true : null); + // Capture automation (Phase 27 K.4). + const captureAutoOn = document.getElementById('pref-capture-automation').checked; + await setOverride('captureAutomation', captureAutoOn ? true : null); + // LLM assist: flag + model preference always; the key only when the // user typed a new one (blank leaves the saved key untouched). const llmOn = document.getElementById('pref-llm-assist').checked; diff --git a/src/options/options.html b/src/options/options.html index 795ec32..ba99ba3 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -476,6 +476,23 @@ <h3 class="xr-opt__subhead">Moral lens</h3> browser session. </p> + <hr /> + <h3 class="xr-opt__subhead">Capture automation</h3> + <label class="xr-opt__field xr-opt__field--inline"> + <input type="checkbox" id="pref-capture-automation" /> + <span>Allow the <code>#xray:capture</code> URL marker to trigger a capture</span> + </label> + <p class="xr-opt__hint" style="margin-top:-8px"> + Off by default. When on, opening any page whose address ends in + <code>#xray:capture</code> captures it and opens the reader — + exactly as the toolbar button would. This exists so a driving + agent (the <code>xray-capture</code> skill) can queue captures by + navigation alone; the marker is stripped before capture and never + reaches the stored URL. It captures pages, nothing more: no + accepting, no publishing. Leave off unless an agent session is + driving. + </p> + <hr /> <h3 class="xr-opt__subhead">Case synthesis</h3> <label class="xr-opt__field xr-opt__field--inline"> diff --git a/src/portal/case-view.js b/src/portal/case-view.js index 0ebc9ce..1f9ad73 100644 --- a/src/portal/case-view.js +++ b/src/portal/case-view.js @@ -431,30 +431,51 @@ export function renderCaseView(host, params) { const snap = side === 'source' ? link.source_snapshot : link.target_snapshot; return (snap && snap.text) || 'another claim'; }; + // Canonical endpoints are row-invariant: index the global + // registry ONCE (O(links)), then each row is a map lookup — + // not rows × links × canon() re-parses. + const byEndpoint = new Map(); // canonical ref → [{link, src, tgt}] + for (const link of Object.values(allLinks)) { + if (link.publishedAt) continue; // already a wire chip + const entry = { link, src: canon(link.source_claim_id), tgt: canon(link.target_claim_id) }; + for (const ref of entry.src === entry.tgt ? [entry.src] : [entry.src, entry.tgt]) { + if (!byEndpoint.has(ref)) byEndpoint.set(ref, []); + byEndpoint.get(ref).push(entry); + } + } + const MAX_LOCAL_CHIPS = 6; // the published-chip cap, matched for (const [coord, row] of rowByCoord) { const canonical = canon(coord); - for (const link of Object.values(allLinks)) { - if (link.publishedAt) continue; // already a wire chip - const src = canon(link.source_claim_id); - const tgt = canon(link.target_claim_id); - if (src !== canonical && tgt !== canonical) continue; + const entries = byEndpoint.get(canonical) || []; + let shown = 0; + let over = 0; + let relRow = null; + for (const { link, src, tgt } of entries) { + const isContradicts = link.relationship === 'contradicts'; + if (!isContradicts && !RELATED_RELATIONSHIPS.has(link.relationship)) continue; + if (shown >= MAX_LOCAL_CHIPS) { over++; continue; } + if (!relRow) { + relRow = row.querySelector('.xr-case__related'); + if (!relRow) { relRow = el('div', 'xr-case__related'); row.appendChild(relRow); } + } const dir = src === canonical ? 'out' : 'in'; - const otherRef = dir === 'out' ? tgt : src; - const otherSide = dir === 'out' ? 'target' : 'source'; - let relRow = row.querySelector('.xr-case__related'); - if (!relRow) { relRow = el('div', 'xr-case__related'); row.appendChild(relRow); } - if (link.relationship === 'contradicts') { + if (isContradicts) { const b = el('span', 'xr-badge xr-badge--warn', '⚠ contradicted (local)'); b.title = 'A local contradiction link — not yet published'; relRow.appendChild(b); - continue; + } else { + const otherRef = dir === 'out' ? tgt : src; + const otherSide = dir === 'out' ? 'target' : 'source'; + const phrase = (RELATED_PHRASE[link.relationship] || {})[dir] || link.relationship; + const b = el('span', 'xr-badge xr-badge--muted', + `${dir === 'out' ? '→' : '←'} ${phrase} (local): ${truncate(nameOf(otherRef, link, otherSide), 60)}`); + b.title = 'A local link — not yet published to relays'; + relRow.appendChild(b); } - if (!RELATED_RELATIONSHIPS.has(link.relationship)) continue; - const phrase = (RELATED_PHRASE[link.relationship] || {})[dir] || link.relationship; - const b = el('span', 'xr-badge xr-badge--muted', - `${dir === 'out' ? '→' : '←'} ${phrase} (local): ${truncate(nameOf(otherRef, link, otherSide), 60)}`); - b.title = 'A local link — not yet published to relays'; - relRow.appendChild(b); + shown++; + } + if (over > 0 && relRow) { + relRow.appendChild(el('span', 'xr-inspector__mono', `… +${over} more local`)); } } })().catch((err) => Utils.error('Local link chips failed', err)); diff --git a/src/portal/synthesis-block.js b/src/portal/synthesis-block.js index a526bf2..f6c9b96 100644 --- a/src/portal/synthesis-block.js +++ b/src/portal/synthesis-block.js @@ -405,13 +405,20 @@ export function renderSynthesisBlock(host, { data, dossier, callbacks = {} }) { for (const m of members) indexByMember[m.article_hash] = createGroundingIndex(m.text); const grounded = groundCaseBrief(reduce.briefInput, indexByMember); + // Triage survives a RE-RUN too (27 S.3 review fix): keys are + // content-derived, so a re-proposed pair keeps its status + // and keys for proposals the new brief no longer makes are + // inert. Without this, every corpus-v2 re-run resurrected + // every dismissed proposal. + const prior = await getCaseBrief(caseId).catch(() => null); const record = { caseId, brief: grounded.brief, grounding: { checked: grounded.checked, dropped: grounded.dropped }, inputHash: liveHash, model: reduce.model, promptVersion: CORPUS_PROMPT_VERSION, members: members.length, analyzed: extracts.length, failed: failures.length, - usage: reduce.usage || null + usage: reduce.usage || null, + triage: (prior && prior.triage) || {} }; try { await saveCaseBrief(record); } catch (err) { Utils.error('saveCaseBrief failed', err); } diff --git a/src/portal/synthesis-review.js b/src/portal/synthesis-review.js index 2948d41..b0219c9 100644 --- a/src/portal/synthesis-review.js +++ b/src/portal/synthesis-review.js @@ -30,6 +30,26 @@ function describe(p, claimsById) { return p.kind; } +/** + * Pure triage partition (27 S.3) — the seam the render projects. + * `triage` maps proposalKey → 'accepted' | 'dismissed'; anything else + * (missing key, unknown status) is OPEN — an unrecognized status must + * never hide a proposal. + */ +export function partitionProposals(acceptable, triage = {}) { + const open = []; + const accepted = []; + const dismissed = []; + for (const p of acceptable || []) { + const key = proposalKey(p); + const status = triage[key]; + if (status === 'accepted') accepted.push(p); + else if (status === 'dismissed') dismissed.push({ p, key }); + else open.push({ p, key }); + } + return { open, accepted, dismissed }; +} + async function accept(p, { model, memberByHash }) { const suggested_by = `llm:${model || 'unknown'}`; if (p.kind === 'relationship') { @@ -74,16 +94,7 @@ export function renderProposals(host, { acceptable, rejected, claimsById, member } }; - const open = []; - const accepted = []; - const dismissed = []; - for (const p of acceptable || []) { - const key = proposalKey(p); - const status = triage[key]; - if (status === 'accepted') accepted.push(p); - else if (status === 'dismissed') dismissed.push({ p, key }); - else open.push({ p, key }); - } + const { open, accepted, dismissed } = partitionProposals(acceptable, triage); host.appendChild(el('h4', 'xr-case__heading', `Proposals — review and accept (${open.length})`)); diff --git a/src/reader/index.js b/src/reader/index.js index a21db17..0f3f222 100644 --- a/src/reader/index.js +++ b/src/reader/index.js @@ -1673,8 +1673,17 @@ async function refreshFindingsBar() { host.querySelectorAll('.xr-findings__baseline').forEach((row) => { const delBtn = row.querySelector('[data-action="baseline-delete"]'); if (delBtn) delBtn.addEventListener('click', async () => { - if (!confirm('Remove this baseline?')) return; - await ForensicBaseline.delete(row.dataset.id); + if (!confirm('Remove this baseline? Findings that marked a deviation from it keep their evidence but lose the baseline link.')) return; + const baselineId = row.dataset.id; + await ForensicBaseline.delete(baselineId); + // Clear dangling deviation links (baseline_ref is editable + // context, not identity, so this is an update not a rekey). + const findings = await ForensicModel.getAll(); + for (const f of Object.values(findings)) { + if (f.baseline_ref === baselineId) { + await ForensicModel.update(f.id, { baseline_ref: null }).catch(() => {}); + } + } toast('Baseline removed', 'success', 1500); await refreshFindingsBar(); }); diff --git a/src/reader/llm-review.js b/src/reader/llm-review.js index f824901..8cd2eac 100644 --- a/src/reader/llm-review.js +++ b/src/reader/llm-review.js @@ -94,8 +94,8 @@ const EDIT_FIELDS = { // 27 F.3 — the misattribution backstop: the subject is // correctable at review time (it was the ONE field you // couldn't fix, before or after accept). - { key: 'subject_ref', label: 'Subject entity ref (E1, E2… from the entities above; blank = use label)', type: 'text' }, - { key: 'subject_label', label: 'Subject label (who PERFORMS the move — not who reports it)', type: 'text' }, + { key: 'subject_ref', label: 'Subject entity ref (E1, E2…; blank = use label — the ref wins when both are set)', type: 'text' }, + { key: 'subject_label', label: 'Subject label (who PERFORMS the move — used only when the ref is blank)', type: 'text' }, { key: 'role', label: 'Role', type: 'select', options: ROLES }, { key: 'maneuver', label: 'Maneuver', type: 'text' }, { key: 'basis', label: 'Basis', type: 'select', options: BASIS_VALUES }, @@ -104,7 +104,11 @@ const EDIT_FIELDS = { { key: 'counter_note', label: 'Counter-read (required)', type: 'textarea' } ], baseline: [ - { key: 'subject_label', label: 'Subject', type: 'text' }, + // Review fix (27 F.3): the ref is editable/clearable here too — + // without it, correcting a baseline's subject via the label was + // cosmetic while the identity join kept the old keyspace. + { key: 'subject_ref', label: 'Subject entity ref (E1, E2…; blank = use label — the ref wins when both are set)', type: 'text' }, + { key: 'subject_label', label: 'Subject label (used only when the ref is blank)', type: 'text' }, { key: 'note', label: 'Note', type: 'textarea' } ] }; @@ -517,6 +521,15 @@ export async function openLlmReview(opts) { if (!entityIdByRef[p.subject_ref]) return 'Accept its subject entity first.'; if (p.value_entity_ref && !entityIdByRef[p.value_entity_ref]) return 'Accept the value entity first.'; } + // 27 F.3 review fix: the identity join is order-dependent — + // a finding/baseline accepted BEFORE its subject entity + // would silently land label-keyed (fragmented, silently + // unpublishable). Same gate facts always had. Label-only + // subjects (no ref) stay acceptable as before. + if ((row.kind === 'finding' || row.kind === 'baseline') + && p.subject_ref && !entityIdByRef[p.subject_ref]) { + return 'Accept its subject entity first.'; + } return ''; } diff --git a/src/shared/case-dossier.js b/src/shared/case-dossier.js index be1ea4c..b40e056 100644 --- a/src/shared/case-dossier.js +++ b/src/shared/case-dossier.js @@ -184,7 +184,9 @@ export async function collectCaseDossierData(caseEntityId, options = {}) { // Full entity registry snapshot (Phase 20.3) — the case graph // resolves names for entities TAGGED on member articles that // never entered an orbit claim (so aren't in orbit.entities). - // Builders ignore it; it only rides for graph consumers. + // Consumed by graph consumers AND (27 S.2) by buildCaseDossier + // itself, which reads the case entity's authored scope question + // from it. entitiesById: allEntities, orbit: { entity_ids: entityIds, diff --git a/src/shared/corpus-prompts.js b/src/shared/corpus-prompts.js index 43a7772..b6e1cd9 100644 --- a/src/shared/corpus-prompts.js +++ b/src/shared/corpus-prompts.js @@ -257,6 +257,10 @@ export function buildReduceSystemPrompt({ caseName = '', scopeQuestion = '' } = ' listed under `contradictions` in the digest.', '- Propose `is_key` for existing claims the whole case turns on, and `claim` for', ' load-bearing assertions in the extracts that have no claim yet.', + '- `art` keys are shorthand for READING the claims index only: the digest\'s `articles`', + ' object maps each key to its full 64-hex hash. Anywhere the tool asks for an', + ' `article_hash` (holders, evidence_refs, load_bearing, claim proposals), supply the', + ' FULL hash from `articles` or the extract headers — never an `art` key.', 'HARD RULES (docs/PHILOSOPHY.md):', '- NEVER output a verdict, score, probability, or "who is right". Disagreement is DATA —', ' present it, do not resolve it.', diff --git a/src/shared/forensic-modal.js b/src/shared/forensic-modal.js index 820b258..313cc9c 100644 --- a/src/shared/forensic-modal.js +++ b/src/shared/forensic-modal.js @@ -81,8 +81,15 @@ export async function openFindingModal({ } = {}) { ensureStyles(); + // An existing finding whose subject is no longer among the tagged + // entities must fall to the CUSTOM slot carrying its own label — + // otherwise the select silently lands on the first choice and an + // edit-save would offer (and persist context against) the wrong + // subject's baselines. + const existingKey = existing ? subjectKeyOf(existing.subject_ref) : null; const initialSubjectKey = existing - ? (subjectKeyOf(existing.subject_ref) || '__custom__') + ? ((existingKey && subjectChoices.some((c) => c.key === existingKey)) + ? existingKey : '__custom__') : (subjectChoices[0] ? subjectChoices[0].key : '__custom__'); const state = { @@ -170,7 +177,13 @@ export async function openFindingModal({ if (!field || !sel) return; const ref = resolveSubjectRef(state, subjectChoices); const bases = await ForensicBaseline.getForSubject(ref).catch(() => []); - if (!bases.length) { field.hidden = true; state.baselineRef = existing ? state.baselineRef : null; return; } + // A baselineRef belonging to a DIFFERENT subject must not + // survive a subject switch — the rebuilt select would show + // "(none)" while the stale id silently persisted on save. + if (state.baselineRef && !bases.some((b) => b.id === state.baselineRef)) { + state.baselineRef = null; + } + if (!bases.length) { field.hidden = true; return; } sel.innerHTML = `<option value="">(none)</option>${bases.map((b) => `<option value="${escapeHtml(b.id)}" ${b.id === state.baselineRef ? 'selected' : ''}>${escapeHtml((b.note || '').slice(0, 80))}</option>`).join('')}`; field.hidden = false; diff --git a/src/shared/llm-client.js b/src/shared/llm-client.js index e8ec150..0c3c7ff 100644 --- a/src/shared/llm-client.js +++ b/src/shared/llm-client.js @@ -201,6 +201,35 @@ async function postMessages(payload, apiKey, { signal } = {}) { return { ok: true, data }; } +/** + * A model-side safety guardrail declining the request is its OWN state, + * never the generic "malformed output" error (the lens pass's §6 rule, + * generalized here). Claude Fable 5 runs classifiers that can decline — + * bio and cyber topics especially — and returns HTTP 200 with + * `stop_reason: 'refusal'` and an empty/partial content array. Without + * this check every caller falls through to extractToolInput() → null → + * "the model did not return a structured extract", which blames the + * wrong thing and sends the user hunting a bug that isn't there. + * + * Returns an `{ ok: false, refused: true, ... }` result to return as-is, + * or null when the response was not a refusal. + * + * @param {object} data the parsed Messages response + * @param {string} what what was being produced, for the message + */ +export function refusalResult(data, what) { + if (!data || data.stop_reason !== 'refusal') return null; + const category = (data.stop_details && data.stop_details.category) || null; + return { + ok: false, refused: true, code: 'model-refusal', category, + error: `The model declined to produce ${what}` + + (category ? ` (safety category: ${category})` : '') + + '. This is a model-side guardrail — not a key, network, or X-Ray problem. ' + + 'Some models decline topics others allow; switching model in ' + + 'Options → Advanced → LLM assist is the usual workaround.' + }; +} + /** * Pull a forced tool's `input` out of a Messages response, by tool name. * Returns the input object, or null if no matching tool_use was found. @@ -280,6 +309,7 @@ export async function runSuggestionPass(req = {}) { if (!res.ok) return res; const data = res.data; + { const r = refusalResult(data, 'capture suggestions for this article'); if (r) return r; } if (data && data.stop_reason === 'max_tokens') { return { ok: false, error: 'The model hit its output limit before finishing. Try a shorter article or fewer tasks.' }; } @@ -391,6 +421,7 @@ export async function runAuditPass(req = {}) { if (!res.ok) return res; const data = res.data; + { const r = refusalResult(data, 'this audit'); if (r) return r; } if (data && data.stop_reason === 'max_tokens') { return { ok: false, error: 'The model hit its output limit before finishing the audit. Try a shorter article.' }; } @@ -478,6 +509,7 @@ export async function runAuditModulePass(req = {}) { if (!res.ok) return { ...res, module: name }; const data = res.data; + { const r = refusalResult(data, `the ${name} audit module`); if (r) return { ...r, module: name }; } if (data && data.stop_reason === 'max_tokens') { return { ok: false, module: name, error: `The ${name} module hit its output limit before finishing.` }; } @@ -562,6 +594,7 @@ export async function runCorpusMapPass(req = {}) { if (!res.ok) return { ...res, member_id: req.member_id }; const data = res.data; + { const r = refusalResult(data, 'an extract for this article'); if (r) return { ...r, member_id: req.member_id }; } if (data && data.stop_reason === 'max_tokens') { return { ok: false, member_id: req.member_id, error: 'The map call hit its output limit before finishing.' }; } @@ -602,6 +635,7 @@ export async function runCorpusReducePass(req = {}) { if (!res.ok) return res; const data = res.data; + { const r = refusalResult(data, 'the corpus brief'); if (r) return r; } if (data && data.stop_reason === 'max_tokens') { return { ok: false, error: 'The synthesis hit its output limit before finishing.' }; } @@ -648,6 +682,7 @@ export async function runHypothesisEdgePass(req = {}) { if (!res.ok) return res; const data = res.data; + { const r = refusalResult(data, 'hypothesis edge proposals'); if (r) return r; } if (data && data.stop_reason === 'max_tokens') { return { ok: false, error: 'The edge-suggestion call hit its output limit before finishing.' }; } diff --git a/src/shared/llm-prompts.js b/src/shared/llm-prompts.js index 173f3ff..4318e6a 100644 --- a/src/shared/llm-prompts.js +++ b/src/shared/llm-prompts.js @@ -30,9 +30,16 @@ import { ENTITY_TYPES } from './entity-model.js'; // latest capable Claude; the Options picker renders these. // ------------------------------------------------------------------ +// Ordered most-capable-first; the Options picker renders this verbatim. +// Adding a model is this line + nothing else — every caller resolves +// through resolveModel(), and the corpus/lens/audit passes all read the +// user's stored choice. Cost note (per MTok in/out, 2026-07): Fable 5 +// $10/$50 · Opus 4.8 $5/$25 · Sonnet 5 $3/$15 · Haiku 4.5 $1/$5. export const LLM_MODELS = Object.freeze([ - { id: 'claude-opus-4-8', label: 'Claude Opus 4.8 (most capable)' }, + { id: 'claude-fable-5', label: 'Claude Fable 5 (most capable — highest cost)' }, + { id: 'claude-opus-4-8', label: 'Claude Opus 4.8 (most capable Opus)' }, { id: 'claude-opus-4-7', label: 'Claude Opus 4.7' }, + { id: 'claude-sonnet-5', label: 'Claude Sonnet 5 (near-Opus quality, Sonnet cost)' }, { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 (balanced)' }, { id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5 (fastest / cheapest)' } ]); diff --git a/src/shared/llm-proposals.js b/src/shared/llm-proposals.js index 23e7154..d5d2afc 100644 --- a/src/shared/llm-proposals.js +++ b/src/shared/llm-proposals.js @@ -40,7 +40,7 @@ import { CLAIM_RELATIONSHIPS, REVISION_RELATIONSHIPS } from './assessment-taxonomy.js'; import { - isValidManeuver, isValidRole, isValidBasis, BASIS_VALUES + isValidManeuver, isValidRole, isValidBasis, BASIS_VALUES, ROLES } from './forensic-taxonomy.js'; // Dependency order: a kind is only ever accepted after the kinds it can @@ -266,7 +266,7 @@ export function validateProposal(prop, ctx = {}) { } case 'finding': { if (!subjectLabelOf(prop, ctx)) return fail('Finding needs a subject'); - if (!isValidRole(prop.role)) return fail(`Role must be one of: apologist, critic, institution, witness, survivor, other`); + if (!isValidRole(prop.role)) return fail(`Role must be one of: ${ROLES.join(', ')}`); if (!isValidManeuver(prop.maneuver)) return fail(`Invalid maneuver: ${prop.maneuver}`); const basis = prop.basis || 'structural-inference'; if (!isValidBasis(basis)) return fail(`Basis must be one of: ${BASIS_VALUES.join(', ')}`); diff --git a/src/shared/metadata/feature-flags.js b/src/shared/metadata/feature-flags.js index a47449a..334248a 100644 --- a/src/shared/metadata/feature-flags.js +++ b/src/shared/metadata/feature-flags.js @@ -113,6 +113,13 @@ export const FLAGS_DEFAULTS = Object.freeze({ // ordinary 30040/30055 through the normal publish paths. caseSynthesis: false, + // Phase 27 K.4: the `#xray:capture` URL marker — a driving agent's + // capture trigger (the connector can neither reach extension pages + // nor fire the command shortcut, so navigation is the only verb it + // has). Gates ONLY the marker; the toolbar/shortcut/menu capture + // paths are unconditional as ever. Captures pages, nothing more. + captureAutomation: false, + // Phase 25 (docs/NETWORK_CLIENT_DESIGN.md §8): gates the Network // SURFACE — the standalone follows-feed page, its context-menu item, // and the options/sidepanel links. Reading relays is not a diff --git a/tests/case-synthesis.test.mjs b/tests/case-synthesis.test.mjs index 33d4d0d..c4c9081 100644 --- a/tests/case-synthesis.test.mjs +++ b/tests/case-synthesis.test.mjs @@ -121,6 +121,26 @@ test('case-synthesis: digest claims carry short per-article keys so cross-articl 'the 64-hex hash no longer rides every claim entry'); }); +test('case-synthesis: DIGEST_CLAIM_CAP bounds the index AND the art-key map together (27 S.1)', () => { + const dossier = { coverage: {}, shape_of_knowledge: {}, knots: {}, orbit: {} }; + const claims = []; + for (let i = 0; i < CS.DIGEST_CLAIM_CAP + 10; i++) { + claims.push({ id: `c${i}`, text: `Claim ${i}.`, article_hash: `${String(i % 3)}`.repeat(64) }); + } + // The capped tail cites a hash NO capped claim carries — its art + // key must not leak into the articles map. + claims[CS.DIGEST_CLAIM_CAP + 5].article_hash = 'f'.repeat(64); + const digest = JSON.parse(CS.digestDossier(dossier, { claims })); + assert.equal(digest.claim_count, CS.DIGEST_CLAIM_CAP); + assert.equal(digest.claims.length, CS.DIGEST_CLAIM_CAP); + assert.ok(!digest.claims.some((c) => c.id === `c${CS.DIGEST_CLAIM_CAP}`), 'claims beyond the cap are absent'); + const mapped = new Set(Object.values(digest.articles)); + assert.ok(!mapped.has('f'.repeat(64)), 'articles map derives from the SAME capped slice'); + for (const c of digest.claims) { + assert.ok(c.art === null || digest.articles[c.art], `art key ${c.art} resolves`); + } +}); + test('case-synthesis: proposalKey is stable and direction-insensitive for relationships (27 S.3)', () => { // The triage record persists under these keys — a key change would // silently resurrect every dismissed proposal. diff --git a/tests/forensic-builders.test.mjs b/tests/forensic-builders.test.mjs index 8cf7da4..97f4ed1 100644 --- a/tests/forensic-builders.test.mjs +++ b/tests/forensic-builders.test.mjs @@ -123,6 +123,19 @@ test('30062: build → parse round-trip', async () => { assert.equal(parseBehavioralFindingEvent({ kind: 30054 }), null, 'wrong kind → null'); }); +test('30062 parse: role values beyond the original six are tolerated (NIP_DRAFT MUST, 27 F.6)', async () => { + const { event } = await buildBehavioralFindingEvent(baseArgs({ role: 'journalist' })); + event.pubkey = 'f'.repeat(64); + event.id = 'e'.repeat(64); + assert.equal(parseBehavioralFindingEvent(event).role, 'journalist'); + // A FOREIGN event with a role outside our taxonomy still parses — + // consumers MUST tolerate unknown role values. + const foreign = JSON.parse(JSON.stringify(event)); + for (const t of foreign.tags) if (t[0] === 'role') t[1] = 'prosecutor'; + assert.equal(parseBehavioralFindingEvent(foreign).role, 'prosecutor', + 'unknown role passes through on read; only the BUILD side validates'); +}); + test('1985 mirror: labels the SUBJECT pubkey under xray/forensic', async () => { const { event, dTag } = buildForensicFindingMirrorEvent({ subjectPubkey: PK, maneuver: 'darvo/attack', sourceUrl: 'https://example.com/x' diff --git a/tests/forensic-section.test.mjs b/tests/forensic-section.test.mjs index 173ba55..4d4339e 100644 --- a/tests/forensic-section.test.mjs +++ b/tests/forensic-section.test.mjs @@ -80,6 +80,13 @@ test('bar: baselines render as plain rows — visible at last, never a weight (2 assert.match(withChip, /Baseline: Even, fact-anchored register\./); const withoutRef = renderFindingsBar([finding()], [baseline]); assert.doesNotMatch(withoutRef, /deviates from baseline/); + // Dangling ref (baseline deleted elsewhere / foreign data): the + // chip still renders with the generic no-weight tooltip — never a + // crash, never a fabricated note. + const dangling = renderFindingsBar([finding({ baseline_ref: 'baseline_gone00000000' })], [baseline]); + assert.match(dangling, /deviates from baseline/); + assert.match(dangling, /context — not a weight/, 'the generic disclaimer tooltip, not a fabricated note'); + assert.doesNotMatch(dangling, /Baseline: Even/); for (const html of [empty, withChip]) { assert.doesNotMatch(html, /score|rating|weight|confidence/i); } diff --git a/tests/llm-proposals.test.mjs b/tests/llm-proposals.test.mjs index cacbd3e..9a7cbec 100644 --- a/tests/llm-proposals.test.mjs +++ b/tests/llm-proposals.test.mjs @@ -392,6 +392,12 @@ test('buildAssessmentInput: an unlocatable label quote saves the label WITHOUT a assert.equal(input.labels[1].label, 'unsupported'); }); +test('captureAutomation defaults OFF — the #xray:capture marker is opt-in (27 K.4)', async () => { + const { FLAGS_DEFAULTS } = await import('../src/shared/metadata/feature-flags.js'); + assert.equal(FLAGS_DEFAULTS.captureAutomation, false, + 'the navigation-marker capture trigger must be explicitly enabled'); +}); + test('findings prompt carries the attribution rules — speaker vs reporter (27 F.2)', () => { const sys = buildSystemPrompt({ tasks: ['findings'] }); assert.match(sys, /the party who\s+PERFORMED the move/); @@ -500,6 +506,47 @@ test('resolveModel defaults unknown ids to the latest capable model', () => { assert.equal(resolveModel('claude-sonnet-4-6'), 'claude-sonnet-4-6'); }); +test('the model picker offers Fable 5 and Sonnet 5, and every id resolves', async () => { + const { LLM_MODELS } = await import('../src/shared/llm-prompts.js'); + const ids = LLM_MODELS.map((m) => m.id); + assert.deepEqual(ids, [ + 'claude-fable-5', 'claude-opus-4-8', 'claude-opus-4-7', + 'claude-sonnet-5', 'claude-sonnet-4-6', 'claude-haiku-4-5' + ]); + // Every offered id must survive resolveModel — a picker entry that + // silently falls back to the default is a lie to the user. + for (const id of ids) assert.equal(resolveModel(id), id); + for (const m of LLM_MODELS) assert.ok(m.label && m.label.length > 3, m.id); + assert.ok(ids.includes(DEFAULT_LLM_MODEL), 'the default is offered'); +}); + +test('a model refusal is its own disclosed state, never a malformed-output error', async () => { + const { refusalResult } = await import('../src/shared/llm-client.js'); + // Fable 5 declines with HTTP 200 + stop_reason refusal and an empty + // content array — the shape that would otherwise fall through to + // "the model did not return a structured extract". + const refused = refusalResult( + { stop_reason: 'refusal', stop_details: { type: 'refusal', category: 'bio' }, content: [] }, + 'the corpus brief'); + assert.equal(refused.ok, false); + assert.equal(refused.refused, true); + assert.equal(refused.code, 'model-refusal'); + assert.equal(refused.category, 'bio'); + assert.match(refused.error, /declined to produce the corpus brief/); + assert.match(refused.error, /safety category: bio/); + assert.match(refused.error, /not a key, network, or X-Ray problem/); + assert.match(refused.error, /switching model/, 'names the actual workaround'); + // stop_details is optional — never assume it is present (it is null + // for every non-refusal stop reason). + const bare = refusalResult({ stop_reason: 'refusal' }, 'suggestions'); + assert.equal(bare.category, null); + assert.doesNotMatch(bare.error, /safety category/); + // Everything else passes through untouched. + assert.equal(refusalResult({ stop_reason: 'end_turn' }, 'x'), null); + assert.equal(refusalResult({ stop_reason: 'max_tokens' }, 'x'), null); + assert.equal(refusalResult(null, 'x'), null); +}); + test('llmAssist flag defaults OFF', async () => { const { FLAGS_DEFAULTS } = await import('../src/shared/metadata/feature-flags.js'); assert.equal(FLAGS_DEFAULTS.llmAssist, false); diff --git a/tests/synthesis-review.test.mjs b/tests/synthesis-review.test.mjs new file mode 100644 index 0000000..d0f88cd --- /dev/null +++ b/tests/synthesis-review.test.mjs @@ -0,0 +1,53 @@ +// Proposal-triage partition tests — 27 S.3 review fix. The DOM render +// projects the pure `partitionProposals` seam 1:1; these pin the +// contract the persistence rides on: statuses key by proposalKey +// (content-derived, endpoint-order-insensitive), and anything without +// a RECOGNIZED status is OPEN — an unknown status must never hide a +// proposal. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +globalThis.chrome = globalThis.chrome || { + storage: { local: { get(_k, cb) { cb({}); }, set(_o, cb) { cb && cb(); }, remove(_k, cb) { cb && cb(); } } } +}; + +const { partitionProposals } = await import('../src/portal/synthesis-review.js'); +const { proposalKey } = await import('../src/shared/case-synthesis.js'); + +const rel = (s, t) => ({ kind: 'relationship', source_claim_id: s, target_claim_id: t, relationship: 'supports' }); +const key = (p) => proposalKey(p); + +test('synthesis-review: partition routes by triage status; missing/unknown statuses stay open', () => { + const a = rel('c1', 'c2'); + const b = rel('c3', 'c4'); + const c = { kind: 'is_key', claim_id: 'c5' }; + const d = rel('c6', 'c7'); + const triage = { + [key(a)]: 'accepted', + [key(b)]: 'dismissed', + [key(c)]: 'starred' // unknown status → must stay open + }; + const { open, accepted, dismissed } = partitionProposals([a, b, c, d], triage); + assert.deepEqual(accepted, [a]); + assert.deepEqual(dismissed.map((x) => x.p), [b]); + assert.deepEqual(open.map((x) => x.p), [c, d], 'unknown status and no status both stay open'); + assert.equal(open[1].key, key(d), 'open rows carry the key the triage write will use'); +}); + +test('synthesis-review: a re-proposed relationship with flipped endpoints keeps its triage status', () => { + // The reduce may emit the same logical pair in either direction on + // a re-run; the endpoint-order-insensitive key means a dismissal + // survives the flip. + const triage = { [key(rel('c1', 'c2'))]: 'dismissed' }; + const { open, dismissed } = partitionProposals([rel('c2', 'c1')], triage); + assert.equal(open.length, 0); + assert.equal(dismissed.length, 1); +}); + +test('synthesis-review: empty inputs partition to empty — the zero-proposals message is reachable', () => { + const { open, accepted, dismissed } = partitionProposals([], {}); + assert.deepEqual([open, accepted, dismissed], [[], [], []]); + const noTriage = partitionProposals([rel('c1', 'c2')]); + assert.equal(noTriage.open.length, 1, 'triage map optional'); +});