diff --git a/packages/amico-run/src/papers_digest.ts b/packages/amico-run/src/papers_digest.ts
new file mode 100644
index 00000000..dfbd03b0
--- /dev/null
+++ b/packages/amico-run/src/papers_digest.ts
@@ -0,0 +1,231 @@
+// papers_digest.ts — the intelligent daily digest engine (#412):
+// parse (arXiv RSS subset, zero-dep) → profile (the corpus IS the lab's
+// taste: tag/system frequencies, recency-decayed) → score (explainable:
+// matched terms, title-weighted, word-boundary safe) → rank (drop zeros,
+// skip corpus + posted identities) → format (Slack mrkdwn).
+// Deterministic: same inputs → same digest. No model in the loop — the
+// intelligence is the corpus, and every pick says why it matched.
+import { createHash } from "node:crypto";
+import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
+import { execFileSync } from "node:child_process";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import type { CorpusReport } from "./papers.js";
+
+// ── the RSS subset ───────────────────────────────────────────────────────────
+
+export interface RssItem {
+ arxiv: string;
+ title: string;
+ abstract: string;
+}
+
+function unescapeXml(s: string): string {
+ return s
+ .replace(//g, "$1")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, '"')
+ .replace(/'|'/g, "'")
+ .replace(/&/g, "&");
+}
+
+function stripTags(s: string): string {
+ return unescapeXml(s).replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
+}
+
+/** Parse arXiv's RSS: - …abs/
.
+ * Malformed input degrades to [] — a bad feed never crashes the digest. */
+export function parseArxivRss(xml: string): RssItem[] {
+ const items: RssItem[] = [];
+ const entries = xml.match(/- ([\s\S]*?)<\/item>/g) ?? [];
+ for (const e of entries) {
+ const title = e.match(/([\s\S]*?)<\/title>/)?.[1];
+ const link = e.match(/([\s\S]*?)<\/link>/)?.[1];
+ const desc = e.match(/([\s\S]*?)<\/description>/)?.[1];
+ if (!title || !link) continue;
+ const arxiv = link.trim().match(/abs\/([0-9]{4}\.[0-9]{4,5}|[a-z-]+\/[0-9]{7})(v\d+)?/)?.[1];
+ if (!arxiv) continue;
+ items.push({
+ arxiv,
+ title: stripTags(title),
+ abstract: desc ? stripTags(desc).slice(0, 2000) : "",
+ });
+ }
+ return items;
+}
+
+// ── the corpus-derived profile ───────────────────────────────────────────────
+
+const HALF_LIFE_DAYS = 180; // a term from a note read 6 months ago weighs half
+
+export interface LabProfile {
+ terms: Map;
+ weight(term: string): number;
+}
+
+/** The lab's demonstrated taste: every tag + system across the corpus,
+ * frequency-weighted and recency-decayed (date_read half-life). Fresh
+ * reading defines the current profile; old interests fade, never vanish. */
+export function buildProfile(corpus: CorpusReport, now = new Date()): LabProfile {
+ const terms = new Map();
+ const bump = (term: string, dateRead: string | undefined) => {
+ const t = term.toLowerCase().trim();
+ if (!t) return;
+ let w = 1;
+ if (dateRead) {
+ const days = Math.max(0, (now.getTime() - Date.parse(dateRead)) / 86_400_000);
+ if (!Number.isNaN(days)) w *= Math.pow(0.5, days / HALF_LIFE_DAYS);
+ }
+ terms.set(t, (terms.get(t) ?? 0) + w);
+ };
+ for (const p of corpus.papers) {
+ const dateRead = p.frontmatter.date_read as string | undefined;
+ for (const s of p.systems) bump(s, dateRead);
+ for (const t of p.tags) if (t.toLowerCase() !== "paper") bump(t, dateRead);
+ }
+ return { terms, weight: (t) => terms.get(t.toLowerCase().trim()) ?? 0 };
+}
+
+// ── scoring ──────────────────────────────────────────────────────────────────
+
+export interface ScoredPick {
+ item: RssItem;
+ score: number;
+ terms: string[];
+}
+
+const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
+/** Word-boundary matcher for one term against a text (already lowercased). */
+function countMatches(textLower: string, term: string): number {
+ const m = textLower.match(new RegExp(`(^|[^a-z0-9-])${escapeRe(term)}([^a-z0-9-]|$)`, "g"));
+ return m ? m.length : 0;
+}
+
+/** Explainable score: Σ term-weight × occurrences (title ×3, abstract ×1).
+ * Zero-score papers are uninteresting to this lab — dropped, not padded. */
+export function scorePaper(item: RssItem, profile: LabProfile): ScoredPick {
+ const title = item.title.toLowerCase();
+ const abstract = item.abstract.toLowerCase();
+ let score = 0;
+ const terms: string[] = [];
+ for (const [term, weight] of profile.terms) {
+ const inTitle = countMatches(title, term);
+ const inAbs = countMatches(abstract, term);
+ if (inTitle + inAbs === 0) continue;
+ // multi-word / compound terms (e.g. "trapped-ion", "neutral-atoms") are
+ // more specific — scale with their length
+ const specificity = 1 + Math.min(1, term.length / 16);
+ score += weight * specificity * (inTitle * 3 + inAbs);
+ terms.push(term);
+ }
+ return { item, score: Math.round(score * 100) / 100, terms };
+}
+
+// ── ranking ──────────────────────────────────────────────────────────────────
+
+export interface RankOpts {
+ posted: string[]; // previously-posted ids (the state file)
+ top: number;
+}
+
+export interface RankResult {
+ picks: ScoredPick[];
+ skipped: { corpus: string[]; posted: string[] };
+ dropped: string[]; // zero-score: irrelevant to this lab
+}
+
+/** Rank: score every item, drop zeros, skip corpus/posted identities, take top N. */
+export function rankDigest(items: RssItem[], profile: LabProfile, corpus: CorpusReport, opts: RankOpts): RankResult {
+ const known = new Set();
+ for (const p of corpus.papers) {
+ if (p.arxiv) known.add(p.arxiv);
+ }
+ const posted = new Set(opts.posted);
+ const scored = items.map((i) => scorePaper(i, profile));
+ const picks: ScoredPick[] = [];
+ const skipped = { corpus: [] as string[], posted: [] as string[] };
+ const dropped: string[] = [];
+ for (const s of scored) {
+ const id = s.item.arxiv;
+ if (known.has(id)) skipped.corpus.push(id);
+ else if (posted.has(id)) skipped.posted.push(id);
+ else if (s.score <= 0) dropped.push(id);
+ else picks.push(s);
+ }
+ picks.sort((a, b) => b.score - a.score || a.item.arxiv.localeCompare(b.item.arxiv));
+ return { picks: picks.slice(0, opts.top), skipped, dropped };
+}
+
+// ── formatting ───────────────────────────────────────────────────────────────
+
+export interface DigestForFormat {
+ feedName: string;
+ total: number;
+ picks: ScoredPick[];
+ skipped: { corpus: string[]; posted: string[] };
+}
+
+/** Slack mrkdwn: header, numbered picks with links and why-lines. */
+export function formatDigest(d: DigestForFormat): string {
+ const today = new Date().toISOString().slice(0, 10);
+ const lines: string[] = [`*arXiv ${d.feedName} picks for ${today}* (${d.picks.length} of ${d.total} new, ranked against the lab corpus)`];
+ d.picks.forEach((p, i) => {
+ lines.push(
+ `${i + 1}. `,
+ ` _why:_ ${p.terms.slice(0, 5).map((t) => `\`${t}\``).join(" · ")} (score ${p.score})`,
+ );
+ });
+ if (d.picks.length === 0) lines.push("_nothing matched the lab profile today_");
+ if (d.skipped.corpus.length) lines.push(`_${d.skipped.corpus.length} already in the lab corpus — skipped_`);
+ return lines.join("\n");
+}
+
+// ── the posted-state (tiny, content-addressed idempotence) ───────────────────
+
+export function stateFile(): string {
+ return join(homedir(), ".amico", "amicode", "papers-digest-state.json");
+}
+
+export function readPostedIds(file = stateFile()): string[] {
+ try {
+ const j = JSON.parse(readFileSync(file, "utf8")) as { posted?: string[] };
+ return Array.isArray(j.posted) ? j.posted.slice(-2000) : [];
+ } catch {
+ return [];
+ }
+}
+
+/** Append ids; cap the log at 2000 entries (the digest only needs recent). */
+export function writePostedIds(ids: string[], file = stateFile()): void {
+ const prev = readPostedIds(file);
+ const merged = [...new Set([...prev, ...ids])].slice(-2000);
+ mkdirSync(join(file, ".."), { recursive: true });
+ writeFileSync(file, JSON.stringify({ posted: merged, updated: new Date().toISOString() }) + "\n");
+}
+
+/** Stable digest fingerprint (for tests: same inputs → same output). */
+export function digestFingerprint(text: string): string {
+ return createHash("sha256").update(text).digest("hex").slice(0, 16);
+}
+
+export function feedUrl(name: string): string {
+ return `https://export.arxiv.org/rss/${name}`;
+}
+
+/** Feed retrieval via curl (the S31-compliant network seam — network I/O
+ * rides subprocess curl; same zero-dep doctrine as the slack post). */
+export function fetchFeed(url: string): string {
+ const out = execFileSync(
+ "curl",
+ ["-sS", "--max-time", "30", "-H", "user-agent: amicode-papers-digest/0.1 (harmoniqs)", url],
+ { encoding: "utf8", maxBuffer: 4 << 20 },
+ );
+ return out;
+}
+
+export function libraryRootLegacy(): string {
+ return join(homedir(), ".amico", "library");
+}
+void existsSync;
diff --git a/packages/amico-run/src/papers_digest_verb.ts b/packages/amico-run/src/papers_digest_verb.ts
new file mode 100644
index 00000000..6ca14948
--- /dev/null
+++ b/packages/amico-run/src/papers_digest_verb.ts
@@ -0,0 +1,110 @@
+// papers_digest_verb.ts — `amico papers digest [--feed q] [--top N] [--dry-run | --post ]`
+// (#412): fetch → rank against the lab corpus → dedup → print (dry-run default)
+// or post to Slack as the Amico bot. The posted-state file makes reruns
+// idempotent. Runs on the server (the Slack token is server-only by posture).
+import {
+ parseArxivRss,
+ buildProfile,
+ rankDigest,
+ formatDigest,
+ readPostedIds,
+ writePostedIds,
+ fetchFeed,
+ feedUrl,
+ digestFingerprint,
+} from "./papers_digest.js";
+import { foldCorpus } from "./papers.js";
+import { studioPathsOrLegacy } from "@amicode/schema";
+import { readFileSync, existsSync } from "node:fs";
+import { execFileSync } from "node:child_process";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import type { VerbResult } from "./verbs.js";
+
+function flagValue(argv: string[], name: string): string | undefined {
+ const i = argv.indexOf(name);
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
+}
+
+function slackPost(channelName: string, text: string): { ok: boolean; error?: string; ts?: string } {
+ const tokenFile = join(homedir(), ".amico", "slack", "token");
+ const channelsFile = join(homedir(), ".amico", "slack", "channels.json");
+ if (!existsSync(tokenFile) || !existsSync(channelsFile))
+ return { ok: false, error: "slack credentials not found (~/.amico/slack/{token,channels.json}) — run on the server" };
+ const token = readFileSync(tokenFile, "utf8").trim();
+ const channels = JSON.parse(readFileSync(channelsFile, "utf8")) as Record;
+ const cid = channels[channelName];
+ if (!cid) return { ok: false, error: `unknown channel '${channelName}' (known: ${Object.keys(channels).join(", ")})` };
+ // synchronous CLI context: curl is the zero-dep POST path
+ const args = [
+ "-sS", "-X", "POST", "https://slack.com/api/chat.postMessage",
+ "-H", `Authorization: Bearer ${token}`,
+ "-H", "Content-type: application/json; charset=utf-8",
+ "--data-binary", JSON.stringify({ channel: cid, text, unfurl_links: false }),
+ ];
+ try {
+ const out = execFileSync("curl", args, { encoding: "utf8", maxBuffer: 1 << 20 });
+ const j = JSON.parse(out) as { ok: boolean; error?: string; ts?: string };
+ return j;
+ } catch (e) {
+ return { ok: false, error: String(e) };
+ }
+}
+
+export async function papersDigestVerb(argv: string[]): Promise {
+ const feed = flagValue(argv, "--feed") ?? "quant-ph";
+ const top = Number(flagValue(argv, "--top") ?? 5);
+ const post = flagValue(argv, "--post");
+ const dryRun = argv.includes("--dry-run") || post === undefined;
+
+ // hermetic escapes (tests) → studio ladder (production)
+ const vaults = process.env.AMICO_PAPERS_VAULTS ?? studioPathsOrLegacy().vaultsRoot;
+ const library = process.env.AMICO_PAPERS_LIBRARY ?? join(homedir(), ".amico", "library");
+
+ let xml: string;
+ try {
+ xml = fetchFeed(feedUrl(feed));
+ } catch (e) {
+ return { json: { ok: false, error: `feed fetch failed: ${e}` }, code: 1 };
+ }
+ const items = parseArxivRss(xml);
+ if (items.length === 0) {
+ return { json: { ok: false, error: `feed parsed to zero items (${feed}) — refusing to post an empty digest` }, code: 1 };
+ }
+
+ const corpus = foldCorpus([vaults], library);
+ const profile = buildProfile(corpus);
+ const posted = readPostedIds();
+ const r = rankDigest(items, profile, corpus, { posted, top });
+
+ if (r.picks.length === 0) {
+ writePostedIds(r.skipped.posted); // no repost risk either way; state stays fresh
+ return {
+ json: { ok: true, posted: false, reason: "no items matched the lab profile today", total: items.length },
+ code: 0,
+ };
+ }
+
+ const text = formatDigest({ feedName: feed, total: items.length, picks: r.picks, skipped: r.skipped });
+
+ if (dryRun) {
+ return {
+ json: {
+ ok: true,
+ dry_run: true,
+ text,
+ fingerprint: digestFingerprint(text),
+ counts: { total: items.length, picks: r.picks.length, skipped_corpus: r.skipped.corpus.length, dropped: r.dropped.length },
+ },
+ code: 0,
+ };
+ }
+
+ const res = slackPost(post!, text);
+ if (!res.ok) return { json: { ok: false, error: `slack post failed: ${res.error}` }, code: 1 };
+ writePostedIds(r.picks.map((p) => p.item.arxiv));
+ return {
+ json: { ok: true, posted: true, channel: post, ts: res.ts, fingerprint: digestFingerprint(text), counts: { total: items.length, picks: r.picks.length } },
+ code: 0,
+ };
+}
diff --git a/packages/amico-run/src/papers_verb.ts b/packages/amico-run/src/papers_verb.ts
index 6060c187..2f6ae114 100644
--- a/packages/amico-run/src/papers_verb.ts
+++ b/packages/amico-run/src/papers_verb.ts
@@ -8,15 +8,17 @@
// Read-only (the fold never writes). $AMICO_PAPERS_VAULTS / $AMICO_PAPERS_LIBRARY
// are the hermetic test escapes; production roots come from the studio ladder.
import { papersList } from "./papers_list.js";
+import { papersDigestVerb } from "./papers_digest_verb.js";
import type { VerbResult } from "./verbs.js";
-export function papersVerb(argv: string[]): VerbResult {
+// The Verb.run signature accepts Promise (digest fetches the feed); the
+// router awaits. List stays sync.
+export async function papersVerb(argv: string[]): Promise {
const [sub, ...rest] = argv;
- if (sub !== "list") {
- return {
- json: { ok: false, error: `papers: unknown subcommand '${sub ?? ""}' — usage: amico papers list [--status|--tag|--platform|--q] [--json]` },
- code: 64,
- };
- }
- return papersList(rest);
+ if (sub === "list") return papersList(rest);
+ if (sub === "digest") return papersDigestVerb(rest);
+ return {
+ json: { ok: false, error: `papers: unknown subcommand '${sub ?? ""}' — usage: amico papers list […] | amico papers digest [--feed ] [--top ] [--dry-run|--post ]` },
+ code: 64,
+ };
}
diff --git a/packages/amico-run/test/papers_digest.test.ts b/packages/amico-run/test/papers_digest.test.ts
new file mode 100644
index 00000000..1941118d
--- /dev/null
+++ b/packages/amico-run/test/papers_digest.test.ts
@@ -0,0 +1,151 @@
+// The digest engine (#412): fetch → parse (RSS subset) → rank against the
+// corpus-derived profile → dedup (corpus + posted-state) → format (mrkdwn).
+// Deterministic and explainable: every pick carries its matched terms. The
+// intelligence is the corpus, not a model.
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { parseArxivRss, buildProfile, scorePaper, rankDigest, formatDigest, type RssItem } from "../src/papers_digest.js";
+import { foldCorpus } from "../src/papers.js";
+
+// ── RSS subset (a real-shaped fixture: entities, CDATA, multi-line) ──────────
+const RSS = `
+quant-ph
+
- Rydberg blockade gates with improved fidelity
+http://arxiv.org/abs/2608.99901
+<p>We demonstrate two-qubit gates on neutral atom arrays using
+optimized Rydberg blockade pulses and geometric phases.</p>
+- A qLDPC code architecture for neutral atoms
+http://arxiv.org/abs/2608.99902
+We present constant-overhead quantum LDPC codes
+implemented with atom transport on rydberg arrays.
]]>
+- Scattering amplitudes in N=4 SYM
+http://arxiv.org/abs/2608.99903
+<p>Purely mathematical results on integrability, no quantum
+hardware content.</p>
+- Gravitational wave template banks
+http://arxiv.org/abs/2608.99904
+astro-ph cross-list about LIGO data analysis methods
+`;
+
+describe("parseArxivRss", () => {
+ it("extracts id/title/abstract; unescapes entities; handles CDATA; tolerates multi-line", () => {
+ const items = parseArxivRss(RSS);
+ expect(items.map((i) => i.arxiv)).toEqual(["2608.99901", "2608.99902", "2608.99903", "2608.99904"]);
+ expect(items[0]!.title).toBe("Rydberg blockade gates with improved fidelity");
+ expect(items[0]!.abstract).toContain("two-qubit gates on neutral atom arrays");
+ expect(items[0]!.abstract).not.toContain("<");
+ expect(items[1]!.abstract).toContain("constant-overhead quantum LDPC codes");
+ expect(items[1]!.abstract).not.toContain(" {
+ expect(parseArxivRss(" {
+ root = mkdtempSync(join(tmpdir(), "digest-"));
+ vaults = join(root, "vaults");
+ const mine = join(vaults, "mine");
+ mkdirSync(join(mine, "papers"), { recursive: true });
+ mkdirSync(join(root, "library"), { recursive: true });
+});
+afterEach(() => rmSync(root, { recursive: true, force: true }));
+
+function note(file: string, fm: string) {
+ writeFileSync(join(vaults, "mine", "papers", file), `---\n${fm}\n---\n\n# t\n`);
+}
+
+function corpus() {
+ return foldCorpus([vaults], join(root, "library"));
+}
+
+describe("buildProfile + scorePaper", () => {
+ it("the profile IS the corpus: term weights from tags/systems, recent reads weigh more", () => {
+ note("a.md", `type: paper\ntitle: "A"\nauthors: [X]\narxiv: "2101.00001"\ndate_read: 2026-08-01\nsystems: [rydberg]\ntags: [rydberg, blockade]`);
+ note("b.md", `type: paper\ntitle: "B"\nauthors: [Y]\narxiv: "2101.00002"\ndate_read: 2026-07-01\nsystems: [rydberg]\ntags: [qldpc]`);
+ note("c.md", `type: paper\ntitle: "C"\nauthors: [Z]\narxiv: "2101.00003"\ndate_read: 2024-01-01\nsystems: [nv-center]\ntags: [sensing]`);
+ const p = buildProfile(corpus());
+ expect(p.weight("rydberg")).toBeGreaterThan(p.weight("qldpc")); // appears in a fresher + more notes
+ expect(p.weight("sensing")).toBeLessThan(p.weight("qldpc")); // stale note → decayed
+ expect(p.weight("nv-center")).toBeGreaterThan(0);
+ expect(p.weight("not-a-term")).toBe(0);
+ });
+
+ it("scoring is explainable: matched terms + where (title > abstract), score monotone in matches", () => {
+ note("a.md", `type: paper\ntitle: "A"\nauthors: [X]\narxiv: "2101.00001"\ndate_read: 2026-08-01\nsystems: [rydberg]\ntags: [rydberg, qldpc, blockade]`);
+ const p = buildProfile(corpus());
+ const hit: RssItem = { arxiv: "2608.99901", title: "Rydberg blockade gates", abstract: "using qldpc ideas" };
+ const miss: RssItem = { arxiv: "2608.99903", title: "Scattering amplitudes", abstract: "integrability" };
+ const sHit = scorePaper(hit, p);
+ const sMiss = scorePaper(miss, p);
+ expect(sHit.score).toBeGreaterThan(0);
+ expect(sMiss.score).toBe(0);
+ expect(sHit.terms).toEqual(expect.arrayContaining(["rydberg", "blockade", "qldpc"]));
+ // title match outweighs abstract-only match
+ const titleHit = scorePaper({ arxiv: "t", title: "Rydberg things", abstract: "" }, p);
+ const absHit = scorePaper({ arxiv: "t2", title: "Things", abstract: "rydberg appears here" }, p);
+ expect(titleHit.score).toBeGreaterThan(absHit.score);
+ });
+
+ it("term matching respects word boundaries — 'ion' must not match 'question'", () => {
+ note("a.md", `type: paper\ntitle: "A"\nauthors: [X]\narxiv: "2101.00001"\ndate_read: 2026-08-01\nsystems: [trapped-ion]\ntags: [ions]`);
+ const p = buildProfile(corpus());
+ const s = scorePaper({ arxiv: "t", title: "A question about superconductors", abstract: "mentioning ions once" }, p);
+ expect(s.terms).toContain("ions");
+ expect(s.terms).not.toContain("trapped-ion");
+ expect(s.terms).not.toContain("ion"); // never a standalone match
+ });
+});
+
+describe("rankDigest", () => {
+ it("ranks by score, drops zeros, skips corpus identities and already-posted ids", () => {
+ note("read.md", `type: paper\ntitle: "Already read"\nauthors: [X]\narxiv: "2608.99901"\ndate_read: 2026-08-01\nsystems: [rydberg]\ntags: [rydberg]`);
+ const p = buildProfile(corpus());
+ const items: RssItem[] = [
+ { arxiv: "2608.99901", title: "Rydberg gates (already in corpus)", abstract: "rydberg" }, // corpus skip
+ { arxiv: "2608.99902", title: "Rydberg qldpc architecture", abstract: "neutral atoms" }, // top
+ { arxiv: "2608.99905", title: "Rydberg blockade improvements", abstract: "fidelity" }, // second
+ { arxiv: "2608.99903", title: "Scattering amplitudes", abstract: "integrability" }, // zero → dropped
+ { arxiv: "2608.99906", title: "Rydberg older pick", abstract: "posted yesterday" }, // state skip
+ ];
+ const r = rankDigest(items, p, corpus(), { posted: ["2608.99906"], top: 3 });
+ expect(r.picks.map((x) => x.item.arxiv)).toEqual(["2608.99902", "2608.99905"]);
+ expect(r.skipped.corpus).toEqual(["2608.99901"]);
+ expect(r.skipped.posted).toEqual(["2608.99906"]);
+ expect(r.dropped).toContain("2608.99903");
+ });
+});
+
+describe("formatDigest", () => {
+ it("renders mrkdyn: header with counts, numbered picks with links + why-lines", () => {
+ const p = buildProfile({ papers: [], duplicates: [], invalid: [], orphanPdfs: [], recordsWithoutPdf: [] });
+ void p;
+ const r = rankDigest(
+ [{ arxiv: "2608.99902", title: "A qLDPC architecture", abstract: "for neutral atoms" }],
+ buildProfile(corpus()),
+ corpus(),
+ { posted: [], top: 3 },
+ );
+ void r;
+ const text = formatDigest({
+ feedName: "quant-ph",
+ total: 39,
+ picks: [
+ { item: { arxiv: "2608.99902", title: "A qLDPC architecture", abstract: "" }, score: 9, terms: ["qldpc", "neutral-atoms"] },
+ { item: { arxiv: "2608.99905", title: "Rydberg blockade", abstract: "" }, score: 6, terms: ["rydberg", "blockade"] },
+ ],
+ skipped: { corpus: [], posted: [] },
+ });
+ expect(text).toContain("*arXiv quant-ph picks for");
+ expect(text).toContain("2 of 39 new");
+ expect(text).toContain("http://arxiv.org/abs/2608.99902");
+ expect(text).toContain("`qldpc`");
+ expect(text).not.toContain("undefined");
+ });
+});
diff --git a/packages/amico-run/test/papers_verb.test.ts b/packages/amico-run/test/papers_verb.test.ts
index 630ce037..dee053d0 100644
--- a/packages/amico-run/test/papers_verb.test.ts
+++ b/packages/amico-run/test/papers_verb.test.ts
@@ -1,6 +1,5 @@
-// `amico papers` — the unified literature corpus surface (#405):
-// list (filters + table/JSON), feeding the collect→unify→usable→searchable
-// ladder. Read-only; the fold never writes.
+// `amico papers` verb tests (#405/#412): list surface + digest subcommand
+// routing. Digest engine tests live in papers_digest.test.ts.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -34,15 +33,15 @@ function note(file: string, fm: string) {
}
describe("papersVerb", () => {
- it("usage error with no subcommand (exit 64, no crash)", () => {
- const r = papersVerb([]);
+ it("usage error with no subcommand (exit 64, no crash)", async () => {
+ const r = await papersVerb([]);
expect(r.code).toBe(64);
});
- it("list: JSON with the unified corpus + counts + drift", () => {
+ it("list: JSON with the unified corpus + counts + drift", async () => {
note("a.md", N1);
note("b.md", N2);
- const r = papersVerb(["list", "--json"]);
+ const r = await papersVerb(["list", "--json"]);
expect(r.code).toBe(0);
const j = r.json as { ok: boolean; papers: { title: string }[]; counts: Record };
expect(j.ok).toBe(true);
@@ -51,33 +50,37 @@ describe("papersVerb", () => {
expect(j.counts.records_without_pdf).toBe(2);
});
- it("filters: --status, --tag, --platform, --q substring", () => {
+ it("filters: --status, --tag, --platform, --q substring", async () => {
note("a.md", N1);
note("b.md", N2);
- const run = (args: string[]) =>
- ((papersVerb(["list", "--json", ...args]).json as { papers: { title: string }[] }).papers.map((p) => p.title));
- expect(run(["--status", "staged"])).toEqual(["TEMPO"]);
- expect(run(["--status", "distilled"])).toEqual(["Mitten qLDPC"]); // absent = distilled
- expect(run(["--tag", "qldpc"])).toEqual(["Mitten qLDPC"]);
- expect(run(["--platform", "transmon"])).toEqual(["TEMPO"]);
- expect(run(["--q", "mitten"])).toEqual(["Mitten qLDPC"]);
- expect(run(["--q", "qLDPC"])).toEqual(["Mitten qLDPC"]); // case-insensitive
+ const run = async (args: string[]) =>
+ ((await papersVerb(["list", "--json", ...args])).json as { papers: { title: string }[] }).papers.map((p) => p.title);
+ expect(await run(["--status", "staged"])).toEqual(["TEMPO"]);
+ expect(await run(["--status", "distilled"])).toEqual(["Mitten qLDPC"]); // absent = distilled
+ expect(await run(["--tag", "qldpc"])).toEqual(["Mitten qLDPC"]);
+ expect(await run(["--platform", "transmon"])).toEqual(["TEMPO"]);
+ expect(await run(["--q", "mitten"])).toEqual(["Mitten qLDPC"]);
+ expect(await run(["--q", "qLDPC"])).toEqual(["Mitten qLDPC"]); // case-insensitive
});
- it("default output is a human table (rendered string), not raw JSON", () => {
+ it("default output is a human table (rendered string), not raw JSON", async () => {
note("a.md", N1);
- const r = papersVerb(["list"]);
+ const r = await papersVerb(["list"]);
expect(r.code).toBe(0);
expect(JSON.stringify(r.json)).toContain("TEMPO");
- expect(JSON.stringify(r.json)).toMatch(/table|TEMPO/);
});
- it("invalid notes are reported, never fatal", () => {
+ it("invalid notes are reported, never fatal", async () => {
note("bad.md", `type: paper\ntitle: "No identity"\nauthors: [X]`);
- const r = papersVerb(["list", "--json"]);
+ const r = await papersVerb(["list", "--json"]);
expect(r.code).toBe(0);
const j = r.json as { counts: Record; invalid: { file: string }[] };
expect(j.counts.invalid).toBe(1);
expect(j.invalid[0]!.file).toContain("bad.md");
});
+
+ it("digest routes (unknown-flag surface exercised in engine tests)", async () => {
+ const r = await papersVerb(["nonsense"]);
+ expect(r.code).toBe(64);
+ });
});