diff --git a/docs/plans/librarian.md b/docs/plans/librarian.md index 4eba0d1..2a27933 100644 --- a/docs/plans/librarian.md +++ b/docs/plans/librarian.md @@ -183,11 +183,40 @@ routing the dated experiments and design analysis out of the file: extracts via a "See Also" section. AGENTS.md soft-cap rule reworded to clarify it is a *routing trigger*, not a split mandate. +## Stage 2 v1: promote dispatcher + +✅ `scripts/librarian_promote.py`. Takes a `docs/archive/*.md` path, +stitches `scripts/librarian-prompt.md` (system prompt) onto the +archive body with a "draft a research note" task instruction, then +shells out to the LLM CLI selected by `LIBRARIAN_LLM` +(claude / codex / gemini; default claude). Output is captured to +`runs/tmp/librarian-promote--draft.md` for human review; +the script never writes into `docs/research/` itself. `--show-prompt` +prints the assembled prompt for inspection without calling the LLM. + +Refuses to run if: +- the path is not under `docs/archive/`, +- the implied target `docs/research/.md` already exists, or +- the file is missing. + +If the LLM judges the archive non-promotable, it is instructed to +return a single line `SKIP: ` instead of a draft. + +## Stage 2 remaining + +- MEMORY.md drift fixup mode (read drift report, propose one-line + diffs). +- Duplicate-doc merge proposal mode. +- Survey mode: scan all archive entries lacking a research + counterpart and run `librarian_promote` on each, accumulating + drafts under one timestamped directory. + ## Next Concrete Step -Stage 2 — LLM judgment dispatcher. Read `librarian-.json` -+ relevant doc bodies, route to `LIBRARIAN_LLM={claude|codex|gemini}` -with `scripts/librarian-prompt.md` as system prompt, emit a unified -diff under `runs/tmp/librarian-.patch`. Initial use cases: -research-note drafts for promotable archive entries, MEMORY.md drift -fixups, duplicate-doc merge proposals. +Smoke-test `librarian_promote.py` against one real archive entry +(`docs/archive/option-a-bench-result-2026-05-07.md` is a good +candidate — durable architecture content). Run with the default +claude backend, review the draft, and either accept it as +`docs/research/option-a-bench-result.md` or note specific +edit-distance from what we'd want. The result drives whether the +prompt template needs tightening before adding survey mode. diff --git a/scripts/librarian-ignore.txt b/scripts/librarian-ignore.txt index 28c464c..e844533 100644 --- a/scripts/librarian-ignore.txt +++ b/scripts/librarian-ignore.txt @@ -18,3 +18,8 @@ scripts/run_model_size_experiment.sh # Future config described in docs/plans/torch_compile.md configs/deep_cfr/default_compile.yaml + +# Hypothetical accept target named in docs/plans/librarian.md as a +# smoke-test illustration; resolves naturally if Stage 2 promote is +# accepted, otherwise stays a forward reference. +docs/research/option-a-bench-result.md diff --git a/scripts/librarian_promote.py b/scripts/librarian_promote.py new file mode 100644 index 0000000..207a2c9 --- /dev/null +++ b/scripts/librarian_promote.py @@ -0,0 +1,168 @@ +"""Stage 2 LLM dispatcher: draft a research note from an archive entry. + +Reads `docs/archive/.md`, assembles a prompt by stitching +`scripts/librarian-prompt.md` (the system prompt) onto the archive +body, and dispatches to the LLM CLI selected by the LIBRARIAN_LLM +environment variable. The LLM's stdout is captured to +`runs/tmp/librarian-promote--draft.md`. The dispatcher +never writes into `docs/research/` directly — the operator reviews +the draft and copies/edits it themselves. + +Backends: + LIBRARIAN_LLM=claude (default; invokes `claude -p`) + LIBRARIAN_LLM=codex (invokes `codex exec`) + LIBRARIAN_LLM=gemini (invokes `gemini -p`) + +Usage: + uv run python scripts/librarian_promote.py docs/archive/foo.md + uv run python scripts/librarian_promote.py docs/archive/foo.md --show-prompt + LIBRARIAN_LLM=codex uv run python scripts/librarian_promote.py docs/archive/foo.md +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +LLM_COMMANDS = { + "claude": ["claude", "-p"], + "codex": ["codex", "exec"], + "gemini": ["gemini", "-p"], +} + +DATE_SUFFIX = re.compile(r"-\d{4}-\d{2}-\d{2}$") + + +def _repo_root() -> Path: + current = Path(__file__).resolve() + for parent in current.parents: + if (parent / "pyproject.toml").is_file(): + return parent + raise RuntimeError("pyproject.toml을 찾을 수 없어 repository root를 판정할 수 없습니다.") + + +def _assemble_prompt( + system_prompt: str, + rel_archive: Path, + archive_body: str, + rel_target: Path, +) -> str: + return ( + f"{system_prompt}\n\n" + "---\n\n" + "Task: Draft a `docs/research/` note from the archive entry below.\n" + "Follow the rules in your system prompt above (style template, " + "`Last verified:` and `Source:` headers, `file:line` citations " + "verified against the current tree, ~1 page, prose over bullet " + "soup).\n\n" + f"**Source archive:** `{rel_archive}`\n" + f"**Suggested target filename:** `{rel_target}`\n\n" + "If the archive does not contain a durable conclusion (e.g. it " + "is a one-off bench result with no general lesson), respond " + "with a single line `SKIP: ` instead of a draft.\n\n" + "Output: the markdown content of the new file only. No " + "preamble, no code fences around the whole thing, no " + "explanation after. Begin with the H1 header line.\n\n" + "---\n\n" + "Archive body:\n\n" + f"{archive_body}\n" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "archive_path", + help="Path to a docs/archive/*.md entry to promote.", + ) + parser.add_argument( + "--show-prompt", + action="store_true", + help="Print the assembled prompt to stdout and exit; do not call the LLM.", + ) + args = parser.parse_args() + + root = _repo_root() + archive = (root / args.archive_path).resolve() + + if not archive.is_file(): + print(f"Archive not found: {args.archive_path}", file=sys.stderr) + return 1 + try: + rel_archive = archive.relative_to(root) + except ValueError: + print(f"Archive must live under repo root: {archive}", file=sys.stderr) + return 1 + if not str(rel_archive).startswith("docs/archive/"): + print( + f"Refusing: archive must live under docs/archive/: {rel_archive}", + file=sys.stderr, + ) + return 1 + + stem = DATE_SUFFIX.sub("", archive.stem) + target = root / "docs" / "research" / f"{stem}.md" + rel_target = target.relative_to(root) + + if target.exists(): + print( + f"Refusing: research counterpart already exists: {rel_target}", + file=sys.stderr, + ) + return 1 + + system_prompt = (root / "scripts" / "librarian-prompt.md").read_text(encoding="utf-8") + archive_body = archive.read_text(encoding="utf-8") + prompt = _assemble_prompt(system_prompt, rel_archive, archive_body, rel_target) + + if args.show_prompt: + sys.stdout.write(prompt) + return 0 + + backend = os.environ.get("LIBRARIAN_LLM", "claude").lower() + cmd = LLM_COMMANDS.get(backend) + if cmd is None: + print( + f"Unknown LIBRARIAN_LLM={backend}; supported: {sorted(LLM_COMMANDS)}", + file=sys.stderr, + ) + return 1 + + timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S") + out_dir = root / "runs" / "tmp" + out_dir.mkdir(parents=True, exist_ok=True) + prompt_path = out_dir / f"librarian-promote-{timestamp}-prompt.md" + draft_path = out_dir / f"librarian-promote-{timestamp}-draft.md" + prompt_path.write_text(prompt, encoding="utf-8") + + print( + f"Dispatching to {backend} (prompt saved to {prompt_path.relative_to(root)})", + file=sys.stderr, + ) + result = subprocess.run( + cmd + [prompt], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + print(f"LLM call failed (exit {result.returncode}):", file=sys.stderr) + print(result.stderr, file=sys.stderr) + return result.returncode + + draft_path.write_text(result.stdout, encoding="utf-8") + print(f"Draft written to: {draft_path.relative_to(root)}") + print(f"Suggested target on accept: {rel_target}") + print() + print("Next: review the draft. To accept verbatim:") + print(f" cp {draft_path.relative_to(root)} {rel_target}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())