diff --git a/AI_POLICY.md b/.github/AI_POLICY.md similarity index 100% rename from AI_POLICY.md rename to .github/AI_POLICY.md diff --git a/CONTRIBUTING.md b/.github/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to .github/CONTRIBUTING.md diff --git a/.github/workflows/knowledge_board.yml b/.github/workflows/knowledge_board.yml new file mode 100644 index 0000000..cdacea7 --- /dev/null +++ b/.github/workflows/knowledge_board.yml @@ -0,0 +1,104 @@ +name: Knowledge Board + +# Publishes the knowledge board โ€” the management view of the Memory's papers +# and wikis (scripts/board.py, the Heart/Hands dashboard pattern): +# * the GitHub Pages page (one-tap ๐Ÿ“‹ work prompts for a phone), +# * badge.json (the shields endpoint the README badge reads), +# * the one-line README strip between the memory:begin/end markers. +# +# Contents-level only: titles and counts, never claim text. Nothing is +# committed โ€” validate_structure.py bans .html and gates the root allowlist, +# so every surface is rendered fresh here. + +on: + push: + branches: [main] + paths: + - "wiki/**" + - "bibliography/**" + - "reading-queue.md" + - "index.md" + - "scripts/board.py" + schedule: + - cron: "45 5 * * 1" + workflow_dispatch: + +# contents: write โ†’ the README strip self-commit; pages/id-token โ†’ publish. +permissions: + contents: write + pages: write + id-token: write + +concurrency: + group: knowledge-board-pages + cancel-in-progress: false + +jobs: + board: + name: Render + publish the knowledge board + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Render every surface (local parse, no network) + run: | + mkdir -p _site + python scripts/board.py --html > _site/index.html + python scripts/board.py --badge > _site/badge.json + python scripts/board.py --md > board.md + python scripts/board.py --md-brief > readme_strip.md + + - name: Write the board to the job step summary + run: cat board.md >> "$GITHUB_STEP_SUMMARY" + + - name: Update the README strip (own repo only, main only) + if: github.ref == 'refs/heads/main' + run: | + python - <<'PY' + import pathlib, re + readme = pathlib.Path("README.md") + text = readme.read_text() + strip = pathlib.Path("readme_strip.md").read_text().strip() + begin, end = "", "" + block = f"{begin}\n{strip}\n{end}" + if begin in text and end in text: + text = re.sub(re.escape(begin) + r".*?" + re.escape(end), block, + text, flags=re.DOTALL) + readme.write_text(text) + PY + if ! git diff --quiet README.md; then + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add README.md + git commit -m "docs(memory): auto-update knowledge-board strip [skip ci]" + for attempt in 1 2 3; do + if git push; then exit 0; fi + git pull --rebase origin main || exit 1 + done + echo "::error::could not push the README strip after 3 attempts" + exit 1 + else + echo "README strip unchanged โ€” nothing to commit." + fi + + # The Pages site is created once out-of-band (gh api -X POST + # repos///pages -f build_type=workflow) โ€” enablement here + # cannot create it with the default token (the Hands lesson). + - uses: actions/configure-pages@v5 + if: github.ref == 'refs/heads/main' + + - uses: actions/upload-pages-artifact@v3 + if: github.ref == 'refs/heads/main' + with: + path: _site + + - id: deployment + uses: actions/deploy-pages@v4 + if: github.ref == 'refs/heads/main' diff --git a/AGENTS.md b/AGENTS.md index 01db2cf..a2590aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,11 +48,13 @@ the allowlist is in `scripts/validate_structure.py`. the organism did. - **Workflow state, health, execution** โ€” Mind / Heart / Build respectively. -## This repo is personal +## Scope: personal research material, out of scope for user-facing repos -PyAutoMemory contains personal research material. **Never reference or copy -PyAutoMemory content into public or user-facing repos** (libraries, -workspaces, tutorials, assistants). +PyAutoMemory holds personal research material. It is a public repo and its +wiki content is CC BY 4.0 (see LICENSE), but it is **out of scope for the +user-facing repos** (libraries, workspaces, tutorials, assistants): link to +it if you must, never inline or copy its content there โ€” user-facing docs +must stand on their own without it. ## Never rewrite history diff --git a/Makefile b/Makefile index d1a3c34..9bc86ed 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: validate validate-literature-citations validate-structure test +.PHONY: validate validate-literature-citations validate-structure test board validate: validate-literature-citations validate-structure @@ -10,3 +10,6 @@ validate-structure: test: python -m pytest tests/ -q + +board: + python scripts/board.py --md diff --git a/README.md b/README.md index f8a62a4..6ea95dd 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,32 @@ [![PyAutoScientist GitHub](https://img.shields.io/badge/%F0%9F%A7%AE%20PyAutoScientist-GitHub-181717?style=flat-square)](https://github.com/PyAutoLabs/PyAutoScientist) [![PyAutoScientist ReadTheDocs](https://img.shields.io/badge/%F0%9F%93%96%20PyAutoScientist-ReadTheDocs-8CA1AF?style=flat-square)](https://pyautoscientist.readthedocs.io) -The long-term memory of the PyAuto organism: what it has learned, distilled -into cross-linked LLM wikis โ€” literature summaries, scientific concepts, and -the citation metadata to verify them. Source PDFs live off-repo; what's here -is the durable knowledge. Start at [`index.md`](index.md). +[![knowledge](https://img.shields.io/endpoint?url=https://pyautolabs.github.io/PyAutoMemory/badge.json)](https://pyautolabs.github.io/PyAutoMemory/) -The sub-wikis (self-contained, shared schema): +**PyAutoMemory is the Memory of the PyAutoScientist** โ€” the long-term memory +of the organism: what it has learned, distilled into cross-linked LLM wikis โ€” +literature summaries, scientific concepts, and the citation metadata to +verify them. Memory holds *what the science says*; what the organism *did* +lives in the Mind. Source PDFs live off-repo; what's here is the durable +knowledge. Start at [`index.md`](index.md). + +See the **[PyAutoMemory Knowledge Board](https://pyautolabs.github.io/PyAutoMemory/)** +(mobile phone dashboard) for managing all of it from one page: the reading +queue, the paper sections still needing a canonical citation key, and each +sub-wiki's maturity โ€” every work queue carrying a one-tap ๐Ÿ“‹ button that +copies a paste-ready Claude prompt (file the next paper, resolve keys, +upgrade a stub, or `/memory ` to recall what's known). + +## Current contents + + + + + + +## The sub-wikis + +Self-contained, shared schema: | Wiki | Covers | |------|--------| @@ -24,8 +44,8 @@ The sub-wikis (self-contained, shared schema): [`bibliography/`](bibliography/README.md) holds the canonical BibTeX metadata every wiki cites against; [`reading-queue.md`](reading-queue.md) is what's waiting to be read and filed. New knowledge updates the metadata -and the claim support together, then passes -`make validate`. +and the claim support together, then passes `make validate` (CI-enforced on +every push). The wiki schema is defined in [`wiki/CLAUDE.md`](wiki/CLAUDE.md) and inherited by every diff --git a/bibliography/README.md b/bibliography/README.md index 20e21fd..9e32df5 100644 --- a/bibliography/README.md +++ b/bibliography/README.md @@ -5,15 +5,9 @@ repository. Wiki source entries explain which claims papers support; the BibTeX citation metadata and canonical keys. Keep PDFs, local paths, abstracts, and long paper summaries out of both layers. -> Renamed from `pyautopaper.bib` (when this repo was PyAutoPaper); the back-compat -> `pyautopaper.bib` symlink was retired in 2026-07 once nothing referenced it. - -The initial canonical file prioritised `library.bib`. Entries with new keys from the other -tracked legacy `.bib` files were added, but conflicting records never replaced the -`library.bib` entry. The legacy root-level `.bib` files were deleted in 2026-07 after a -key-level audit confirmed every unique key already existed here (they remain in git -history). All canonical metadata lives in this folder; loose `.bib` files must not be -added elsewhere in the repo. +All canonical metadata lives in this folder; loose `.bib` files must not be +added elsewhere in the repo. (The file was consolidated from a legacy +personal library in 2026-07 โ€” the merge audit is in git history.) ## Adding a paper diff --git a/index.md b/index.md index 73844c8..d7aa83a 100644 --- a/index.md +++ b/index.md @@ -50,23 +50,12 @@ The sub-wikis explain which claims papers support. Canonical BibTeX metadata, key aliases, downstream-project resolution rules, and validation live in [`bibliography/`](bibliography/README.md). -## Status (2026-05-22) - -- Initial lensing-wiki build: 193 papers from Strong_Lens/, - Substructure/, StrongLensCluster/, Dark_Matter_Detection/, - DarkMatterModels/. -- Sibling sub-wiki build (this commit): ~422 additional papers from - SMBHs/, CTI/, Euclid/, MassiveEllPaper/, Ellipticals/, - Bulge_Disk_Decomp/, LightProFFits/, IFUs/, Manga/, COSMOS/, - StellarHalos/, High_Redshift_galaxies/, Dark_Matter_Geometry/, - Stats/, GaussianLinearModels/, PPLs/, Deep Learning/, Software/, - Simulation/, PyAutoLens/, uvplane/, FRBLenses/, WeakLensing/, - WeakLensingHaloShape/, Clusters/, LSS/, Lyman_Alpha_Forest/, - SpiralsMorph/, AndrewSuggests/, UnRead/, Summarys/, - Collaborations/, GWB/, Medical/, plus root-level singletons. -- PDFs deleted from the repo on the same day (backed up externally); - `File:` lines in stubs are archival relative paths. - -Most legacy per-paper entries remain filename-inferred stubs. Upgrade them to -compact, claim-oriented `drafted` entries only after verifying the paper, and -log the change in the relevant sub-wiki. +## Provenance + +The wikis were seeded in 2026-05 from a ~615-paper personal PDF library +(the PDFs themselves were deleted from the repo the same day, backed up +externally). Most legacy per-paper entries remain filename-inferred stubs: +upgrade them to compact, claim-oriented `drafted` entries only after +verifying the paper, and log the change in the relevant sub-wiki. The live +counts โ€” pages, maturity, unresolved citation keys, reading queue โ€” are on +the [knowledge board](https://pyautolabs.github.io/PyAutoMemory/). diff --git a/scripts/board.py b/scripts/board.py new file mode 100644 index 0000000..ad73090 --- /dev/null +++ b/scripts/board.py @@ -0,0 +1,395 @@ +"""scripts/board.py โ€” the PyAutoMemory knowledge board. + +A phone-readable, MANAGEMENT-FIRST view of the repo's knowledge: what is +waiting to be read, which paper sections still need a canonical BibTeX key, +how mature each sub-wiki is โ€” each work queue carrying a one-tap ๐Ÿ“‹ copy +block holding a paste-ready Claude Code prompt that executes this repo's own +documented workflow (``bibliography/README.md`` "Adding a paper", +``wiki/CLAUDE.md``'s schema) โ€” plus contents cards with ``/memory `` +recall chips. + +**Contents-level only.** The board shows titles and counts, never claim text +or summaries โ€” the knowledge itself stays in the wiki pages. + +**Fully local.** Everything renders from the checkout (no network): +frontmatter statuses, ``**Canonical BibTeX key:**`` markers, bib entry +counts, reading-queue sections, wikilink totals. Repo identity (for links) +derives from ``git remote`` โ€” nothing is hardcoded, so the script travels +into spawned templates unchanged. + +Published by ``.github/workflows/knowledge_board.yml`` (Pages + badge + the +README ``memory:begin/end`` strip). Nothing is committed โ€” +``validate_structure.py`` bans ``.html`` and gates the root allowlist, so the +board is rendered fresh in CI, the Heart pattern. + +Usage: + python scripts/board.py [--md | --md-brief | --html | --badge | --json] +""" + +from __future__ import annotations + +import datetime +import html as _html +import json +import re +import subprocess +import sys +from pathlib import Path + +MEMORY_HOME = Path(__file__).resolve().parents[1] + +KEY_MARK = "**Canonical BibTeX key:**" +STATUS_RE = re.compile(r"^status:\s*(\S+)", re.MULTILINE) +WIKILINK_RE = re.compile(r"\[\[([^\]]+)\]\]") +BIB_ENTRY_RE = re.compile(r"^@\w+\{", re.MULTILINE) +RESOLVED_KEY_RE = re.compile(r"\*\*Canonical BibTeX key:\*\* `([^`]+)`") +QUEUE_SECTION_RE = re.compile(r"^([A-Za-z][A-Za-z /&'\-]+):\s*$") + + +def _owner_repo() -> tuple[str, str]: + out = subprocess.run( + ["git", "-C", str(MEMORY_HOME), "remote", "get-url", "origin"], + capture_output=True, text=True, + ).stdout.strip() + m = re.search(r"[:/]([^/:]+)/([^/]+?)(?:\.git)?/?$", out) + return (m.group(1), m.group(2)) if m else ("", "") + + +# --- collect (local files only) ---------------------------------------------- +def collect(root: Path | None = None) -> dict: + root = root or MEMORY_HOME + owner, repo = _owner_repo() if root == MEMORY_HOME else ("", "") + snapshot: dict = { + "generated": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "owner": owner, + "repo": repo, + "wikis": [], + "bib_entries": 0, + "queue": [], + "links": {"total": 0, "unique": 0, "wanted": 0}, + } + + stems: set[str] = set() + all_slugs: list[str] = [] + wiki_root = root / "wiki" + for wdir in sorted(d for d in wiki_root.glob("*") if d.is_dir()): + pages = sorted(wdir.rglob("*.md")) + stems |= {p.stem for p in pages} + statuses: dict[str, int] = {} + kinds = {"concepts": 0, "entities": 0, "sources": 0} + sections = 0 + todo = 0 + resolved: set[str] = set() + for p in pages: + text = p.read_text(errors="replace") + m = STATUS_RE.search(text) + if m: + statuses[m.group(1)] = statuses.get(m.group(1), 0) + 1 + if p.parent.name in kinds: + kinds[p.parent.name] += 1 + n_marks = text.count(KEY_MARK) + sections += n_marks + keys = RESOLVED_KEY_RE.findall(text) + resolved |= set(keys) + todo += n_marks - len(keys) + all_slugs += WIKILINK_RE.findall(text) + snapshot["wikis"].append({ + "name": wdir.name, + "pages": len(pages), + **kinds, + "statuses": statuses, + "sections": sections, + "todo": todo, + "resolved_keys": len(resolved), + }) + + for bib in sorted((root / "bibliography").glob("*.bib")): + snapshot["bib_entries"] += len(BIB_ENTRY_RE.findall( + bib.read_text(errors="replace"))) + + queue = root / "reading-queue.md" + if queue.exists(): + section, count = None, 0 + for line in queue.read_text(errors="replace").splitlines(): + m = QUEUE_SECTION_RE.match(line.strip()) + if m: + if section is not None: + snapshot["queue"].append({"section": section, "count": count}) + section, count = m.group(1), 0 + elif section is not None and line.strip(): + count += 1 + if section is not None: + snapshot["queue"].append({"section": section, "count": count}) + + slugs = [s.split("|")[0].strip() for s in all_slugs] + uniq = set(slugs) + snapshot["links"] = { + "total": len(slugs), + "unique": len(uniq), + "wanted": len({s for s in uniq if s not in stems}), + } + return snapshot + + +# --- pure helpers -------------------------------------------------------------- +def _totals(snapshot: dict) -> dict: + wikis = snapshot.get("wikis") or [] + statuses: dict[str, int] = {} + for w in wikis: + for k, v in (w.get("statuses") or {}).items(): + statuses[k] = statuses.get(k, 0) + v + sections = sum(w.get("sections", 0) for w in wikis) + todo = sum(w.get("todo", 0) for w in wikis) + return { + "pages": sum(w.get("pages", 0) for w in wikis), + "statuses": statuses, + "sections": sections, + "todo": todo, + "resolved": sections - todo, + "queued": sum(q.get("count", 0) for q in snapshot.get("queue") or []), + } + + +def pages_url(snapshot: dict) -> str: + owner = str(snapshot.get("owner") or "").lower() + repo = snapshot.get("repo") or "" + return f"https://{owner}.github.io/{repo}/" if owner and repo else "" + + +def _repo_url(snapshot: dict) -> str: + owner, repo = snapshot.get("owner"), snapshot.get("repo") + return f"https://github.com/{owner}/{repo}" if owner and repo else "" + + +def _read_prompt(snapshot: dict, section: str) -> str: + repo = snapshot.get("repo") or "the memory repo" + return (f"Work through the next paper in the '{section}' section of " + f"{repo}/reading-queue.md: verify it against an authoritative " + f"record, add its canonical entry to the bibliography/ BibTeX " + f"file, stub it in the matching wiki//sources/ page per " + f"wiki/CLAUDE.md, remove it from the queue, and run make validate.") + + +def _todo_prompt(snapshot: dict, wiki: str) -> str: + repo = snapshot.get("repo") or "the memory repo" + return (f"Resolve TODO canonical BibTeX keys in {repo}/wiki/{wiki}/sources/: " + f"for each '{KEY_MARK} TODO' section, search the bibliography/ " + f"BibTeX file by DOI, arXiv ID and title, set the canonical key " + f"per bibliography/README.md, and run make validate. Work through " + f"a handful, then stop for review.") + + +def _stub_prompt(snapshot: dict, wiki: str) -> str: + repo = snapshot.get("repo") or "the memory repo" + return (f"Upgrade one stub page in {repo}/wiki/{wiki}/ to drafted: verify " + f"its claims against the cited sources, expand it per the schema " + f"in wiki/CLAUDE.md, set status: drafted, and run make validate.") + + +# --- renderers ------------------------------------------------------------------ +def _render_md(snapshot: dict) -> str: + t = _totals(snapshot) + lines = ["# PyAutoMemory knowledge board", "", + "_Contents and work queues โ€” the knowledge itself lives in the " + "wiki pages._", "", + f"**{t['pages']} pages** across {len(snapshot.get('wikis') or [])} " + f"sub-wikis ยท **{t['resolved']}/{t['sections']}** paper sections " + f"cite a resolved key ยท **{snapshot.get('bib_entries', 0)}** " + f"bibliography entries ยท **{t['queued']}** papers queued", ""] + lines += ["| Wiki | Pages | Stub | Drafted | Reviewed | Key TODOs |", + "|---|---|---|---|---|---|"] + for w in snapshot.get("wikis") or []: + s = w.get("statuses") or {} + lines.append(f"| {w['name']} | {w['pages']} | {s.get('stub', 0)} | " + f"{s.get('drafted', 0)} | {s.get('reviewed', 0)} | " + f"{w.get('todo', 0)} |") + lines += ["", "## Reading queue", ""] + for q in snapshot.get("queue") or []: + lines.append(f"- {q['section']}: {q['count']}") + if not snapshot.get("queue"): + lines.append("- _(queue empty or unavailable)_") + url = pages_url(snapshot) + if url: + lines += ["", f"[Knowledge board]({url}) โ€” one-tap ๐Ÿ“‹ work prompts"] + return "\n".join(lines) + + +def _render_md_brief(snapshot: dict) -> str: + t = _totals(snapshot) + pct = round(100 * t["resolved"] / t["sections"]) if t["sections"] else 0 + bits = [f"๐Ÿง  **{t['pages']} pages** ยท {t['statuses'].get('drafted', 0)} drafted", + f"{pct}% of {t['sections']} paper sections cite a resolved key", + f"{t['queued']} papers queued"] + url = pages_url(snapshot) + if url: + bits.append(f"[knowledge board โ†’]({url})") + return " ยท ".join(bits) + + +def _copy_btn(payload: str, label: str = "copy") -> str: + return (f"") + + +def _bar(statuses: dict) -> str: + stub = statuses.get("stub", 0) + drafted = statuses.get("drafted", 0) + reviewed = statuses.get("reviewed", 0) + total = max(1, stub + drafted + reviewed) + seg = lambda n, cls: (f"" + if n else "") + return f"{seg(reviewed, 'ok')}{seg(drafted, 'mid')}{seg(stub, 'lo')}" + + +def _render_html(snapshot: dict) -> str: + t = _totals(snapshot) + pct = round(100 * t["resolved"] / t["sections"]) if t["sections"] else 0 + repo_url = _repo_url(snapshot) + + queue_rows = [] + for q in snapshot.get("queue") or []: + queue_rows.append( + f"{_html.escape(q['section'])}" + f"{q['count']} waiting " + f"{_copy_btn(_read_prompt(snapshot, q['section']), 'copy: file the next paper')}" + f"") + if not queue_rows: + queue_rows.append("queue empty or unavailable") + + todo_rows = [] + for w in snapshot.get("wikis") or []: + if not w.get("todo"): + continue + todo_rows.append( + f"{_html.escape(w['name'])}" + f"{w['todo']} of {w['sections']} sections " + f"{_copy_btn(_todo_prompt(snapshot, w['name']), 'copy: resolve canonical keys')}" + f"") + if not todo_rows: + todo_rows.append("every paper section cites a resolved key") + + wiki_rows = [] + for w in snapshot.get("wikis") or []: + s = w.get("statuses") or {} + link = (f"" + f"{_html.escape(w['name'])}" if repo_url else _html.escape(w["name"])) + wiki_rows.append( + f"{link}" + f"{w['pages']} pages ยท {w.get('concepts', 0)}c/" + f"{w.get('entities', 0)}e/{w.get('sources', 0)}s" + f"{_bar(s)} {s.get('stub', 0)} stub ยท " + f"{s.get('drafted', 0)} drafted " + f"{_copy_btn(_stub_prompt(snapshot, w['name']), 'copy: upgrade a stub')} " + f"{_copy_btn('/memory ' + w['name'], 'copy: recall this domain')}" + f"") + + return f""" + + +PyAutoMemory โ€” {t['pages']} pages + + +
+

PyAutoMemory knowledge board

+

{t['pages']} pages ยท {pct}% cited

+

Contents and work queues for the organism's long-term memory + โ€” titles and counts only; the knowledge itself lives in the wiki pages. + ๐Ÿ“‹ copies a paste-ready prompt for a Claude Code chat.

+

Reading queue ({t['queued']} papers waiting)

+ {''.join(queue_rows)}
+

Citation work queue ({t['todo']} sections need a canonical key)

+ {''.join(todo_rows)}
+

Sub-wikis ({snapshot.get('bib_entries', 0)} bibliography entries ยท + {snapshot.get('links', {}).get('wanted', 0)} wanted pages)

+ {''.join(wiki_rows)}
+ +
+""" + + +def badge_endpoint(snapshot: dict) -> dict: + t = _totals(snapshot) + if not t["pages"]: + return {"schemaVersion": 1, "label": "knowledge", + "message": "unknown", "color": "lightgrey"} + pct = round(100 * t["resolved"] / t["sections"]) if t["sections"] else 0 + return {"schemaVersion": 1, "label": "knowledge", + "message": f"{t['pages']} pages ยท {pct}% cited", "color": "blueviolet"} + + +def render(snapshot: dict, fmt: str = "md") -> str: + if fmt == "md": + return _render_md(snapshot) + if fmt == "md-brief": + return _render_md_brief(snapshot) + if fmt == "html": + return _render_html(snapshot) + if fmt == "badge": + return json.dumps(badge_endpoint(snapshot)) + if fmt == "json": + return json.dumps({**snapshot, "pages_url": pages_url(snapshot)}, + indent=2, sort_keys=True) + raise ValueError(f"unknown board fmt: {fmt!r}") + + +def main(argv: list[str] | None = None) -> int: + import argparse + + ap = argparse.ArgumentParser(prog="scripts/board.py", description=__doc__) + g = ap.add_mutually_exclusive_group() + g.add_argument("--md", action="store_true", help="markdown board (default)") + g.add_argument("--md-brief", action="store_true", help="the README strip") + g.add_argument("--html", action="store_true", help="the Pages page") + g.add_argument("--badge", action="store_true", help="shields endpoint JSON") + g.add_argument("--json", action="store_true", help="the machine surface") + ns = ap.parse_args(argv) + snap = collect() + fmt = "md" + for name, label in (("md", "md"), ("md_brief", "md-brief"), + ("html", "html"), ("badge", "badge"), ("json", "json")): + if getattr(ns, name): + fmt = label + break + print(render(snap, fmt)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate_structure.py b/scripts/validate_structure.py index 6fcfdb3..1de6088 100644 --- a/scripts/validate_structure.py +++ b/scripts/validate_structure.py @@ -27,9 +27,7 @@ ALLOWED_TOP_FILES = { ".gitignore", "AGENTS.md", - "AI_POLICY.md", "CLAUDE.md", - "CONTRIBUTING.md", "LICENSE", "Makefile", "README.md", diff --git a/tests/test_board.py b/tests/test_board.py new file mode 100644 index 0000000..7801920 --- /dev/null +++ b/tests/test_board.py @@ -0,0 +1,110 @@ +"""tests/test_board.py โ€” the knowledge board (scripts/board.py). + +The board's contract: counts are computed correctly from a synthetic tree, +every fmt renders, the work-queue prompts reference the repo's documented +workflow, the html is self-contained (no external assets), and โ€” the privacy +guarantee โ€” outputs are CONTENTS-LEVEL only: page titles and counts, never +page body text. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +import board # noqa: E402 + +BODY_MARKER = "the-secret-claim-text-that-must-never-leak" + + +def _tree(tmp_path: Path) -> Path: + w = tmp_path / "wiki" / "demo" + (w / "concepts").mkdir(parents=True) + (w / "sources").mkdir() + (w / "concepts" / "alpha.md").write_text( + f"---\ntitle: Alpha\ntype: concept\nstatus: drafted\n---\n\n" + f"{BODY_MARKER} with a [[beta]] link and a [[gamma]] link.\n") + (w / "sources" / "papers.md").write_text( + "---\ntitle: Papers\ntype: sources\nstatus: stub\n---\n\n" + "## One\n**Canonical BibTeX key:** `Key2020`\n\n" + "## Two\n**Canonical BibTeX key:** TODO โ€” no unique match found.\n") + (w / "index.md").write_text("# Demo index\n- [[alpha]]\n") + bib = tmp_path / "bibliography" + bib.mkdir() + (bib / "demo.bib").write_text("@article{Key2020,\n title={T}\n}\n" + "@book{Other2021,\n title={U}\n}\n") + (tmp_path / "reading-queue.md").write_text( + "# Reading queue\n\nintro prose\n\n---\n\n" + "Demo Papers:\n\nTitle One\nTitle Two\n\nOther Things:\n\nTitle Three\n") + return tmp_path + + +def test_counts_from_a_synthetic_tree(tmp_path): + snap = board.collect(_tree(tmp_path)) + (w,) = snap["wikis"] + assert w["name"] == "demo" and w["pages"] == 3 + assert w["concepts"] == 1 and w["sources"] == 1 + assert w["statuses"] == {"drafted": 1, "stub": 1} + assert w["sections"] == 2 and w["todo"] == 1 and w["resolved_keys"] == 1 + assert snap["bib_entries"] == 2 + assert snap["queue"] == [{"section": "Demo Papers", "count": 2}, + {"section": "Other Things", "count": 1}] + # alpha exists; beta/gamma are wanted + assert snap["links"]["wanted"] == 2 + + +def test_every_fmt_renders(tmp_path): + snap = board.collect(_tree(tmp_path)) + for fmt in ("md", "md-brief", "html", "badge", "json"): + assert board.render(snap, fmt) + + +def test_contents_level_only_no_body_text_leaks(tmp_path): + snap = board.collect(_tree(tmp_path)) + for fmt in ("md", "md-brief", "html", "badge", "json"): + assert BODY_MARKER not in board.render(snap, fmt) + + +def test_work_queue_prompts_reference_the_documented_workflow(tmp_path): + snap = board.collect(_tree(tmp_path)) + html = board.render(snap, "html") + assert "data-copy=" in html and "cp(this)" in html + assert "reading-queue.md" in html # file-the-next-paper prompt + assert "make validate" in html # every prompt ends at the gate + assert "/memory demo" in html # the recall chip + assert "wiki/CLAUDE.md" in html # the schema anchor + + +def test_md_brief_is_one_line(tmp_path): + out = board.render(board.collect(_tree(tmp_path)), "md-brief") + assert "\n" not in out and "pages" in out + + +def test_badge_shape(tmp_path): + badge = json.loads(board.render(board.collect(_tree(tmp_path)), "badge")) + assert badge["schemaVersion"] == 1 and badge["label"] == "knowledge" + assert "50% cited" in badge["message"] + + +def test_html_is_self_contained(tmp_path): + out = board.render(board.collect(_tree(tmp_path)), "html") + assert out.lstrip().startswith("") + assert "src=" not in out and "