Format (v0.95.0+): entries follow a lightweight Keep a Changelog convention going forward — within each version use
### Added / Changed / Deprecated / Removed / Fixed / Securitywhere applicable, and flag breaking changes with a BREAKING: prefix. Per-release migration steps are mirrored in UPGRADE.md. Entries before v0.95.0 predate this convention and are kept verbatim (retrofitting 90+ historical entries was judged higher-risk than the consistency gain — G4 audit #3).
The UI 80/20 track (Phase B: ⌘K command palette + mobile breakpoints +
_HOME wayfinding; Phase D: Zotero metadata correctness) is a SEPARATE
future release — it was never folded into v1.1.0, which is the P2
"scale + provenance" release below. UI scope stays capped: the dashboard
remains a thin status-mirror + palette + onboarding demo; no 3-pane /
citation-graph rebuild (link out to the real tools instead).
vault gcdangling-link bug (found on a real vault during the v1.1.0 graph migration):find_orphan_mocstreated ahub/_moc/<name>.mdpage as orphan when no live cluster derived it — but did not check whether live paper notes still linked it. A note carrying a stale sub-MOC link after a rebind/merge (whose## Hubblock was never rewritten) would have had its_mocpage deleted byvault gc --apply, stranding dangling wikilinks.run_gcnow computes every wikilink target live (non-deleted) notes point at (note_linked_targets) and protects both the_mocpass AND the orphan-hub pass (ahub/<slug>/linked by a cross-cluster[[slug/00_overview]]body reference is likewise protected). gc never makes the graph worse. Regression tests pin both guards. (The stale link itself is a separate, deferred cleanup.)
"Scale + provenance" — the roadmap P2 block (robustness sweep, PRISMA
screening provenance, config-rot hygiene, vault gc), plus the v1.0.11
graph follow-ups that unblock the real-vault graph migration.
- PRISMA screening provenance (P2-3): an append-only
.research_hub/screening_log.jsonlrecords every relevance-gate decision (included/screened_outby reason, plus aunverifiedflag on papers the no-LLM gate kept without a clear verdict). Newclusters prisma <slug>CLI +cluster_prismaMCP tool render the PRISMA flow counts. The no-LLM fit gate now emits these records and prints a cold-start nudge ("N papers ingested unverified — run an LLM fit-check to confirm"). vault gc(P2-5d): garbage-collect agedraw/_deleted_<slug>/residue (--older-than-days, default 30), orphanhub/<slug>/dirs with no registry entry, and orphanhub/_moc/*.mdpages no live cluster references; plus a paper-note## Hub-block bare-parent strip (--no-strip-parentsto skip). Dry-run by default;--applyto execute.PARENT_MOCSand query-derived sub-MOCs are protected from deletion.relevance_unverifiedis now persisted to paper-note frontmatter (it was computed by the no-LLM fit gate but discarded) — an unverified paper leaves an auditable trace.
- Windows FS-lock hardening (P2-1): the 6 remaining bare
shutil.movesites (cluster archive / unarchive / cascade-delete soft-move; paper prune / unarchive / bulk-move) now route throughfsops.robust_move(retry/backoff on transientPermissionError), completing the v1.0.7 sweep. - Graph view label colors (P2-5a):
vault graph-colors --refreshnow emits label color groups ONLY for#label/*tags that actually exist in the vault, so a refresh no longer re-injects the 9 canonical label groups every time (a user who deleted the dead ones can now keep them deleted). The one-time init/bootstrap still pre-seeds the full palette. clusters._PARENT_MOCSis nowfrozenset(PARENT_MOCS)derived from the single source of truth invault.hub_overview(was a hand-maintained literal mirror).- The
## Hub-backfill path (vault hub-backlink-migrate) now drops the bare family parent on paper notes (for_paper_note=True), matching the live-ingest P1-4a policy (paper notes link the sub-MOC only). vault prune-footersnow skips soft-deletedraw/_deleted_<slug>/residue.
- Dead duplicate
[tool.pytest.ini_options]block inpyproject.toml(pytest.iniis the authoritative pytest config; the pyproject block was ignored and silently drifting). Reconciled the stale "≥1-week RC bake" claim indocs/stable-api.mdto the real release-gate cadence.
- Typed MCP path-param validation contract (P2-5e): the MCP slug/identifier
validation set is now an explicit module-level contract
(
_SLUG_PARAM_NAMES/_IDENTIFIER_PARAM_NAMES) and_validate_mcp_argsfails closed on an unregistered slug-like kwarg. A new meta-test proves every slug-like parameter across the live tool registry is covered, closing the silent-bypass drift class (a new tool adding a path param under an unrecognized name).
vault gcstaletopic:-tag rewrite (the riskiest, content-mutating pass) and full PRISMAidentified/dedupedfunnel emission (the screening log schema supports both stages; v1.1 auto-emits only the relevance-screen stage).
"Token economy + Obsidian graph" — make the vault graph a readable star-of-stars and stop the MCP surface from blowing the agent context window.
- Token-economy caps (P1-5).
ask_cluster(mode='briefing')default cap lowered 100K→12K chars + asection=markdown-section projection.read_crystalat tldr/gist returnsbased_on_paper_countonly — the fullbased_on_papersslug list + per-claimevidence+see_alsoship only atlevel='full'.get_topic_digestpaginatespapers(default 10;limit=0for all) and makes the markdown digest opt-in (include_markdown=True). - Per-cluster MOC, capped footers (P1-4).
derive_moc_links(for_paper_note=True)→ paper notes link the per-cluster sub-MOC ONLY; the parent[[LLM-Agents]]/[[Water-Resources]]link now lives on sub-MOC + overview pages, not on every note — killing the O(n^2) super-hub clique. The backward footer-append re-ranks by tag overlap and caps at the same top-10 as the forward path (was unbounded). The v1.0.7 merged-cluster GC still protects the parent MOC (it uses the page variant of derive_moc_links).
research-hub vault prune-footers [--top N] [--apply]re-caps already-bloated Related-Papers footers (P1-4c). Adoctor cluster/crystal_coveragecheck flags clusters that have papers but 0 crystals, andask_cluster's digest fallback now returns an inlineemit_crystal_promptso an AI can crystalize on demand (P1-5d).
"Institutional access end-to-end" — make a paywalled-no-OA paper with a DOI localizable through the library's EZproxy by both the Zotero and auto-ingest paths, with fail-soft auth + a discoverable keepalive.
- Institutional PDF localization (P1-1).
find_pdf_urlonly ever returned open-access URLs, so EZproxy never fired for a paywalled-no-OA paper. Newfind_institutional_pdf_urlresolves the publisher landing page (OpenAlexprimary_location/ DOI redirect), fetches it THROUGH the proxy, extracts the standard<meta name="citation_pdf_url">, and localizes the PDF —paper attach-pdfsand the NotebookLM auto-ingest path (fetch_paper_pdf(cfg=...)) both reach subscribed content now, reported asezproxy-citation-pdfinstead of a silent skip. The landing + PDF fetches reuse the v1.0.8 cookie-scoped_credentialed_getcore, so the SSO session is never forwarded off-proxy. - EZproxy fail-soft auth (P1-2). New
ezproxy_probe(single-hop, cookie-safe) classifies the session live vs expired;doctorgains anezproxy_sessioncheck with a real "last verified live: Nh ago" recency signal (fromauth_state.json), andpaper attach-pdfssurfaces aRequiresAuthRefreshone-liner (research-hub ezproxy login) when papers are paywalled and the session has lapsed — instead of a silent "no PDF". - Keepalive discoverable + py3.14
--from-browser(P1-3).doctornudgesnotebooklm keepalive --install-windows-taskwhen the NLM session is live but the refresh task isn't registered;--from-browsergains abrowser_cookie3fallback (pure-Python, py3.14-compatible — rookiepy has no 3.14 wheel) behind the existing suffix-anchored cookie filter.
_download_via_httpx_resultrefactored onto a shared_credentialed_get(the v1.0.8 bounded-redirect cookie-scoping core), now reused by the institutional landing-HTML fetch.browser-cookie3added to thebrowser-authextra.
CI-hardening release — no shipped-behavior change. Converts the test suite's remember-every-entry-point offline-stub denylist into a structural fence.
- Structural network fence in the test suite (pytest-socket). An autouse
fixture in
tests/conftest.pyrestricts every test to LOOPBACK (127.0.0.1 / ::1 / localhost) + unix sockets by default; any connect to an external host fails loudly withSocketConnectBlockedError. The moment a new leak to a real API (arXiv, Crossref, Unpaywall, zotero.org, NotebookLM) is introduced — or an existing offline stub regresses — CI fails immediately instead of flaking on a network blip (the green-by-luck class behind several past CI saves). Tests that genuinely need a live external service opt in via@pytest.mark.network(plus the now-registeredreal_zotero/real_authenticitymarkers). The URL-quality active probe is also stubbed offline by default so the bundle tests make zero external connects.pytest-socketadded to thedevextra;tests/test_v109_network_fence.pyguards the fence (external connect blocked, loopback bind allowed).
Credential-safety release. Hardens the two highest-consequence trust paths the maturity audit flagged: the institutional-access credential path and the secret-at-rest key.
- EZproxy session cookies are now scoped to the proxy host and never
forwarded across an off-proxy redirect. The credentialed PDF fetch loaded
cookies into a domain-less dict and followed redirects with
follow_redirects=True, so a proxied publisher page that 302'd to any other host received the university SSO session — the hardest-to-renew credential in the system, leaked silently on the default path. The fetch now follows redirects manually (bounded), sending cookies ONLY to hosts within the proxy suffix and dropping them on every off-proxy hop. - All credentialed / redirect-followed fetches are http(s)-only. A shared
security.is_safe_fetch_urlguard rejectsfile:///ftp:///data:/ hostless URLs on the initial request AND every redirect hop, on both the httpx (Zotero PDF) and urllib (NotebookLM PDF) paths — closing the SSRF / local-file-read primitive a poisoned Unpaywall / OA / publisher record opened viaurlopen. - The secret-box encryption key is now OS-ACL-hardened.
.secret_box.keywas written with a bareos.chmod(0o600)— a no-op on Windows that left the key inheriting the parent directory ACL. It now routes through the realchmod_sensitiveuser-only Windows-ACL path (loud warning on failure), so the key gets the same at-rest protection as the secrets it guards.
research_hub.security.is_safe_fetch_url+host_in_suffix(shared URL and cookie-scope guards), a bounded manual-redirect loop in the Zotero PDF download path, and a_SafeRedirectHandlerfor the urllib fetch path. Newtests/test_v108_credential_safety.pycovers cookie non-forwarding, the scheme guard on both fetch paths, the redirect cap, and the key hardening.
- Curation moves are now deep, retried, and fully index-synced (data-integrity
keystone).
operations.move_paper— the primitive underclusters merge/clusters split— was a shallowPath.replacewith no retry and no index/link sync. On a transient Windows AV / Search-indexer lock it could strand a paper; worse, every move silently drifted the persisted dedup index (observed 126 stale entries vs 102 alive in the maintainer vault), left stale[[wikilink]]footers in the old cluster's siblings, and left the moved note's## Hubblocktopic:body tag pointing at the old cluster. It now: moves via the retry/backofffsops.robust_move; invalidates the old path and registers the new one indedup_index.json; rewires the source/target "Related Papers in This Cluster" footers; retargets the Hub block +topic:tag (backfillingtopic_clusterfrontmatter when a note was missing it); and returns a richer result dict carrying async_warningslist so degraded (non-fatal) syncs are observable instead of silent.
merge/splitare atomic. Each note move is verified (_verify_note_in_cluster); if any move fails the moves roll back and the source cluster stays intact (statusunchanged) — never a half-merge or a stranded paper. Asplitwhose moves fail also drops the just-created (empty) target cluster.- Merge leaves a tombstone instead of dropping the cluster — fixes the
duplicate-cluster bug. A merged source is kept as
status: merged+merged_into: <target>and hidden from the active cluster list;match_by_query/resolve_mergedredirect a re-ingest on the merged-away seed query to the target (cycle-guarded) instead of RE-CREATING the cluster and duplicating its papers. Orphanedhub/<source>/dirs and source-only sub-MOCs are GC'd on merge (a shared sub-MOC referenced by any other active cluster always survives).
research_hub.fsops.robust_move— shared Windows-lock-safe move primitive (retry/backoff on transientPermissionError), promoted out ofcluster_rebind(which now re-imports it). NewCluster.status/Cluster.merged_intofields;ClusterRegistry.resolve_mergedandlist(include_merged=False). New regression tests cover atomic-merge rollback, the dedup-index sync, the tombstone redirect, and a note missingtopic_clusterfrontmatter (no spurious rollback).
clusters rebind --auto-create-newno longer strands papers on a transient filesystem lock (Windows). The case-only folder rename (Foo→foo) did a two-stepshutil.move(source→*.__rebind_tmp__→target) and swallowed any exception intoresult.errors. A transient Windows AV / Search-indexer sharing-violation (WinError 5/32) on the second leg left the papers stranded in a*.__rebind_tmp__folder with no cluster binding, surfacing asmoved=0. Moves now retry transientPermissionErrorlocks with backoff (_robust_move); a failed second leg rolls the temp folder back to its original name so papers are never stranded; and a partial cross-filesystem move is now reported instead of silently recording a short moved count. This was the root cause of the rare release-gate flake intest_v039_rebind_autocreate— it was not config-singleton pollution (disproven) ormove_paper(not in this path).
ezproxy login --from-browser <browser>— import EZproxy session cookies from an already-logged-in real browser via rookiepy (no Playwright popup, no terminal interaction), mirroringnotebooklm login --from-browser. Reuses the session you already have from browsing through your library proxy. Requires thebrowser-authextra; rookiepy has no Python 3.14 wheel yet, so it degrades with a clear message (use the interactiveezproxy loginon 3.14). Newezproxy.capture_cookies_from_browser.- Auto-detected
ezproxy_host_suffix—resolve_confignow derives the hostname-rewrite suffix from captured cookie domains when it is not explicitly configured, so a one-time login enables hostname-rewrite without a manualconfig set ezproxy_host_suffix. Newezproxy.detect_host_suffix; an explicit config value still takes precedence.
ezproxy loginnow saves cookies reliably. It previously calledstorage_state()only AFTER the close-detection loop, but closing the window tears down the Playwright context first, so the save raised and nothing was persisted (0 cookies). Login now savesstorage_stateevery second WHILE the window is open (plus a final save on close), so the session is captured even on an abrupt window close.
- Hostname-rewriting EZproxy mode (
ezproxy_host_suffix). Most modern institutions (e.g. Lehigh) run EZproxy in hostname-rewrite mode (www.nature.com→www-nature-com.ezproxy.lib.lehigh.edu) rather than the legacy/login?qurl=starting-point form — the latter returns anEZproxyCheckBackJavaScript interstitial that research-hub's non-browser httpx fetch cannot follow, so paywalled PDFs were never retrieved. Setresearch-hub config set ezproxy_host_suffix "<your-ezproxy-host>"; the publisher host is rewritten in place andpaper attach-pdfs/autofetch the proxied PDF directly. Hostname rewriting takes priority overezproxy_url_templatewhen both are set (the template remains a legacy fallback). Newezproxy.wrap_url_hostname;wrap_urlgains ahost_suffixarg;ezproxy statusshows the suffix;RESEARCH_HUB_EZPROXY_HOST_SUFFIXenv override. See docs/ezproxy.md.
autonow attaches Unpaywall open-access PDFs (previously silently skipped).auto's PDF-attach step (_run_pdf_attach_step) calledplan_attach_for_items(items)without theunpaywall_emailkwarg, so the Unpaywall lookup was skipped (the "Skipping Unpaywall" hint printed) andautoattached 0 OA PDFs even whenunpaywall_emailwas configured. The standalonepaper attach-pdfs(cli_paper) and thepipeline.run_pipelinepath (oa_email) already passed it — onlyautowas missing it. Now threadscfg.unpaywall_emailthrough, matching the other two call sites. Regression:tests/test_v087_pdf_attach_reporting.py::test_auto_pdf_attach_forwards_unpaywall_email.
- Dashboard surfaces fit-check quarantined papers (FUNC-1 dashboard half).
The Diagnostics tab gains a "Quarantined (N)" card listing papers the
authenticity gate rejected (slug / cluster / layer / reason / date) --
completing FUNC-1 alongside the existing MCP
list_quarantineand RESTGET /clusters/{slug}/quarantine. Additive: newQuarantineRecord+DashboardData.quarantined, collected best-effort so a missing quarantine store never breaks dashboard render. mcp-tools.mdcoverage drift gate.docs/mcp-tools.mdhad drifted to 56 documented vs 79 live MCP tools;tests/test_mcp_tools_doc_coverage.pynow enforces coverage (a new tool must be documented or consciously allowlisted; the gap allowlist may only shrink). The README already surfaces the live count viadescribe --filter mcp_tools.
- User-Agent centralized in
research_hub._useragent(ARCH-1). The ~22 hardcodedresearch-hub/<version>UA literals across the search backends and verify / operations / importer / notebooklm now build fromresearch_hub.__version__viauser_agent(). This fixes 3 STALE pins (0.4.1,0.9.0,0.43) that had drifted off the real version, and a version-scan gate test (test_useragent_version_sync.py) now fails CI if any hardcoded UA literal is reintroduced. - CHANGELOG archived. Split the 8499-line
CHANGELOG.mdat the v1.0.0 boundary -- current + recent releases stay here (~1.4k lines); the v1.0.0 pre-release notes and all v0.95.x / earlier moved toCHANGELOG-archive/CHANGELOG-pre-1.0.md(linked at the bottom).
- REST
_optional_boolcoerces string booleans.POST /api/v1/auto(and other bool flags) now accept case-insensitive"true"/"false"strings (REST clients commonly send string bools); other non-bool values still return 400. auto --forcegenuinely overwrites.force=truewas previously a no-op beyond the FUNC-2 guard (indistinguishable from--append); it now clears the cluster'sraw/<slug>/*.mdnotes before re-ingest (scoped, non-recursive, never on dry-run; Zotero / NotebookLM are not pruned).--force --dry-runadvertises the clear step in the plan so the destructive action is previewable.zotero.fetch.get_notesno longer swallows all exceptions. Narrowed a bareexcept:toexcept Exception+ a warning so KeyboardInterrupt / SystemExit propagate and real fetch errors surface (best-effort[]fallback retained).- Added functional-test coverage for the
restore_quarantineMCP tool.
tests/test_skill_structure.py— structural + trust invariants for the 11SKILL.mdfiles, closing the gap an ai-research-skills quality audit (2026-05-28) flagged: frontmatter shape and the installer mirror were already tested, but three guarantees were defended only by convention. Now asserted on every PR: (1) the ≤500-line progressive-disclosure ceiling (max today is research-hub at 244); (2) anti-fabrication safety strings survive every reword/sync (research-hub"Do not invent",paper-memory-builderstatus: gap,paper-summarize"hallucinated",notebooklm-brief-verifier"do NOT assume coverage"); (3) the sibling disambiguation arrows that keep auto-trigger from mis-firing (the paper-summarize / paper-memory-builder / literature-triage-matrix trio name each other;zotero-library-curatornameszotero-skills;gap-to-topicnamesliterature-triage-matrixresearch-design-helper). Test-only — no skill content changed; all 21 parametrized cases pass against the current files.
-
cli.pygod-file split intocli_*domain modules (ARCH-2). The 8439-linesrc/research_hub/cli.pywas broken into focused sibling modules (cli_common,cli_citations,cli_search,cli_notebooklm,cli_zotero,cli_summarize,cli_clusters,cli_vault,cli_pipeline,cli_paper,cli_maintenance); cli.py is now 3654 lines (−57%) and retains only the orchestrator core (build_parser,_main_dispatch,main,_sync_cli_dependencies). Pure move — byte-faithful handler bodies, no behaviour change; the CLI / MCP / REST surface is unchanged and the test files were not modified. Each domain module re-exports through cli.py and a_sync_cli_dependencies()forwarder keeps test monkeypatches onresearch_hub.cli.*reaching the relocated handlers. Also deduped the triplicated_load_zotero_if_configuredinto a single canonicalcli_commondefinition and removed a dead, uncalled_read_doi_from_frontmattercopy. -
MCP tool docstrings rewritten for 12 worst-scoring tools (Glama TDQS lift) (
mcp_server.py). Replaced terse one-line docstrings on the 12 tools that Glama's Tool Definition Quality Score flagged as weakest (cluster_rebind,ask_cluster,discover_continue,prune_cluster,discover_variants,examples_show,autofill_emit,autofill_apply,fit_check_audit,fit_check_drift,apply_fit_check_to_labels,propose_cluster_rebind). New docstrings hit all six TDQS dimensions — Behavior (verb-first opening), Conciseness, Completeness (full Returns dict-key list), Parameters (per-arg semantics + defaults + valid values), Purpose (relationship to neighbouring tools, deprecated-alias cross-refs), Usage Guidelines (When to use / When NOT to use bullets). The four canonical tools that LLM clients hit most (cluster_rebind,ask_cluster,discover_continue,prune_cluster) get the full multi-line template; the other eight get a compact 1-line-per-section variant. Zero behavioural change — function bodies / signatures / decorators byte-identical, only docstring text differs. Full pytest suite green (3101 passed). Expected TDQS impact: individual tool scores 1.8-2.4/5 lift toward 3-4/5; overall quality grade C should move to B-ish after Glama re-introspects on the next release. -
MCP deprecated aliases hidden from default surface (env-gated) (
mcp_server.py,tests/_mcp_helpers.py). The 10 deprecated MCP tool aliases that were already scheduled for v2.0.0 removal are now hidden from the default@mcp.tool()registration. Glama's Tool Definition Quality Score (TDQS) penalises high tool count + poor disambiguation; this drops the LLM-facing surface from 86 → 76 tools without breaking the v1.x SemVer promise (the functions remain importable as Python module attributes; only the FastMCP registration is gated). Hidden by default — setRESEARCH_HUB_MCP_INCLUDE_DEPRECATED=1to re-expose them during your migration window. Affected aliases (use the canonical replacement going forward):propose_cluster_rebind → cluster_rebind(action='propose') apply_cluster_rebind → cluster_rebind(action='apply') list_orphan_papers → cluster_rebind(action='list_orphans') summarize_rebind_status → cluster_rebind(action='status') list_entities → read_cluster_memory(kind='entities') list_claims → read_cluster_memory(kind='claims') list_methods → read_cluster_memory(kind='methods') read_briefing → ask_cluster(source='notebooklm', mode='briefing') ask_cluster_notebooklm → ask_cluster(source='notebooklm') brief_cluster → ask_cluster(source='notebooklm', mode='brief')Test helper
_get_mcp_toolgains an optionalmodule=kwarg for callers that want to fall back to a Python attribute when an MCP registration is gated (back-compat: defaultNonepreserves the existing strict registry lookup). -
README.zh-TW.mdfull mirror of the new EN structure. Previously the zh-TW README was 152 lines / 6 sections (Real Screenshots, Why this exists, Start Here, First-Run Checklist, Connect your AI host, License only) — 10 sections behind the EN master + in the pre-restructure order. Now 430 lines / 18 sections matching the EN's 6-layer importance order:## 快速開始(Quick start) +## 目錄(Contents TOC) + the 16 content sections in the new order. 10 new sections translated from EN viagemini-delegate; existing 6 sections kept where the prose was operator-approved quality. All skill names, file paths, CLI commands, env var names, brand names, version refs, MCP terms, and code blocks preserved in English per locale convention. -
README.mdreordered into 6 importance layers, with a new## Quick startsection and a 17-row## ContentsTOC inserted at the top. Previously install (## Start Here) lived at line 121 behind four selling sections (Real Screenshots / Why this exists / What it does / Is this for me?). A first-time visitor arriving from awesome-mcp-servers / SkillHub / MCP registry / promo posts had to scroll past ~100 lines of pitch before reaching a command they could run. New order: Quick start (line 26) → TOC → Real Screenshots → Is this for me? → full install (Start Here,First-Run Checklist,Credential Reference,Connect your AI host— moved up from line 243 to sit alongside install) → design rationale (Why this exists,What it does,Operator Modes— moved down from lines 51-73 into the design layer) → reference (Dashboard tour, Inside Zotero, Feature matrix) → troubleshooting + meta. No content removed; pure reorder + 2 new navigational sections (Quick start ~10 lines, Contents ~20 lines).
- Pipeline ingest tests no longer make live Crossref / DOI-resolve network
calls (CI flake fix).
run_pipeline(dry_run=False)runs the authenticity gate, which corroborates DOIs over HTTP (_resolve_head_with_retry+CrossrefBackend). In the test suite that leaked real network calls which, on a CI network blip, hung until the 30s pytest-timeout killed them (the 2026-06-01 master CI failure ontest_v041_pipeline_ingest_fixes/test_v062_note_enrich, while local + PR CI were green). Aconftest.pyautouse fixture now runs the gate OFFLINE for pipeline tests — stubbing only the two network entry points so the gate's real logic (local rejections such as L0no_identifier) is preserved while DOI / Crossref checks take the designed fail-open path. Tests that drive the gate's network themselves are auto-excluded via source inspection of the gate internals. Suite stays 3144-green and deterministic. - Subprocess reads now degrade gracefully on a stray byte instead of
crashing the reader thread. Six diagnostic/system-tool subprocess
calls used
text=Truewith noerrors=handler:doctor.py(thewmic/pschrome-process lookup),security/__init__.py(theicaclsACL reset / grant / verify calls),dashboard/executor.py(the command runner'ssubprocess.run+Popenpaths), anddefuddle_extract.py(hadencoding="utf-8"but noerrors=). Under Python's UTF-8 mode (PYTHONUTF8=1), reading a Windows tool that emits cp950/Big5 raisedUnicodeDecodeError: ... byte 0xa4and killed thesubprocessreader thread (10 tracebacks polluteddoctoroutput; the affected lookup silently returned nothing). Addederrors="replace"to all six — the codec is unchanged (these tools legitimately emit locale-encoded output, so forcing utf-8 would mangle them), only a bad byte now becomes a replacement char rather than a crash. The user-facing LLM-output reads (llm_cli.py,paper_summarize.py) were already protected withencoding="utf-8", errors="replace". Verified:doctorunderPYTHONUTF8=1goes from 10UnicodeDecodeErrors to 0; the executor / doctor / security / defuddle suites stay green (141 passed). Surfaced during a live literature-search dogfood of the ai-research-skills audit follow-up.
First stable release. 129 commits since v0.91.1 (95 features + fixes; bumped from 0.91.x trail through 0.95.0 rc cycle to 1.0.0). Authenticity gate (v0.95+), MCP Server Registry preparation, and PDF attach reliability hardening are the headline themes.
mcp-name: io.github.WenyuChiou/research-hubline in README.md (HTML comment at the top, invisible in rendered Markdown). Required for ownership validation when publishing this package to the MCP Server Registry atregistry.modelcontextprotocol.io. Without this line in the PyPI-rendered README,mcp-publisher publishfails withPyPI package 'research-hub-pipeline' ownership validation failed. The companionserver.jsonmanifest already declares the namespaceio.github.WenyuChiou/research-hub; this line is the cross-verifier. No version bump or release flow is triggered by this change — the next PyPI release (currently staged as v1.0.0) will be the first PyPI artifact carrying themcp-nameline, at which pointmcp-publisher publishcan succeed.
- PDF attach 0% regression from PR #108's requests → httpx port
(
zotero/pdf_attach.py). Therequests→httpxmigration in PR #108 usedhttpx.get()without overriding the default User-Agent (python-httpx/<ver>). MDPI, Frontiers, Springer-pdfdirect, IEEE, and other publishers' bot filters block that UA and return 403. Production E2E coverage crashed: a flood cluster got 0/15 OA PDFs attached, the Human-Nature cluster got 0/30. New_PDF_DOWNLOAD_HEADERSconstant sends a real Chrome-on-Windows UA +Accept: application/pdf,...so the publishers' filters let the download through. Live verification:_download_via_httpx_result(<Springer pdfdirect URL>)now returns status 200 + a 1.6 MB PDF (was 403 / 406 bytes of HTML). Regression test intests/test_v080_pdf_imported_file.pypins both that headers are passed AND that the UA looks like a real browser.
- Explicit sub-MOC override via
cluster.moc_links(vault/hub_overview.py,pipeline.py,vault/hub_backlink_migrate.py,cli.py). When a cluster'smoc_linksfield contains a name with a family prefix (LLM-Agents-*orWater-Resources-*), the auto-derived slug-based sub-MOC for THAT family is now suppressed — the user-provided name wins instead of being appended alongside. Use case: sluggenerative-ai-large-language-models-coupledauto-derivesLLM-Agents-Coupled, but the topic is really Human-Nature Systems; settingmoc_links: [LLM-Agents-HumanNature]inclusters.yamlnow yields[LLM-Agents-HumanNature, LLM-Agents]instead of also tacking onLLM-Agents-Coupled. Each family is independent: anLLM-Agents-*override does NOT suppress the Water-Resources auto sub-MOC, and vice-versa. Names that do NOT match a family prefix (e.g.MyCustomMOC) still pass through additively, as before. The override propagates through ALLderive_moc_linkscall sites — the cluster00_overview.md, MOC pages,_HOME.mdrendering, per-paper## Hubsection at ingest time (P1 fix: previouslypipeline._render_obsidian_notedid NOT passcluster.moc_links, so the overview/MOC honoured the override while every paper wikilinked to the slug-derived sub-MOC), andhub-backlink-migratebackfills. Regression testtest_explicit_override_propagates_to_paper_note_hub_sectionpins the pipeline call-site fix. - Two-level hub-and-spoke MOC graph (per-cluster sub-MOC)
(
vault/hub_overview.py). Every LLM/water cluster now links to BOTH a parent MOC (e.g.LLM-Agents) AND a per-cluster sub-MOC derived from the slug's distinctive tail (e.g.LLM-Agents-Flood,LLM-Agents-ConsumerBehavior,Water-Resources-DataPipeline). Without the sub-MOC, every LLM cluster collapsed onto a singleLLM-Agentsnode in Obsidian graph view; with it, each cluster has a distinct sub-hub between the parent and the paper notes — the cross-cluster centre stays, but each topic gets its own visible centre too. Two parser fixes along the way: hyphenated slug tokens likelarge-language-models-consumer-behaviornow correctly match"large language model"substring (haystack normalised-/_→ space), and generic possessive prefixes (my,our,new,old) are stripped somy-clusterproducesClusternotMyCluster. Existing clusters needresearch-hub vault rebuild-overviewsto backfill sub-MOC links into legacy paper-note frontmatter; new ingests get them automatically. - EZproxy support for paywalled PDF downloads (
ezproxy.py,cli.py,zotero/pdf_attach.py,pyproject.toml). Opt-in viacfg.ezproxy_url_template. Newresearch-hub ezproxy logincaptures institutional SSO cookies via Playwright;paper attach-pdfsthen wraps publisher URLs through the proxy and falls back to the direct URL on any proxy failure. Generic per-institution template — any university with an EZproxy gateway can configure it. Closes the IEEE/Wiley/Elsevier-via-paywall gap noted in the README's Known limitations. auto --year RANGEflag (cli.py,auto.py). The standalonesearch --yearfilter is now also exposed on theautosubcommand so users who want recent literature can writeauto "LLM × X" --year 2024-directly instead of running a separate search step. Same syntax assearch --year:2024-2025(closed range),2024-(from 2024),-2024(up to 2024). Wired end-to-end through_auto()→auto_pipeline(..., year_from, year_to)→_run_search()→search_papers(year_from, year_to). Unset = no year filter, behaviour unchanged.
-
LLM-judge fit-check uses a stricter rubric when the cluster topic is LLM-narrowed (
fit_check.py). For a cluster topic that explicitly mentions LLMs (any ofLLM/large language model/ChatGPT/GPT-N/generative AI/LLM agent/AI agent/agentic AI), the LLM-judge prompt switches from the default 0-5 rubric ("squarely about / on-topic adjacent angle / tangentially related…") to a stricter one anchored on how central the LLM is to the paper's contribution. Under the new rubric an ML/DL-without-LLM paper in the same parent domain scores 2 (instead of 4 under the old rubric), so the default--fit-check-threshold 4(PR #104) now actually filters those out. This addresses the empirical finding that runningauto "LLM × flood"was still letting through ~12/15 pure ML-flood papers — they were getting "on-topic adjacent angle (4)" from the generic rubric. Detection scans BOTH the cluster definition and the slug, so freshly-created clusters (where the definition is the slugified topic fallback) trigger the strict rubric correctly. Five new tests pin every LLM-narrowing token variant. -
NLM keepalive: real refresh via SDK public API + minute-cadence default (
notebooklm/keepalive.py,cli.py). The oldrotate_and_persist_sessionused the SDK's private_rotate_cookiespoke which returned success without verifying the session was still alive — every "ok" in the logs could have been a silent no-op against a revoked session, which is why the documented "持久一年" never actually held. Newrefresh_and_persist_sessioncalls the SDK's publicfetch_tokens_with_domainswhich GETs the NotebookLM homepage and extracts (csrf_token, session_id) as a side-effect of cookie rotation: if tokens come back, the cookies are observably good. The function returns a structuredRefreshResultwithbefore_metadata/after_metadata/changed(freshness-cookie expiry diff) so an operator can see PSIDTS actually moved forward. The old function name is retained as a thin bool shim for back-compat callers.keepalive_oncewas restructured to call refresh first (no pre-health gate — the Codex review of the old design flagged that as the source of permanently-stale sessions: a transient health-check error skipped the only refresh attempt). Default cadence flipped from hourly to 15 minutes (--interval21600 → 900 s, floor 3600 → 600 s;--interval-minutes6 → 15 with /SC MINUTE schtasks) — PSIDTS expires every ~3-4 hours, so hourly left only ~3 retries per expiry window and routinely lost races on flaky networks.--interval-hoursis kept as a deprecated alias multiplied to minutes at dispatch. Existing tests intests/test_v0950_nlm_keepalive_and_browser_login.pyrewrote classes A (refresh) and B (keepalive_once) for the new contract; classes C/D/E (CLI / from-browser) unchanged. Note: "持久一年" is achievable only when (a) the scheduled task runs every 15 min without interruption, (b) Google does not revoke the account on a security event, and (c) the long-livedSID/PSIDcookies (~1 year nominal) have not naturally expired — keepalive does not defeat any of those. -
auto --fit-check-thresholddefault raised 3 → 4 (cli.py). The LLM-judge fit-check now defaults to "clearly related" instead of "tangentially related and above". Rationale: at threshold 3 a "Large language models for X" cluster was consistently letting through pure ML/DL papers about X (no LLM at all) — the judge correctly scored them as 3 ("flood + AI, sort of related") and they passed. At threshold 4 the same papers are filtered out (still scored 3, now below the bar), cleaning the cluster to the actually-LLM-specific subset. Empirically on an "LLM × flood forecasting" run: kept dropped from 22/30 → 15/30, and the truly-LLM proportion rose from ~18% → ~20% (the LLM-judge is still generous on ML-flood papers; a sharper prompt is a follow-up). Use--fit-check-threshold 3to get the old lax behaviour. Python APIauto_pipeline(..., fit_check_threshold=3)deliberately stays at 3 for back-compat with programmatic callers and tests — the strict default is a CLI-UX choice, not an API contract change. -
auto --with-summaryis now ON by default (cli.py). After ingest, the per-paper Key Findings / Methodology / Relevance sections are filled via the detected LLM CLI (claude / codex / gemini) on every run; previously the--with-summaryflag had to be opted in explicitly. The flag usesargparse.BooleanOptionalAction, so--no-with-summaryis the new opt-out.--full-autono longer needs to re-set--with-summaryand was simplified accordingly, which incidentally lets--full-auto --no-with-summaryrespect the opt-out (silently overridden before). Python APIauto_pipeline(..., with_summary=False)deliberately stays opt-in (mirrors the--with-pdfsPR #90 split) so programmatic callers don't fire 20+ LLM CLI invocations per run silently. -
auto --with-pdfsis now ON by default (cli.py,auto.py). Theautosubcommand attaches open-access PDFs from arXiv/OpenAlex/Unpaywall/Crossref to the ingested Zotero items as part of every run; previously--with-pdfshad to be opted in explicitly. The flag usesargparse.BooleanOptionalAction, so--no-with-pdfsis the new opt-out for runs where you only want the NotebookLM bundle. Rationale: the bundle ladder already downloaded the PDFs for NLM but they never made it into Zotero, leaving thecluster/pdf_coveragedoctor check stuck at 0%. The Python APIauto_pipeline(..., with_pdfs=False)deliberately stays opt-in so programmatic callers (tests, library users) don't fire the PDF-attach network round-trips silently — the CLI hands in an explicitTruefrom argparse instead.--full-autono longer needs to re-set--with-pdfsand was simplified accordingly, which incidentally lets--full-auto --no-with-pdfsrespect the explicit opt-out (it was silently overridden before). Theingestandrunsubcommands stay opt-in (--with-pdfsonly) — they are lower-level entry points where an explicit flag still makes sense.
- Cluster overview LLM auto-fill now fires after
populate_overviewruns too (cluster_overview.py). PR #91 fixed_CHINESE_TEMPLATE_MARKERto match the Chinese scaffold (一到兩句話...) but in the realautoflow,populate_overview(vault/hub_overview.py) runs BEFOREapply_overviewand overwrites the TL;DR with the cluster's topic-string fallback ("LLM for flood forecasting..."). The Chinese marker then no longer matches andapply_overviewsilently classified the topic string as "hand-curated", skipping LLM enrichment. Net effect: everyauto-fresh cluster still ended with an empty TL;DR / Core Question / Scope. The scaffold check is now_is_scaffold_tldr(text, cluster_query, cluster_slug)which also recognises:- the English fallback
"No cluster summary available yet."(from_render_tldr) - exact match against the cluster's
first_query(the topic-string fallback_overview_tldrwrites when there's no NLM brief yet) - exact match (case-insensitive) against the slug humanised (the
last-resort fallback in
_overview_tldr) Two regression tests pin the new branches.
- the English fallback
-
brief_to_docx.jsships insideskills/research-design-helper/scripts/(plugin0.3.13 → 0.3.14). Sister script toskills/gap-to-topic/scripts/dossier_to_docx.js— same Markdown → Word converter (heading styles, bullet lists via numbering reference, tables with dual-width DXA, en / zh-TW font auto-select, Markdown separator-row skip), but default stem isdesign_briefinstead oftopic_dossier. Makes the Stage 3a artifact shareable with advisors / committee members who prefer Word over Markdown.- Not part of the contracted Stage 3a output.
design_brief.mdremains the canonical artifact; the.docxis an optional convenience for human consumption only. Downstream skills (Stage 3bresearch-context-compressor) read the.mdfrontmatter + section 1 directly, not the.docx. - Verdict-colour regex inherited verbatim but does NOT fire on design brief content — no "Do not pursue" / "Not assessed" / "不予推進" / "未評估" strings appear in a design brief. Keeping the regex unchanged means a single fix to the dossier generator lands in both scripts via parallel PRs.
- SKILL.md adds
## Generate .docx (optional, plugin v0.3.14+)section between## Outputand## Token-saving behavior, mirroring the gap-to-topic§4.5 Generate .docxpattern. scripts/README.mdships alongside, documenting prereq (npm install -g docx), invocation examples (en + zh-TW), and the design-brief-specific deviation (the regex no-op).- New test
test_design_helper_has_brief_to_docx_script_and_skill_md_sectionassertsscripts/brief_to_docx.jsexists, the SKILL.md has the new## Generate .docx (optionalheading, and the script's default stem isdesign_briefnottopic_dossier. - Mirrored to
src/research_hub/skills_data/.
- Not part of the contracted Stage 3a output.
-
Stage 2 → 3a → 3b handoff wiring (
skills/research-design-helper/,skills/research-context-compressor/,tests/fixtures/topic_dossier_sample.gaps.yml,tests/test_handoff_gap_to_topic_design_helper.py, plugin0.3.11 → 0.3.12). Two broken wires fixed as one coherent user-facing capability:research-design-helperreads.research/topic_dossier.gaps.ymlas new Input #2. New Workflow §0 preamble auto-pre-fills segment 1 (RQ) from the chosengaps[].statementand segment 5 (risks) fromopen_questions[]+ the specific concern hinted bygaps[].feasibility. Segments 2–4 (mechanism / identifiability / validation) are never pre-filled — the dossier doesn't carry that material and pre-filling with non-content corrupts the Socratic dialog. When the upstream.gaps.ymlis absent, the skill behaves exactly as before (no regression for standalone users). Candidate selection is verdict-aware: filtergaps[]toverdict in {conditional-go, go}, then auto-pick if one, ask-the-user if 2+, halt with "nothing to frame" if zero.design_brief.mdfrontmatter carries Stage 2 provenance. Two new optional fields:source: topic_dossier.gaps.yml#<gap-id>(URI-fragment pointer to the chosen gap) andgap_verdict:(frozen snapshot ofverdict+ first 60 chars ofverdict_reason). The brief becomes self-contained — a future reader sees which dossier candidate this design was framed for. Provenance-protection: a refresh that would change thesourcegap-id triggers a confirm prompt instead of silent overwrite.research-context-compressorreads.research/design_brief.mdas new Input #2 under "For any project". Reads the frontmatter (project,source,gap_verdict) + section 1 (Research question) only. This implements the long-claimedpipeline.mdcontract; the brief is the authority on the sharpened RQ and the manifest mirrors it. If frontmattersourceis set, copies that gap-id into the manifest'sprovenance.from_gapfield (forward-compat with downstream tools).- First cross-skill handoff integration test ships at
tests/test_handoff_gap_to_topic_design_helper.pywith a frozen fixture attests/fixtures/topic_dossier_sample.gaps.yml(copied from the v0.3.10 dogfood example). Asserts: fixture parses + has v0.3.10+ top-level keys +recall.screensub-block + per-gap downstream-consumer fields +open_questions[].text; downstream SKILL.md prose lists the handoff input + has the §0 preamble + preserves the absent-.gaps.ymlfallback; design_brief_template frontmatter hassource+gap_verdict; research-context-compressor mentionsdesign_brief.md; the schema reference covers every key the fixture contains (drift detector). Subsequent stage-to-stage wires (3a → 3b proper, 6 → 7, …) should follow this test shape. - Mirrored to
src/research_hub/skills_data/. Pure additive change — no removed inputs, no breaking behaviour, no required new fields. Users without a.gaps.ymlsee identical UX to v0.3.11.
-
dossier_to_docx.jsships insideskills/gap-to-topic/scripts/(plugin0.3.9 → 0.3.10).topic_dossier.docxis now a first-class contracted deliverable of thegap-to-topicskill. The generator convertstopic_dossier.mdto a styled Word document usingdocx9.x: heading styles (H1/H2 navy, H3 dark grey), bullet lists via numbering reference, tables with dual-width DXA column sizing, bilingual verdict colour coding (light red "Do not pursue" / "不予推進", light yellow conditional "Worth pursuing … only if" / "值得推進…條件", light green unconditional, light grey "Not assessed" / "未評估"), Markdown separator-row skip, and an optional TOC + page break after the first table (--no-tocto suppress). Font auto-selects: filename matching.zh|zh-|zh_|-tw|-cn→ Microsoft JhengHei; else Arial. Prerequisite:npm install -g docx. SKILL.md updated with §4.5 "Generate .docx" workflow step; front-matter description and "What it produces" updated to list.docxas a first-class artifact. Mirrored tosrc/research_hub/skills_data/gap-to-topic/scripts/.
-
paper-memory-builderdoc enhancements — F-cross2 figures.yml sentinel + F-cross3 evidence-artifact scanning (skills/paper-memory-builder/SKILL.md,skills/paper-memory-builder/references/yaml-schemas.md,tests/test_handoff_gap_to_topic_design_helper.py, plugin0.3.15 → 0.3.16). Two small doc improvements surfaced by the Stage 7-8 dogfood (~/.claude/audits/dogfood_runs/2026-05-23-paper-memory-academic-writing-stage-7-8/VERIFICATION.md):- F-cross2 — figures.yml
file:sentinel values. The schema requiresfigures[].file(required field), but real-world Word-based research workflows often have figures embedded directly inside the.docxmanuscript with no separable source file. Adds a### file: sentinel values (v0.3.16+)section toreferences/yaml-schemas.mddocumenting three sentinels:embedded-in-manuscript,embedded-in-supporting-information,embedded-in-presentation. Downstream consumers (academic-writing-skills figure-text checks, future figure-archive tooling) treat these as "present in manuscript, no separable artifact to verify independently." Documented limitation, not a permanent solution — future versions may add a pre-processing step that extracts embedded figures. - F-cross3 — SKILL.md "Scanning the paper repo for evidence
artifacts" sub-section. The previous Inputs section only
described figures + manifest files but real research repos
contain many non-figure evidence artifacts (simulation CSVs,
analysis scripts, drawio sources, reviewer-response artifacts).
The new sub-section documents typical artifact types and shows
an example of populating
claims[].evidence_artifactswith artifact PATHs for non-figure evidence (e.g. a chi-squared test claim might cite anoutputs/llm-abm_decision_log.csv+ the analysis script that ran it, in addition to the manuscript anchor). Makes the audit trail end-to-end traceable. - Tests: added 2 new test cases to
test_handoff_gap_to_topic_design_helper.py: (1)test_paper_memory_yaml_schemas_documents_file_sentinel_valuesasserts all 3 sentinels appear inyaml-schemas.md; (2)test_paper_memory_skill_md_documents_evidence_artifact_scanningasserts the SKILL.md Inputs section has the new sub-section AND mentions at least 3 non-figure artifact types (Simulation, Analysis scripts, Drawio). - Pure additive prose change. Mirrored to
src/research_hub/skills_data/paper-memory-builder/.
- F-cross2 — figures.yml
-
Codex review tightenings — multi-eligible fixture + design_brief placeholder marker (
tests/fixtures/topic_dossier_multi_eligible_sample.gaps.yml,skills/research-design-helper/references/design_brief_template.md,skills/research-design-helper/SKILL.md,tests/test_handoff_gap_to_topic_design_helper.py, plugin0.3.14 → 0.3.15). Two follow-ups from the independent Codex evaluation of the v0.3.12 + v0.3.13 + v0.3.14 deliverables (.ai/codex_eval_report.md, verdict ship-with-fixes):- C2 — multi-eligible fixture. The §0 "2+ candidates, ask the
user" branch was documented in SKILL.md and verified by the
branch-coverage prose test, but never exercised against a real
fixture with multiple
verdict ∈ {conditional-go, go}entries. New synthetic fixturetests/fixtures/topic_dossier_multi_eligible_sample.gaps.ymlships 3 gaps (G1 conditional-go, G2 conditional-go, G3 no-go) so the filter produces 2 eligibles. Two new tests: (a)test_multi_eligible_fixture_parses_and_has_2plus_go_eligibleasserts the fixture's filter result keeps the 2+ branch exercised (guards against fixture decay), and (b) a parametrizedtest_fixture_parses_and_drives_correct_section_0_branchruns across both fixtures asserting each drives its expected §0 branch (auto-pre-fill vs ask-the-user). A future third fixture for the zero-eligible halt branch can be added with one tuple. - C4 — design_brief placeholder marker. The v0.3.12 dogfood
filled segments 2–4 with test-fit placeholder content (not from
real Socratic dialog) and used an ad-hoc
_TEST-FIT-PLACEHOLDER_inline tag. Codified as a structured frontmatter field:placeholder_segments: [](list of segment numbers). Example:placeholder_segments: [2, 3, 4]means segments 2–4 are placeholders, downstream tools should refuse to gate real research on a brief with non-empty list. SKILL.md adds a "Placeholder marker (v0.3.15+)" paragraph in the Output section explaining when to use it. New testtest_design_brief_template_has_placeholder_segments_fieldasserts the frontmatter accepts the field. - Pure additive: no removed fields, no changed enum tokens, no
behaviour change on absent/empty placeholder list. Mirrored to
src/research_hub/skills_data/.
- C2 — multi-eligible fixture. The §0 "2+ candidates, ask the
user" branch was documented in SKILL.md and verified by the
branch-coverage prose test, but never exercised against a real
fixture with multiple
-
research-context-compressorOutput spec showsprovenance.from_gap;research-design-helper§0 forbids in-file pre-fill annotations (skills/research-context-compressor/SKILL.md,skills/research-design-helper/SKILL.md,tests/test_handoff_gap_to_topic_design_helper.py, plugin0.3.12 → 0.3.13). Two minor SKILL.md prose tightenings surfaced by the v0.3.12 Stage 3a + 3b dogfood (~/.claude/audits/dogfood_runs/2026-05-22-research-design-helper-llm-abm-socio-hydrology/):- F1 — compressor Output example now shows
provenance.from_gap. The field was registered indocs/research-workspace-manifest.md(PR #95) and mentioned in the compressor## Inputssection (PR #96), but the## Outputs you must producesection had no example — a prose-driven skill is at risk of agents missing the wire. v0.3.13 adds an explicit example block under Outputs showing theprovenance: { from_gap: ... }shape and codifies the absent-state rule (omit the block when there's no upstream; do NOT writeprovenance: {}ornull). Provenance-protection on refresh now also explicitly applies tomanifest.provenance.from_gap(mirrors the v0.3.12 rule inresearch-design-helper). - F2 — §0 forbids
_PRE-FILL_-style annotations indesign_brief.mdcontent. The v0.3.12 §0 wrotegaps[].statementinto segment 1 but didn't say HOW to format it. The dogfood agent added a_PRE-FILL (review/sharpen): ..._annotation that wouldn't get cleaned up after segment 1 dialog sharpened the RQ. v0.3.13 clarifies: write the statement verbatim, no annotation in the file; the chat-level message flags it as pre-fill. Means segment 1 dialog can simply overwrite the statement with the sharpened RQ — no cleanup step needed. - Test coverage: added 2 new test cases to
test_handoff_gap_to_topic_design_helper.py(14 → 16 total) asserting (1) the compressor Outputs section mentionsprovenanceANDfrom_gap, (2) the §0 segment-1 pre-fill rule explicitly forbids_PRE-FILL_annotations in file content (negation regex, not just word-presence — guards against rewrites that drop the prohibition). - Pure additive prose change (no removed inputs, no schema
changes, no behaviour change on absent upstream). Mirrored to
src/research_hub/skills_data/.
- F1 — compressor Output example now shows
-
gap-to-topicschema reference + research-hub discoverability (skills/gap-to-topic/references/dossier-template.md,docs/ai-research-skills.md, plugin0.3.10 → 0.3.11). Two contract-vs-reality fixes ahead of the Stage 2 → 3a handoff wiring:- Schema reference refresh. The
topic_dossier.gaps.ymlschema inreferences/dossier-template.mdwas stale relative to whatgap-to-topicactually emits since v0.3.6 (the--screenfit-check integration) and v0.3.9 (the 7-section reflow): missing top-levelrun_type/recall(with fullscreensub-block) /pipeline; missing per-gapopen_confidence/dead_end_evidence/borderline_reason/verdict/verdict_reason. The schema is now byte-aligned with the real output and adds a top-leveldownstream_consumer: research-design-helperkey as a forward-compat hook recording the contract reader. - Discoverability.
docs/ai-research-skills.mdhad zero mentions ofgap-to-topicdespite the skill being shipped at v0.3.10. Added a new Stage 2.5 row to the Stages table, a "Deciding whether a research gap is worth pursuing" entry to the When-to-use table, and a full### gap-to-topic (v0.3.11)section in All packaged skills documenting Reads / Writes / handoff to Stage 3a / trigger phrases. - Pure additive changes (no removed fields, no enum tokens changed).
Mirror at
src/research_hub/skills_data/gap-to-topic/updated in parity.
- Schema reference refresh. The
-
gap-to-topicdossier reflowed as a 7-section research-grade decision memo (skills/gap-to-topic/, plugin0.3.8 → 0.3.9). The v0.3.8 dossier rendered as "Markdown converted to Word" — wide scorecard with one-letter-wide columns, no decision-level visual cue, too many tables in the body.references/dossier-template.mdis reorganised: §1 Executive Decision Summary (metadata + verdict cards + key uncertainty) → §2 Candidate Definitions (prose, no roster table) → §3 Decision Scorecards (per-candidate small tables, replacing the wide combined one) → §4 Evidence Base → §5 Gate-by-Gate Assessment (each gate uses a fixed Score / Evidence / Interpretation / Risk / Action-needed skeleton) → §6 Risks and Upgrade/Kill Tests (named risks; operational kill-test artifacts; salvage path) → §7 Recommended Next Steps (formal research-memo prose) → Appendix A Search and Screening Protocol (reproducibility log) → Appendix B Deliverable File List. SKILL.md "What it produces" updated to match. Mirrored tosrc/research_hub/skills_data/gap-to-topic/. -
Cluster overview LLM auto-fill now actually fires on fresh clusters (
cluster_overview.py,vault/hub_overview.py,tests/test_v071_cluster_overview.py,tests/test_hub_overview.py). The_CHINESE_TEMPLATE_MARKERconstant incluster_overview.pyand the_SCAFFOLD_MARKERStuple invault/hub_overview.pyboth stored mojibake from a historical cp950 ↔ UTF-8 round-trip, so they never matched the actual Chinese placeholder phrasestopic.pywrites for a new cluster (一到兩句話說清楚...for TL;DR,用一句話寫下...for 核心問題). Net effect: every brand-new cluster was immediately classified as "hand-curated",apply_overviewsilently refused to call the LLM, and the siblingpopulate_overviewpath likewise refused to refresh those sections — leaving the00_overview.mdstuck at the empty scaffolding (TL;DR / 核心問題 / 範圍定義 / 領域地圖 / 必讀論文 / 時間線 / 開放問題 all blank). The markers are now the real opening phrases (一到兩句話,用一句話寫下); the legacy mojibake markers stay so vaults whose existing00_overview.mdstill contains the corrupted bytes are still recognised and refreshed cleanly. The test fixture_template_textwas likewise mojibaked and now mirrorstopic.pyverbatim, plus two dedicated regression tests (one per code path) pin the marker ↔ template round-trip so this can't drift silently again. -
gap-to-topicdossier — section-by-section rework for researcher value (skills/gap-to-topic/, plugin0.3.7 → 0.3.8). A section-by-section review with the maintainer, plus a Codex evaluation, reworkedreferences/dossier-template.md:- Decision scorecard is now a 5-point Likert per gate — each gate is
a claim rated 1–5 (strongly agree … strongly disagree); the cell is
N/5 label — description; a topic is worth pursuing only if every gate rates 3 or higher. - Gate 2 gains "What it would contribute" (one explicit claim) and a "Minimum validation sketch"; a caution paper is summarised by the kind of risk it raises, not offloaded to the reader.
- Gate 3 gains "Design feasibility", a "Scale outline", and an explicit proposal-vs-dissertation feasibility split — it is no longer only a data-availability test.
- The decision is yours is framed consistently as a conditional recommendation; the upgrade/kill test is written as concrete artifacts; a failed candidate gets a salvage-path line.
- Appendix A is recast from a tool log into a method note (search scope, inclusion criteria, automated-vs-judgement).
- What's in this deliverable gains a file tree.
SKILL.md "What it produces" updated. Mirrored to
src/research_hub/skills_data/gap-to-topic/.
- Decision scorecard is now a 5-point Likert per gate — each gate is
a claim rated 1–5 (strongly agree … strongly disagree); the cell is
-
gap-to-topicdossier reworked to read as a plain summary report (skills/gap-to-topic/, plugin0.3.6 → 0.3.7). A review found the dossier still did not read cleanly in Word: the candidate roster showed anidcolumn ofG1/G2codes; the Decision scorecard used✓ ✗ ~glyphs and the jargon verdicts "No-go / Conditional go / Go"; there was no index of the bundle's files.references/dossier-template.mdnow: drops theidcolumn (theG1/G2ids stay only in.gaps.yml) and states "why it could be a gap" in plain words; drops the scorecard glyphs for plain words; replaces the verdict jargon with plain phrasing ("Do not pursue — as stated" / "Worth pursuing — only if its open conditions hold" / "Worth pursuing"); adds a "What's in this deliverable" index section; gives the Bottom line one plain paragraph per candidate. The.gaps.ymlschema moved to a trailing "not emitted" reference block. SKILL.md "What it produces" updated. Mirrored tosrc/research_hub/skills_data/gap-to-topic/. -
ingest collapses in-batch duplicate papers (
pipeline.py). Two search backends can return the SAME paper under different DOIs (a journal DOI vs a repository/preprint DOI). DOI-keyed search-merge dedup keeps both, anddedup.check()cannot catch them either —dedup.add()runs only in the note-writing loop, so in-batch siblings stay invisible during Zotero-item creation. Result: two Zotero items for one paper and two notes colliding on an identical filename slug (one silently overwriting the other). A new pre-ingest in-batch dedup pass now collapses candidates sharing a normalized DOI or normalized title (mirrorsDedupIndex.add()'s >15-char title guard), keeping the first occurrence and logging adup-in-batchmanifest entry for each collapsed sibling. -
gap-to-topic§1 now applies the fit-check relevance gate (skills/gap-to-topic/, plugin0.3.5 → 0.3.6).search --screen(PR #84) wired the BM25 relevance gate into thesearchcommand; §1 now uses it. SKILL.md §1: step 1 runssearch --adversarial --screen --jsonand reads the new{screening_summary, results}shape (each paper carries arelevancefield); steps 2-3 build the matrix and.bibfrom the on-topic results only (kept: true), excluding screened-out off-topic noise; step 4 reports the retrieved-vs-on-topic counts in the recall headline.references/dossier-template.mdAppendix A pipeline + recall-mechanics rows updated. Mirrored tosrc/research_hub/skills_data/gap-to-topic/. -
ingest dedup re-scoped to research-hub's own literature (
pipeline.py).autofor a new topic produced a near-empty cluster — a search returned 25 papers, fit-check kept all 25, yet only ~1 Obsidian note was created. Cause: the dedup index was dominated bysource:"zotero"entries mirroring the user's ENTIRE Zotero library (mostly never managed by research-hub) plus stalesource:"obsidian"entries pointing at deleted note files. The dedup block treated any paper merely present in the Zotero library as a duplicate andcontinue-d past Obsidian-note creation, dropping it from the new cluster. Dedup now skips a paper only when it duplicates research-hub's OWN literature: (1) theobsidian_hitbranch requires the note file to actually exist on disk — a stale index entry no longer blocks ingest; (2) thezotero_hitbranch no longer skips — it still reuses the existing Zotero item (moves it into the cluster collection, adds hub tags + note, creates no duplicate item) but the paper is now ingested into research-hub with its Obsidian note created and bound to the cluster. New manifest actioningest-reuse-zoterorecords the reuse decision. -
fit-check no-LLM relevance gate rewritten (
fit_check.py,auto.py). The oldno_llm_fit_checkgate split the topic into independent unigrams and kept any paper matching>= 0.1of them — i.e. 1 of 5 words. A generic hydrology paper trivially matchedwater/model/resourcesand passed while the discriminating phrase "large language model" was destroyed; thellm-water-resourcescluster ended up 38/43 off-topic. The gate is rebuilt as a pure-Python BM25 scorer over 1–3-gram topic terms — phrases like "large language model" survive intact, matching is plural-tolerant, and IDF is self-calibrated on the candidate batch. A paper is rejected only when the batch's sorted BM25 scores show a blatant bimodal split — a gap whose upper cluster out-scores the lower by ≥ 5× (the cross-field contamination signature: pure-hydrology papers score ~1 vs ~8 for genuine LLM papers). A focused, all-relevant search spreads only ~2× and is kept whole; cold-start (batch < 5, no topic terms, or no clear gap) defers — keeps all, flagsrelevance_unverified. Recall-biased: the no-LLM tier only catches blatant contamination; fine-grained relevance screening is the LLM-judge tier's job. New public API:extract_topic_terms,bm25_scores,screen_relevance. Grounded in a research sweep of ASReview (TF-IDF + Naive Bayes) and BM25 screening practice. -
gap-to-topicdossier — evidence-strength tags + an upgrade/kill test (skills/gap-to-topic/, plugin0.3.4 → 0.3.5). A Codex evaluation of a real dossier found two gaps: (1) Gate 1 said a gap was "densely populated" but the reader had to openliterature_matrix.mdto see the occupancy signal was partly conference abstracts / artifacts, not primary studies; (2) a "conditional go" listed open conditions but no threshold — the advisor still had to supply the kill/upgrade logic.references/dossier-template.mdnow: Gate 1 tags each cited work by evidence type (primary study / review / close analogue / caution paper / conference abstract / preprint / data artifact) and ends with a one-sentence evidence-mix summary; "The decision is yours" carries an explicit upgrade / kill test per conditional-go candidate. Mirrored tosrc/research_hub/skills_data/gap-to-topic/. -
gap-to-topicdossier — added tables for scannability (skills/gap-to-topic/, plugin0.3.3 → 0.3.4). Follow-up to the reader-first redesign: the verdicts were still spread across prose bullets in each gate section.references/dossier-template.mdnow puts a Decision scorecard table in the Bottom line (candidates × the 3 gates- verdict, with a
✓/✗/~/—cell convention), a roster table in The candidates, and turns Appendix A (how it was produced) and Appendix B (companion files) into tables. Gate 1–3 bodies keep prose — the verdicts now live once, in the scorecard, so the gate sections carry only the evidence (no duplication). SKILL.md "What it produces" updated. Mirrored tosrc/research_hub/skills_data/gap-to-topic/.
- verdict, with a
-
gap-to-topicdossier was organised tool-first and code-first, not reader-first (skills/gap-to-topic/, plugin0.3.2 → 0.3.3). A review of a real dossier found it opened with a metadata table of pipeline / API rows, wovesearch --adversarial/literature-triage-matrix/ plugin versions through the body, and labelled candidates with bare[G1]/[G2]codes a researcher cannot decode.references/dossier-template.mdis redesigned reader-first: a plain-language Bottom line leads; each candidate gets a readable name (theG1id demoted to a.gaps.ymltag); each gate states its verdict in plain words before the evidence; all tool / pipeline / recall mechanics move to Appendix A, companion-file notes to Appendix B. SKILL.md "What it produces" + the §0 step updated to match;.gaps.ymlschema gains aname:field. Mirrored tosrc/research_hub/skills_data/gap-to-topic/. -
gap-to-topic§1 namedliterature-triage-matrixas the default prior-art tool but no step produced its matrix (skills/gap-to-topic/, plugin0.3.1 → 0.3.2). The SKILL.md "orchestrates" paragraph +Inputs- the §2 component input-contracts all referenced
.research/literature_matrix.mdas an available input, yet the §1 numbered procedure only ransearch— so an agent following §1 literally never built the matrix (a "phantom input"; surfaced by a 2026-05-21 workflow audit). §1 now has an explicit step 2 that feeds thesearch --adversarial --jsonresults toliterature-triage-matrixto produce the matrix;.biband recall are renumbered to steps 3-4.references/dossier-template.mdPipeline line updated. Mirrored tosrc/research_hub/skills_data/gap-to-topic/.
- the §2 component input-contracts all referenced
-
gap-to-topic§1.bibinstruction was unworkable (skills/gap-to-topic/, plugin0.3.0 → 0.3.1). SKILL.md §1 step 2 andreferences/dossier-template.mdtold the skill to emit the §1 reference list viacite --format bibtex, butciteresolves identifiers only against an already-ingested Zotero library — at topic-selection time the candidate papers are not ingested, socitereturns "Could not resolve identifier" for every §1 DOI. Surfaced by the 2026-05-21 dogfood run. The.bibis now built from thesearch --adversarial --jsonmetadata, which the skill already has in hand at §1. Mirrored tosrc/research_hub/skills_data/gap-to-topic/; plugin version bumped so the marketplace cache invalidates. -
notebooklm login --auto-detectnow works (notebooklm/auth.py). The pre-fix implementation shelled out to the upstreamnotebooklm loginsubprocess and polled the patchright Chromium profile's on-disk Cookies SQLite for anotebooklm.google.comrow. Chromium buffers cookies in memory and flushes them to that store only on a lazy timer, so a freshly-signed-in session stayed invisible on disk for minutes — the poll loop always timed out without ever firing the save.--auto-detectnow drives the Chromium browser directly via Playwright (same stealth flags the SDK uses) and polls the livepage.url: the moment the page settles on the NotebookLM host — and holds there for 3 consecutive polls, so a mid-redirect flash never triggers a premature save — the session is captured straight from the live browser context viastorage_state. No terminal ENTER, no subprocess, no disk-cookie race. Fail-closed: a timeout, a browser-launch error, or a driver-start failure all return non-zero without saving and never raise into the CLI. Removed the dead disk-polling helpers (_patchright_cookies_db,_has_notebooklm_cookie,_cookies_db_modified_since). -
probe_cleared_failed_no_abstractURL quality signal now triggers text fallback in the NLM bundle builder (notebooklm/bundle.py). Springer / Wiley paywall skeleton pages return HTTP 200 with no body; the URL quality probe previously rated thisok, so the URL was uploaded to NotebookLM which also received an empty shell. The fix treats this reason code the same aslikely_error_page— falling back to the abstract as a copied-text source, or skipping if no abstract is available. -
Zotero reparent on cluster reuse (
auto.py). Clusters created before the parent-collection feature was added lived at the Zotero library root. On subsequentautoruns the pipeline found them by name and returned early, never callingensure_parent_collection. New helper_maybe_reparent_collectionPATCHes the collection'sparentCollectionto the configured parent (default"research-hub") when it is currently top-level (parentCollection=False). Idempotent and best-effort. -
PDF auto-attach step in
auto_pipeline(auto.py). When--with-pdfsis passed, the pipeline now also calls the OA-PDF attachment chain (OpenAlex / Unpaywall / arXiv) for every Zotero item in the cluster after ingest, attaching the PDF as animported_filechild item. Previously--with-pdfsonly fed PDFs to the NLM bundle, not Zotero. -
Summarize pending hint in
auto_pipelinenext-steps output. After a run, if any papers in the cluster havesummarize_status: pending, the terminal output now prints a[HINT]line with the exact command to run (paper summarize --pending --cluster <slug>).
-
search --screen— fit-check BM25 relevance gate on the search command (cli.py). The BM25 relevance gate (screen_relevance/bm25_scores, from PRs #79/#81/#83) was only applied by theautoingest pipeline; the standalonesearchcommand returned unscreened, potentially off-topic results.search --screennow runs that same gate over the retrieved papers: each result is tagged with a relevance score + a keep / screened-out verdict, and a screening summary (N retrieved → M kept, K screened out) is printed to stderr. It is recall-preserving — no paper is dropped from the output, so a caller can still audit the full retrieved count.--jsonswitches to a{"screening_summary": {...}, "results": [...]}object (each result carrying arelevanceblock) when--screenis set; without--screenthe output is byte-identical to before (bare array). Composable with--adversarial/--max-variants/--rank-by(orthogonal to ranking —--screenonly annotates). Reuses the existing gate; no new scorer. -
gap-to-topicskill — a topic-decision dossier. New 11th packaged skill. Choosing a thesis/proposal topic is a go/no-go decision, not a literature review;gap-to-topicproduces a.research/topic_dossier.mdthat runs a candidate breakthrough point through a 3-gate AND test — ① is the gap open (adversarial-recall search + a verifiable.bib), ② is it a contribution (dead-end history + problem-solving/incremental typing), ③ is it feasible (data/resource accessibility) — and hands the final "is it worth doing" verdict back to the researcher.skills/gap-to-topic/(SKILL.md + 3 references + evals) mirrored toskills_data/;.claude-plugin/plugin.json10→11 skills, version0.2.0 → 0.3.0. Theresearch-design-helperandliterature-triage-matrixdescriptions gain a one-line boundary clause routing topic-selection prompts togap-to-topic. -
NLM session pre-flight in
auto_pipeline. Before attempting any NotebookLM browser work,auto_pipelinenow callscheck_session_healthon the storedstate.json. When the session is missing or expired the pipeline skips NLM gracefully, setsnlm_deferred=True, and prints a[HINT]pointing atresearch-hub notebooklm login— instead of running a full headless browser session that would crash with an opaque Playwright error. The_print_next_stepsfooter now distinguishes "session expired → login first" from "transient error → retry". Health-check errors are caught so existing behaviour is preserved when the auth module is unavailable. -
Adversarial-recall search —
research-hub search --adversarial. A single query phrasing systematically misses papers that use other vocabulary; in topic-scoping search a missed paper makes a research gap look open when it is not.--adversarialexpands the query into several phrasings (LLM-generated when an LLM CLI is on PATH, deterministic fallback otherwise), searches each, unions the results bydedup_key, and prints a recall-confidence verdict (high/medium/low, derived from query saturation) to stderr. Newsearch/query_expansion.py;adversarial_search()+RecallReportadded tosearch/fallback.py;tests/test_search_adversarial.py(18 cases). -
Summary quality improvements (4 changes):
link_updater.find_related_in_clustercapped at 10 results (was unbounded) — prevents mega-hub nodes in the Obsidian graph for large clusters.remove_paper_links(slug, raw_dir, cluster)— new function scrubs a deleted paper's slug from every sibling note's Related Papers section; removes the entire section header when the deleted slug was the last entry.remove_paper()now cascades the cleanup automatically and returnslinks_cleanedin its result dict.dry_run=Trueis respected.paper_summarizeRELEVANCE prompt updated to require a specific dimension (method / empirical context / finding) rather than accepting a generic "this paper is relevant to [cluster]" sentence.crystal emitprompt now exposes afirst_findingfield per paper (first Key Findings bullet, callout-format aware) to give the LLM more signal per paper.
-
docs/literature-review-deliverable.md— format specification. Defines the consolidated document the skill pipeline (search→literature-triage-matrix→research-design-helper) produces end to end: the fixed 9-section contract, the.bib+.gaps.ymlcompanion-file schemas, the per-paper summarization contract (and how it relates to thepaper-summarizeskill), honesty rules, and the bilingual / Markdown+Word bundle convention. A fully synthetic worked example ships in theai-research-skillscatalog repo. -
CI
skill-version-guardjob (.github/workflows/ci.yml). Blocks a PR that changes skill content (skills/orsrc/research_hub/skills_data/) without also bumping.claude-plugin/plugin.jsonversion. The marketplace plugin cache directory is keyed on the version string, so an un-bumped skill-content change ships tomasterbut never reaches user installs. A 2026-05-21 dogfood verification (finding V1b) caught exactly this — a SKILL.md change merged without a version bump and silently failed to propagate. This guard makes that class of bug a hard CI failure at PR time. -
--peer-reviewedflag onsearchandauto. Drops preprint backends (arXiv/bioRxiv/chemRxiv/medRxiv), excludes gray doc types (preprint/posted-content/report/book-chapter/paratext/dataset), and floors corroboration. Closes the gap whereautoran search with zero filtering and the one-shot pipeline could not express "peer-reviewed only" (gray literature silently entered the vault). -
doctorchecknlm_auth_paths. Reports which NotebookLM re-authentication paths actually work on this machine (interactive vs--from-browser/rookiepy) and the exact command to run. -
paper-memory-builderanti-leakage rule + E4 triad. New JSON Schema atskills/paper-memory-builder/references/claims.schema.jsonenforces: a claim with empty / absentevidence_artifactsMUST havestatus: gapand a non-emptygap_reason. Two new files alongside:scripts/check_claims_schema.py(validator with JSON-pointer-style error paths) andtests/test_check_claims_schema.py(14 cases — 1 meta, 4 positive, 5 negative, 4 schema-shape guardrails). The binding contract is restated inskills/paper-memory-builder/SKILL.md§"Anti-leakage rule" so it survives the SKILL.md-only marketplace install sync. Status enum extended:draft | supported | rejected | gap. Closes the long-standing Phase 2 Task B1 + E4 backlog item from theWenyuChiou/ai-research-skillsplan. -
tests/test_skills_data_parity.py(21 cases) — guards theskills/↔src/research_hub/skills_data/byte-parity invariant.research_hub.skill_installercopies skills fromskills_data/, so editing a SKILL.md inskills/only ships the change to public-repo readers, not toresearch-hub installusers. This test catches the divergence at PR time. Exception list (SHADOW_ONLY_IN_SKILLS_TREE) documents intentional shadows; one entry today (zotero-skillsvendored copy, scheduled for removal in Phase 2 Wave C). -
Backup-first callout in
zotero-library-curator. SKILL.md §"Output discipline" +references/report-template.mdnow lead the "Suggested follow-ups" section with a Zotero-RDF backup reminder before any apply/CRUD handoff tozotero-skillsorresearch-hub zotero ... --apply. Closes the gap surfaced by theai-research-skillsTask #27 dogfood walk: read-only audit + apply step are different skills, and the callout was only on the marketplace README — never echoed to the user at handoff time.
research-workspaceplugin version bumped0.1.0→0.2.0(.claude-plugin/plugin.json). The marketplace plugin cache is keyed on this version; a dogfood test on 2026-05-20 confirmed freshclaude plugin installusers were still receiving the pre-Phase-7 cached0.1.0skill bundle — i.e. thepaper-memory-builderanti-leakage rule (Phase 7 Wave A) and thezotero-skillsshadow removal (Wave C) had shipped tomasterbut not to user installs. Bumping the plugin version forces a fresh cache directory so those changes actually propagate. No skill behavior changed by the bump itself.paper-summarizeandliterature-triage-matrixdescriptions gain an "extract claims" disambiguation clause. The same dogfood test surfaced a fragile auto-trigger boundary: a user saying "extract claims from these papers" could land atpaper-memory-builder(own draft),paper-summarize(per-cited-paper), orliterature-triage-matrix(cross-paper matrix). Both descriptions now state the disambiguation explicitly so the router picks correctly.
- Vendored
skills/zotero-skills/shadow (Phase 7 Wave C). The 308-line vendored copy of the standalonezotero-skillsplugin has been deleted; the canonical 60-line skill atWenyuChiou/zotero-skillsis now the single source of truth. Coupled site updates landing in the same commit:skill_installer.pydrops itszotero-skillsexclusion (the dir no longer exists to filter),tests/test_skills_data_parity.pyemptiesSHADOW_ONLY_IN_SKILLS_TREE,tests/test_v068_3_version_sync.pyandtests/test_v066_skill_schema.pydrop the now-stale exclusion comments / dead constants, anddocs/interop-test-v068-2.mdnotes the historical snapshot vs the post-Wave-C state. Callers that resolveSkill(skill="zotero-skills")by bare name will now route to the canonical standalone plugin once that marketplace install exists in their environment. Users who relied on the vendored copy without separately installingWenyuChiou/zotero-skillswill get a skill-not-found error until they rungit clone https://github.com/WenyuChiou/zotero-skills ~/.claude/skills/zotero-skills(orclaude plugin install zotero-skills@ai-research-skills --scope user). Closes Phase 2 backlog Item #3 from theWenyuChiou/ai-research-skillsplan. .claude-plugin/plugin.jsondescription count corrected 9 → 10 skills (paper-summarize was added in v0.69 but never reflected in the auto-discovery plugin manifest). Picked up the Wave B reviewer P9 follow-up.
probe_cleared_failed_no_abstractnow routes to abstract-text fallback. When a URL'ssummarize_statusisfailed_no_abstractand the HTTP probe returns 200, the bundle was classifying the entry asurl_quality=okand uploading the URL to NotebookLM. This silently failed because publisher paywall pages (e.g. Springer) return HTTP 200 with a skeleton page that NLM also cannot read — the same paywall the summarizer hit at ingest time.probe_cleared_failed_no_abstractnow resolves toquality=likely_error_pageso the bundle falls back to abstract text when available, matching the same path already taken forcloudflare_block,tf_cookie_wall, and other confirmed paywall signals.- Ubuntu CI OOM (issue #61). ubuntu-latest runners have ~7 GB RAM;
2800+ tests with lazy-loaded modules OOM the runner.
testjob split intotest(windows/macOS — 14 GB, full suite) andtest-ubuntu(4pytest-splitshards, ~700 tests each). Coverage moved towindows-latest(per-shard coverage would undercount).pytest-split>=0.8added to dev deps. - User-agent strings updated to
research-hub/1.0.0across all 14 search backends (biorxiv, chemrxiv, cinii, crossref, dblp, eric, kci, nasa_ads, openalex, pubmed, repec, websearch + the 3 already-fixed in the prior OSS-readiness commit). Mailto format preserved for APIs that recommend it (openalex, crossref, pubmed). cli.py build_parser()catchesPackageNotFoundError(not bareException) when looking up the installed package version.pipeline_repair.pyprovenance tag bumped frompipeline-repair-v0.12.0topipeline-repair-v1.0.0.
notebooklm login --helprewritten to the three real paths. Previously advertised--cdp / --from-chrome-profile / --use-system-chrome / --timeout / --keep-openas working modes; they were silently no-ops (the underlying aliasesdel'd their arguments). Help now states only: interactive default,--import-from,--from-browser.--from-browserfailure on Python ≥3.14 is now actionable. Detects the missing-rookiepy + no-prebuilt-wheel case and points to the two paths that work (interactive in a terminal /--import-from) instead of a genericpip installhint that cannot succeed there.- 5 SKILL.md descriptions tightened for keyword overlap with
natural-language trigger prompts (Phase 7 Wave A polish):
research-hubadds "organize them" (matches the verified trigger prompt "find papers and organize them");paper-memory-builderadds "extract claims, supporting evidence, and figure key numbers" (matches the catalog phrase);research-design-helperadds "is my research question sharp enough to be falsifiable?" (matches the verified Phase 5.3.b trigger example);zotero-library-curatoradds "bloated or under-used" (matches the SKILL body's own audit dimension) plus an explicit RDF-backup reminder line.research-hub-multi-aireframes itself as the research-domain router vsagent-collab-workspace:agent-task-splitter(generic multi-agent decomposition), documenting the artifact asymmetry (.coord/multi_ai_plan.mdvs.coord/plan.yml) so the two skills no longer silently shadow each other on the same prompt — the routing overlap theai-research-skillsTask #27 trigger verification surfaced.
auto/ingestnow refresh_HOME.md, MOC bodies, and cluster overviews on success. Thepopulate_all_overviewscascade (which writes_HOME.md, populates(populated by sync)MOC bodies with their cluster lists, and refreshes everyhub/<slug>/00_overview.md) existed but was wired intovault rebuild-overviewsonly —autonever called it. Result: every research-session ingest left the vault-level navigation silently stale (empirically reproduced post-PR-D 4-leg E2E: no_HOME.mdon disk, MOCs frozen at "(populated by sync)"). Now the cascade fires automatically when ingest wrote >0 papers; failures are logged to stderr and swallowed (the ingest itself already succeeded, navigation drift is non-fatal).--auto-detectcookies path hotfix. PR-D's_patchright_cookies_dbhardcoded the LEGACYDefault/Cookiespath; modern Chromium (80+, including patchright's bundled chromium-1208) stores cookies underDefault/Network/Cookies. Result: auto-detect polled the wrong file, never fired, user logged in successfully in the browser but the save never triggered. The path resolver now prefersDefault/Network/Cookies, falls back to legacyDefault/Cookiesonly if modern is missing AND legacy exists; if neither has been written yet (browser starting), returns the modern path so the next poll finds it the moment chromium writes it.
notebooklm login --auto-detect— fully automatic zero-touch login. Replaces the half-automatic--wait-fileflow (which still required a manual file-touch after browser sign-in). With--auto-detect, research-hub polls the patchright Chromium profile's Cookies SQLite read-only for anotebooklm.google.comhost_key. When you sign in and land on the NotebookLM homepage, the cookie appears, the script feeds both\n(any pendinginput()ENTER) andy\n(any pendingclick.confirm"Save anyway?" fallback) to the SDK subprocess, and the session is saved automatically. No terminal, no wait-file touch, no click.confirm response. Fail-closed on--wait-timeout(default 300 s): nothing is saved on timeout. Mutually exclusive with--wait-file,--import-from,--from-browser.
- Ingest skips a paper missing one or more required core fields
instead of aborting the whole batch. A real-world
autorun (LLM reservoir management search) hit a CrossRef return with emptyauthors: []and the pipeline fail-fast-aborted the entire ingest with "INPUT VALIDATION FAILED" even though 2 other valid papers were ready to write. The previousmissing_doi_onlyskip branch was inert (doiis not in any required-fields list, so_validate_paper_inputnever emitted "missing required field 'doi'") and is replaced by a working_only_missing_required_field_errorspredicate covering every field inREQUIRED_FIELDS_CORE(title / authors / year). When every error for a paper is a "missing required field 'X'" error, that paper is skipped from the batch with a logged "SKIPPED invalid input" entry, and the remaining valid papers still write to Zotero / Obsidian. Strict dry-run behaviour is preserved (every validation issue surfaces) so the operator sees the full picture before a real run. - L1-deferred-but-L2-corroborated papers no longer fail-close at
L1-deferred. When the DOI resolver HEAD is transient-blocked (anti-bot 401/403/406/418/451/etc. -- classified as*_check_unavailableby F7 / PR #51), the gate used to quarantine immediately atL1-deferredeven when L2 corroboration (now augmented by PR #53's CrossRef-by-DOI metadata verify) would confirm it. The gate now lets such papers fall through to L2 / L3 / fit-check; if all pass, the paper is accepted withprovenance.doi_recheck_pending = Trueandprovenance.doi_recheck_details = {reason, status_code, url, resolved_via}so a future tool can re-verify the DOI when the publisher's anti-bot wall lifts. Definitive L1 failure (HTTP 404/410) still fail-closes asdoi_unresolved/L1-- the fabrication guarantee is unchanged; L2 corroboration + L3 metadata integrity remain the fabrication gate. TheL1-deferredquarantine bucket is structurally empty post-fix;DEFERRED_LAYERsurvives as a public constant used by docs / reporting. - L2 corroboration augments single-source DOIs with direct CrossRef
metadata verify. A paper found by only one search backend (e.g.
source: 'openalex') was quarantinedL2 / uncorroboratedeven when its DOI was independently confirmable via CrossRef. The gate now does a directhttps://api.crossref.org/works/{doi}metadata fetch for such papers; if CrossRef returns a record whose title/year/ authors match per the existing_records_agreepredicate (fuzz.token_set_ratio >= 85,|year_delta| <= 1, surname intersection >= 1), CrossRef is recorded as a verified backend and the paper passes L2. Strictly augmentative: CrossRef is an authoritative metadata source, this only adds an existing-paper evidence check; the L2 corroboration bar itself is unchanged. Result cached incrossref_verify_cache.json(schema 1.0); failures are fail-quiet and not cached. zotero gc/zotero mark-kept --all-orphans: a real cluster collection whose Zotero key drifted from itscluster.zotero_collection_keybinding (e.g. a Zotero-truncated date-prefixed name like20260518-machine-learning-flood-forecas) is no longer mis-flagged as an orphan candidate. New conservative, suppression-only name-normalization match (_normalize_collection_name+ bidirectional prefix match, min 12-char guard). No rebind/merge/delete-logic change; strictly fewer GC candidates. PR-A's non-empty hard-skip remains as an independent safety net.- arXiv hits no longer leak past
--exclude-type preprint. The arXiv backend leftdoc_typeempty, so the type filter silently missed every raw arXiv result (bioRxiv/chemRxiv already set it). Nowdoc_type="preprint". - Self-heal commands are environment-correct. New
recommended_cli_invocation()picks theresearch-hubconsole script when on PATH, else thepython -m research_hubmodule form. Wired into the NLM preflight,RequiresAuthRefresh, keepalive task resolution, and the auto-pipeline NLM-skip hint — these previously hardcoded a console-script command that fails on source checkouts. notebooklm keepaliveno longer reports false-green. It now re-probes session health after rotating cookies; a rotation that does not preserve a usable session (server-side re-auth) returns non-zero instead of exit 0.- F7: doi.org anti-bot 418 no longer quarantines every valid paper.
The authenticity gate's DOI resolver sent the default
python-requestsUser-Agent, which doi.org/Cloudflare answer with HTTP 418 — read asdoi_unresolved→ every peer-reviewed paper fail-closed-quarantined (reproduced:accepted: 0; quarantined: 2,DOIs accessible: 0; the same DOI returns 200 with a real UA). Fix: send a realUser-Agent; classify 408/418/425/429/5xx + network errors as transient (bounded retry + backoff, not cached as a permanent miss, surfaced as*_check_unavailable); genuine 404/410 stay fail-closeddoi_unresolvedso the anti-fabrication guarantee is unchanged. A 0/malformed status also fails closed. - F7 completion: access-blocked resolver statuses defer. DOI HEAD
statuses other than resolved 2xx/3xx or definitive 404/410
non-registration (for example 401/403/406/451 anti-bot walls) now
route to
doi_check_unavailable/L1-deferredinstead of permanentdoi_unresolved/L1quarantine. - Semantic Scholar API keys are validated before use. A
non-latin-1
SEMANTIC_SCHOLAR_API_KEYnow emits a clear warning and is ignored so the backend queries anonymously instead of crashing while requests encodes thex-api-keyheader. - DOI resolve cache auto-migrates pre-PR-#51 poisoning. The F7
completion (anti-bot HEAD defers, PR #51) was silently neutralised on
any DOI cached under the old non-transient classification -- the
cache-hit short-circuit returned the stale
doi_unresolvedoutcome before the new logic ran.DoiResolveCache.loadnow performs a one-shot schema 1.0 -> 1.1 migration that prunesreason="doi_unresolved"entries whosestatus_codeis not in{404, 410}(genuine non-existence) and rewrites the cache at the new schema version. Entries with status 404/410, success entries, and any other reason are preserved. Idempotent; logged at WARNING when any entry is pruned.
- Dead
notebooklm loginflags:--cdp,--chrome-binary,--use-system-chrome,--from-chrome-profile,--chrome-profile-path,--chrome-profile-name,--keep-open,--timeout, and the unusedlogin_interactive/login_interactive_cdpaliases they delegated to.
zotero gcno longer presents non-empty real collections as deletion candidates. Any Zotero collection whose key was not a current cluster binding was flaggedorphan-from-vault— including stale non-empty date-prefixed duplicate collections holding real items.delete_candidatesalready hard-skipped non-empty collections (so no data could actually be lost), but the output falsely implied a data-loss risk, eroding trust in the tool. Now: non-empty orphans get a distinctorphan-with-items(N)reason, are listed under a separate "NON-EMPTY ORPHANS — review only; gc cannot delete these" section, are never offered in the interactive prompt, and are excluded from--yes. Empty/test junk GC is unchanged.
- F6:
autono longer prints[OK] ingest N paperswhen 0 were written. When every candidate was quarantined by the fail-closed authenticity gate the raw dir was never created, theexists()guard was skipped, and the tentativelen(papers)count survived — the pipeline reported a clean ingest of N papers while the vault got nothing. The count is now authoritative (0when nothing written); an all-quarantined ingest is reported as a failed step with an actionablequarantine listhint, not[OK]. A 0-written / 0-quarantined result (e.g. empty search) keeps the lenient path with an honestN written, M quarantined (of K candidates)message. - F8:
notebooklm uploadno longer exits 0 when 0 sources were transferred. (Root cause later revised — the common cause is the URL-quality skip, not the upstream API; see the "Fixed (F8 real fix)" entry above.) When NotebookLM's source API drifts under the pinnednotebooklm-py(e.g.Sources data ... is not a list (NoneType)) the notebook is created but no sources land; the old code returned 0 becausefail_count == 0. A non-dry-run upload that transfers, caches, and prunes nothing is now a non-zero error naming the likely cause. (notebooklm-pywas already pinned<0.5.0; this drift is server-side, so honest reporting is the only available remedy.)
- Transient DOI-resolution failures defer instead of being
fail-closed-quarantined as fraud. PR-B's minimal F7 fix stopped the
cache poisoning but the authenticity gate still quarantined ANY
not okat L1 — including*_check_unavailable(doi.org / Crossref rate-limit or network blip after the bounded retry). A sustained rate-limit window therefore still rejected valid papers, requiring manualquarantine restore. Now the L1 branch splits by reason family: transient → distinctL1-deferredlayer (reported as "deferred, retryable"; recovers on a later run /quarantine restore); permanent (*_unresolved, 404/410, no-identifier, predatory/metadata/fit/uncorroborated) → unchangedL1quarantine. The paper is still held out of ingest in both cases (fail-closed — the anti-fabrication L0–L5 guarantee is unchanged; a fabricated DOI returns 404 → permanent → quarantined).autonow reportsN quarantined, D deferreddistinctly, and an all-deferred run says the papers were not rejected and how to retry, instead of "quarantined". (An optionalquarantine retry-deferredconvenience CLI is deferred to a later follow-up — existingquarantine restorealready recovers deferred entries.)
notebooklm login --wait-file PATH— non-interactive login (no terminal/ENTER). The upstream login blocks oninput("press ENTER"), which can't be driven headless/scripted. With--wait-fileyou sign in in the browser then create PATH (touch PATH, or an automation wrapper does it); research-hub polls for it and feeds the newline that triggers the upstream session save.--wait-timeout(default 300s) fails closed — on timeout the subprocess is terminated and nothing is saved. Stale signal files are cleared first so a leftover from a previous run can't auto-trigger before sign-in. This formalizes (in Python, testable) the signal-pipe technique used to recover the maintainer's expired session.
autocan now actually upload publisher-URL clusters to NotebookLM. Diagnosed 2026-05-19: an all-URL cluster (DOIs → ScienceDirect/Elsevier) uploaded 0 sources because every entry waslikely_error_page(our local probe can't read the anti-bot wall) and the conservative URL-quality gate skipped them all — andautocalledupload_clusterpositionally with no way to override, so there was no path to rescue it through the pipeline. The earlier F8 message also mis-blamed a NotebookLM API change (theSourcesAPI.list ... NoneTypewarning was a red herring — listing an empty new notebook). The conservative skip is intentional design and is unchanged; instead--include-suspect-urlsis now exposed onautoand threadedauto → auto_pipeline → upload_cluster, and the 0-sources error message now lists the real likely cause first (URL sources skipped → re-run with--include-suspect-urls) instead of pointing at the upstream API.
- NotebookLM bundles now upload the abstract as a text source when no
PDF is obtainable, instead of a paywalled URL. First-principles
diagnosis: NLM needs content, not a URL — a paywalled publisher DOI
carries none. Local PDF (rung 1) and Unpaywall/OA PDF (rung 2, via
fetch_paper_pdf) already worked; the gap was rung 3.bundle.pynow: no PDF + (no URL or alikely_error_pagepaywall URL) + a real## Abstractin the note → emitsaction="text"carrying the abstract (title/DOI-prefixed); a good (non-suspect) URL is still preferred (full text).NotebookLMClient.upload_text→sources.add_text;upload.pydispatchesaction="text";BundleReport.text_count. Net: an all-paywall-URL cluster that previously uploaded 0 sources now uploads the abstracts — real content NotebookLM can synthesise. The conservative URL-quality gate and--include-suspect-urlsoverride (band-aid) are unchanged. socket.getfqdnCI flake — autouse stub intests/conftest.py(master had been red on this for ≥3 consecutive runs:26185448887,26191738564,26192402510). Multiple test files constructThreadingHTTPServer(("127.0.0.1", 0), ...)whoseserver_bind()callssocket.getfqdn("127.0.0.1"). On macOS GitHub Actions runners (and Bonjour/mDNS-equipped environments generally) the reverse-DNS lookup hangs 30+ seconds beforepytest-timeoutfires — Python stdlib issue14914, 14-year-old bug. The first attempt of this fix patched only theartifact_serverfixture intest_artifact_delete_endpoint.py, but the next CI re-run surfaced the same flake attest_dashboard_executor_e2e.py::test_e2e_sse_event_after_action.grep -rln 'ThreadingHTTPServer'found 7 test files with the same construction pattern, so the fix promoted to aconftest.py-level autouse fixture (_stub_socket_getfqdn) that stubssocket.getfqdnfor every test intests/. Production code path is unchanged —monkeypatchscope is per-test, reverting at teardown.
- Legacy root
plugin.jsondeleted (Phase 7 Wave B from theWenyuChiou/ai-research-skillsmarketplace-maturity tracker — see research-hub issue #60). The file was an early Cowork-plugin placeholder with a self-described TODO ("Convert to full Cowork plugin manifest when plugin schema is finalized") that never got migrated. It declared 3 skill paths, one of which (skills/knowledge-base/SKILL.md) was the long-removedknowledge-basealias, while 11 actual skill dirs ship underskills/. The active manifest is.claude-plugin/plugin.json, which uses Claude Code marketplace auto-discovery and is unaffected.grep -rn 'plugin\.json'againsttests/+src/+ non-meta docs returns zero references to the root file — it is a pure orphan. Removing it removes a misleading source of truth without touching any install / marketplace / test path.
Older releases (the v1.0.0 pre-release planning notes, v0.95.x and earlier) are archived in CHANGELOG-archive/CHANGELOG-pre-1.0.md.