diff --git a/README.md b/README.md index e290e2f..172c5eb 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Sage runs inside Claude Code and turns study into active recall, Socratic coachi ## See it in action ``` -You: /sage async programming +You: /sage learn async programming Sage: Before I design your plan, I need to understand your learning landscape. What's your current experience with async? What does success look like? What do you expect to be hardest? @@ -63,24 +63,64 @@ Sage: Good instinct. But you said it's offloaded to "another stack." ## Usage +Every command takes a verb: `/sage learn ` or `/sage archive `. + ### First session ``` -/sage +/sage learn ``` The coach starts by asking about your goals, prior knowledge, and how much time you have. It builds a structured learning plan from your answers, then jumps straight into teaching. Each session ends with a checkpoint, so you can close the terminal and come back anytime. Examples: -- `/sage React hooks` -- `/sage distributed systems` -- `/sage statistics for ML` +- `/sage learn React hooks` +- `/sage learn distributed systems` +- `/sage learn statistics for ML` ### Resuming -Run `/sage ` again. The coach detects your existing artifacts, loads your last savepoint, handles any overdue reviews, then continues from where you stopped. +Run `/sage learn ` again. The coach detects your existing artifacts, loads your last savepoint, handles any overdue reviews, then continues from where you stopped. To pick from a list instead, run `/sage learn` with no topic. + +If you hard-close the terminal mid-session, your previous sessions are safe (already checkpointed), but the current session's final state may be partially lost. Run `/resume` from the same directory to reopen the conversation where you left off, or run `/sage learn ` to start a fresh session from the last completed checkpoint. + +### Archiving + +Retire a project you're done with so it stops cluttering your list: + +``` +/sage archive +``` + +Run `/sage archive` with no topic to pick from a list. The coach shows you exactly what will move and asks for confirmation first. Archiving moves the project's directory to a hidden `.archive/` folder under your learning root and tidies the cross-reference registry — **nothing is deleted**. + +**Archiving is one-way by design.** There's no `unarchive` command, and none is planned. Your artifacts stay readable under `.archive//` for reference, but the tracking state — knowledge map, cards, SRS schedule — stops being used. Coming back to a topic means starting a fresh project with `/sage learn `. That's what the confirmation prompt is for: archive when you're genuinely done. + +
+Restoring an archived project by hand -If you hard-close the terminal mid-session, your previous sessions are safe (already checkpointed), but the current session's final state may be partially lost. Run `/resume` from the same directory to reopen the conversation where you left off, or run `/sage ` to start a fresh session from the last completed checkpoint. +Nothing is deleted, so you can always undo an archive manually. Each archived project keeps an `archive-meta.json` recording what was removed from the cross-reference index. + +```bash +ROOT= +SLUG= + +# 1. shard back to the registry +mv "$ROOT/.archive/$SLUG/cross-refs.md" "$ROOT/cross-refs/$SLUG.md" + +# 2. read the stashed index fragments, then remove the archive artifact +cat "$ROOT/.archive/$SLUG/archive-meta.json" +rm "$ROOT/.archive/$SLUG/archive-meta.json" + +# 3. project back into place +mv "$ROOT/.archive/$SLUG" "$ROOT/$SLUG" +``` + +Then hand-edit `cross-refs/INDEX.md`: re-add the project's own row from the meta's `index.own_row`, and add `$SLUG` back to the "Overlaps With" cell of each project listed under `index.inbound_rows`. + +⚠️ **Don't paste `index.inbound_rows[].row` back verbatim.** Those rows are snapshots from the moment of archival and may be stale — pasting one can resurrect a reference to a *different* project you archived later, which breaks that project's cross-ref loading. Add only `$SLUG` to each inbound project's *current* cell, and skip any `own_overlaps` entry whose project is no longer active. + +
## Configuration @@ -95,7 +135,7 @@ To point Sage at a different directory later, use any of these (highest preceden ```json { "learning_root": "/new/path", "version": 1 } ``` -3. **Re-trigger the first-run prompt** — delete `~/.config/sage/config.json`. The next `/sage ` run will ask you to pick a directory again. +3. **Re-trigger the first-run prompt** — delete `~/.config/sage/config.json`. The next `/sage learn ` run will ask you to pick a directory again. Existing topic folders aren't moved automatically — if you want to keep past progress, move them into the new learning root yourself. diff --git a/SKILL.md b/SKILL.md index a96f192..48a10a6 100644 --- a/SKILL.md +++ b/SKILL.md @@ -3,7 +3,7 @@ name: sage description: | Evidence-based learning session with spaced repetition, retrieval practice, and mastery tracking. -argument-hint: "" +argument-hint: "learn | archive " --- You are running a Sage session. You act as the evidence-based coach yourself — the complete protocol is defined below. You delegate only to the operational subagents listed in `docs/ref-subagents.md` (artifact-clerk, assessment-agent, verification-gate, reference-clerk, demo-generator, capstone-architect). Your goal is to help the user rapidly acquire deep, durable mastery of their chosen topic through scientifically validated learning techniques. @@ -14,12 +14,18 @@ $ARGUMENTS ## Step 0: Session Setup -Run the session router: +The command grammar is `/sage ` with exactly two verbs — `learn` +and `archive` (see `adr/0002-mandatory-command-verbs.md`). The router parses the +leading verb. Run it, passing `$ARGUMENTS` verbatim (it already includes the verb): ```bash SAGE_ROOT=$(cat /tmp/.sage-plugin-root) python3 "$SAGE_ROOT/tools/session_router.py" "$SAGE_ROOT" "$ARGUMENTS" ``` +- If `mode` is `unknown_verb`: the learner used the old verb-less grammar (e.g. + `/sage react hooks`) or a dropped keyword (`continue`). Show the router's + `message` field verbatim — it maps the old form to the new one — and stop. Do + not guess a topic or start a session. - If `mode` is `needs_config`: ask the learner where to store projects, then: 1. **Preview** the resolved path so typos and `~` expansion are visible before anything is written: ```bash @@ -33,12 +39,71 @@ python3 "$SAGE_ROOT/tools/session_router.py" "$SAGE_ROOT" "$ARGUMENTS" 3. If `save_config` raises (e.g. permission denied, or the path sits under an existing file), report the error and ask for a different location — nothing is persisted on failure, so the learner can safely retry. Then re-run the router. -- If `mode` is `pick`: the learner used a resume keyword (e.g., "continue", "resume"). Present the `projects` list from the router output (sorted by most recent session). Ask the learner to pick one. Then re-run the router with the selected slug — it will return `mode: "resume"`. +- If `mode` is `pick`: the learner used a bare verb with no topic (`/sage learn` or `/sage archive`). Present the `projects` list from the router output (sorted by most recent session) and ask the learner to pick one. Then re-run the router as ` ` using the `action` field from the output — `learn` returns `mode: "resume"`, `archive` returns `mode: "archive"`. - If `mode` is `fresh`: create the `` directory, read eager-load references, continue with Phase 1. - If `mode` is `resume`: read eager-load references, follow Resume Protocol. +- If `mode` is `archive_no_match`: no project matched the slug. Tell the learner nothing was archived. If `suggestion` is non-null, offer it ("Did you mean ``?"). Do not create anything. Stop. +- If `mode` is `archive`: follow the **Archive Flow** below. This is NOT a learning session — do not read eager-load references or enter Phase 1. Use `topic_path` and `sage_root` from the output for all subsequent commands. +### Archive Flow + +Archiving retires a **Project** (its on-disk container) by moving it under +`/.archive/`. It is **one-way by design** — there is no `unarchive` +command and none is planned. Nothing is deleted (the artifacts stay readable for +reference), but the learner is giving up the tracking state: knowledge map, cards, +and SRS schedule. Coming back to the topic means starting a fresh project. Make sure +the learner understands that before proceeding — it is the whole point of the +confirmation. See `adr/0003-archive-by-move-recoverable.md`. + +1. **Quiescent-project invariant.** `archive_project.py` only ever operates on an + at-rest project. If the target `slug` is the project you have been teaching in + *this* conversation and its state is unsaved, first run the full end-of-session + checklist (`docs/ref-session-end.md`) to persist journal, savepoint, and + cross-refs. Only then proceed. (Cold targets — any project you are not actively + teaching — are already quiescent; skip straight to step 2.) + +2. **Get the plan.** Never describe the archive from your own reading of + `INDEX.md` — the tool computes every fact. Run it in dry-run mode, which + touches nothing (not even `.archive/`): + ```bash + python3 "$SAGE_ROOT/tools/archive_project.py" "" "" --dry-run + ``` + It returns `status: "dry_run"` plus `archived_dir` (the real destination, + including any numeric suffix), `shard_archived`, `index_own_row_removed`, + `inbound_refs_scrubbed`, and `inbound_ref_count`. + +3. **Confirm before mutating**, rendering the prompt **from the dry-run JSON** — + every path and number below comes from that output, never from your own + inspection. Require an explicit yes. The inbound count is what makes a + heavily-linked hub project give pause, so state it plainly: + ``` + Archive ""? + • moves + • moves cross-refs/.md → /cross-refs.md [omit if shard_archived is false] + • removes from INDEX.md: its own row [omit if index_own_row_removed is false] + + inbound references () + • ONE-WAY: there is no unarchive command. Your knowledge map, cards, and + SRS schedule stop being used — returning to this topic means starting + a fresh project. The artifacts stay readable under .archive/. + Nothing is deleted. Proceed? (yes/no) + ``` + If `archived_dir` carries a numeric suffix, say so — it means a previous + archive of this slug already exists. If the learner declines, stop — change + nothing (the dry-run has already left the filesystem untouched). + +4. **Run the tool for real**, with today's date (passed in so the tool stays + deterministic): + ```bash + python3 "$SAGE_ROOT/tools/archive_project.py" "" "" --date "$(date +%Y-%m-%d)" + ``` + It recomputes the plan from scratch rather than trusting the dry-run, then + executes it. + +5. **Report the result** from the tool's JSON summary (`status: "archived"`) — + same fields as the plan. Then stop; archival is a complete, standalone action. + ### Eager-Load References Before any teaching begins (both resume and fresh start paths), read these files: diff --git a/tests/test_archive_project.py b/tests/test_archive_project.py new file mode 100644 index 0000000..465ed4e --- /dev/null +++ b/tests/test_archive_project.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Tests for archive_project.py. + +Covers: +- parse_index removes the archived project's own row +- parse_index strips inbound Overlaps-With references +- parse_index preserves title / blockquote / header / separator / other rows +- parse_index handles em-dash and empty overlap placeholders +- parse_index is idempotent for an absent slug +- next_archive_dir returns the base name when free, numeric suffix on collision +- archive_project moves the whole project tree under .archive/ +- archive_project co-locates the cross-ref shard as cross-refs.md +- archive_project writes archive-meta.json with correct stash + provenance +- archive_project scrubs INDEX.md +- archive_project works with no cross-refs registry at all +- archive_project uses a numeric suffix when .archive/ already exists +- archive_project raises on a non-existent project (never half-moves) +""" + +import json +import os +import tempfile +import unittest +from pathlib import Path + +import archive_project as ap + + +SAMPLE_INDEX = """\ +# Cross-Reference Index + +> When running a session for project X, load `cross-refs/X.md` plus every +> file in the "Overlaps With" column. + +| Project | Overlaps With | +|---|---| +| alpha | beta, gamma | +| beta | alpha | +| gamma | alpha, delta | +| delta | — | +""" + + +class TestParseIndex(unittest.TestCase): + def test_removes_own_row(self): + new_text, frags = ap.parse_index(SAMPLE_INDEX, "alpha") + self.assertNotIn("| alpha |", new_text) + self.assertIsNotNone(frags["own_row"]) + self.assertEqual(frags["own_overlaps"], ["beta", "gamma"]) + + def test_strips_inbound_references(self): + new_text, frags = ap.parse_index(SAMPLE_INDEX, "alpha") + # beta only overlapped alpha → now empty placeholder + self.assertIn("| beta | — |", new_text) + # gamma overlapped alpha, delta → alpha gone, delta kept + self.assertIn("| gamma | delta |", new_text) + inbound = {i["project"] for i in frags["inbound"]} + self.assertEqual(inbound, {"beta", "gamma"}) + + def test_preserves_structure(self): + new_text, _ = ap.parse_index(SAMPLE_INDEX, "alpha") + self.assertIn("# Cross-Reference Index", new_text) + self.assertIn("> When running a session", new_text) + self.assertIn("| Project | Overlaps With |", new_text) + self.assertIn("|---|---|", new_text) + # untouched row survives verbatim + self.assertIn("| delta | — |", new_text) + + def test_absent_slug_is_noop(self): + new_text, frags = ap.parse_index(SAMPLE_INDEX, "nonexistent") + self.assertEqual(new_text, SAMPLE_INDEX) + self.assertIsNone(frags["own_row"]) + self.assertEqual(frags["inbound"], []) + + def test_split_overlaps_placeholders(self): + for placeholder in ("—", "-", "", " "): + self.assertEqual(ap._split_overlaps(placeholder), []) + self.assertEqual(ap._split_overlaps("a, b ,c"), ["a", "b", "c"]) + + +class TestNextArchiveDir(unittest.TestCase): + def test_free_returns_base(self): + with tempfile.TemporaryDirectory() as tmp: + base = os.path.join(tmp, ".archive") + os.makedirs(base) + self.assertEqual(ap.next_archive_dir(base, "alpha"), + os.path.join(base, "alpha")) + + def test_collision_suffixes(self): + with tempfile.TemporaryDirectory() as tmp: + base = os.path.join(tmp, ".archive") + os.makedirs(os.path.join(base, "alpha")) + self.assertEqual(ap.next_archive_dir(base, "alpha"), + os.path.join(base, "alpha-2")) + os.makedirs(os.path.join(base, "alpha-2")) + self.assertEqual(ap.next_archive_dir(base, "alpha"), + os.path.join(base, "alpha-3")) + + +class TestArchiveProject(unittest.TestCase): + def _make_root(self, tmp, with_cross_refs=True): + root = Path(tmp) + # Project tree + jdir = root / "alpha" / "learning" / "journal" + jdir.mkdir(parents=True) + (jdir / "index.md").write_text("| 1 | 2026-07-10 |\n") + (root / "alpha" / "learning" / "plan.md").write_text("plan\n") + if with_cross_refs: + cr = root / "cross-refs" + cr.mkdir() + (cr / "INDEX.md").write_text(SAMPLE_INDEX) + (cr / "alpha.md").write_text("# Cross-References: alpha\n") + return root + + def test_moves_project_tree(self): + with tempfile.TemporaryDirectory() as tmp: + root = self._make_root(tmp) + ap.archive_project(str(root), "alpha", date="2026-07-17") + self.assertFalse((root / "alpha").exists()) + moved = root / ".archive" / "alpha" + self.assertTrue(moved.is_dir()) + self.assertTrue((moved / "learning" / "journal" / "index.md").is_file()) + self.assertTrue((moved / "learning" / "plan.md").is_file()) + + def test_colocates_shard(self): + with tempfile.TemporaryDirectory() as tmp: + root = self._make_root(tmp) + summary = ap.archive_project(str(root), "alpha", date="2026-07-17") + self.assertTrue((root / ".archive" / "alpha" / "cross-refs.md").is_file()) + self.assertFalse((root / "cross-refs" / "alpha.md").exists()) + self.assertTrue(summary["shard_archived"]) + + def test_writes_meta(self): + with tempfile.TemporaryDirectory() as tmp: + root = self._make_root(tmp) + ap.archive_project(str(root), "alpha", date="2026-07-17") + meta = json.loads( + (root / ".archive" / "alpha" / "archive-meta.json").read_text() + ) + self.assertEqual(meta["original_slug"], "alpha") + self.assertEqual(meta["archived_date"], "2026-07-17") + self.assertEqual(meta["archived_dir"], "alpha") + self.assertEqual(meta["inbound_ref_count"], 2) + self.assertTrue(meta["shard_archived"]) + self.assertEqual(meta["index"]["own_overlaps"], ["beta", "gamma"]) + + def test_scrubs_index(self): + with tempfile.TemporaryDirectory() as tmp: + root = self._make_root(tmp) + summary = ap.archive_project(str(root), "alpha", date="2026-07-17") + index_text = (root / "cross-refs" / "INDEX.md").read_text() + self.assertNotIn("alpha", index_text) + self.assertIn("| beta | — |", index_text) + self.assertIn("| gamma | delta |", index_text) + self.assertEqual(sorted(summary["inbound_refs_scrubbed"]), + ["beta", "gamma"]) + + def test_no_cross_refs_registry(self): + with tempfile.TemporaryDirectory() as tmp: + root = self._make_root(tmp, with_cross_refs=False) + summary = ap.archive_project(str(root), "alpha", date="2026-07-17") + self.assertTrue((root / ".archive" / "alpha").is_dir()) + self.assertFalse(summary["shard_archived"]) + self.assertFalse(summary["index_own_row_removed"]) + meta = json.loads( + (root / ".archive" / "alpha" / "archive-meta.json").read_text() + ) + self.assertEqual(meta["inbound_ref_count"], 0) + + def test_collision_uses_suffix(self): + with tempfile.TemporaryDirectory() as tmp: + root = self._make_root(tmp) + # Pre-existing archive of a prior generation. + (root / ".archive" / "alpha").mkdir(parents=True) + summary = ap.archive_project(str(root), "alpha", date="2026-07-17") + self.assertTrue((root / ".archive" / "alpha-2").is_dir()) + self.assertEqual(summary["archived_dir"], str(root / ".archive" / "alpha-2")) + + def test_missing_project_raises(self): + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(ValueError): + ap.archive_project(tmp, "ghost", date="2026-07-17") + + +class TestPlanArchive(unittest.TestCase): + """The plan is what the coach's confirmation prompt is built from, so it + must be exact and must not touch disk.""" + + def _make_root(self, tmp, with_cross_refs=True): + root = Path(tmp) + jdir = root / "alpha" / "learning" / "journal" + jdir.mkdir(parents=True) + (jdir / "index.md").write_text("| 1 | 2026-07-10 |\n") + if with_cross_refs: + cr = root / "cross-refs" + cr.mkdir() + (cr / "INDEX.md").write_text(SAMPLE_INDEX) + (cr / "alpha.md").write_text("# Cross-References: alpha\n") + return root + + def test_dry_run_touches_nothing(self): + with tempfile.TemporaryDirectory() as tmp: + root = self._make_root(tmp) + before = sorted(p.relative_to(root).as_posix() + for p in root.rglob("*")) + index_before = (root / "cross-refs" / "INDEX.md").read_text() + + ap.plan_archive(str(root), "alpha") + + after = sorted(p.relative_to(root).as_posix() for p in root.rglob("*")) + self.assertEqual(before, after) + self.assertEqual(index_before, + (root / "cross-refs" / "INDEX.md").read_text()) + # planning must not even create .archive/ + self.assertFalse((root / ".archive").exists()) + + def test_plan_reports_exact_inbound_refs(self): + with tempfile.TemporaryDirectory() as tmp: + root = self._make_root(tmp) + plan = ap.plan_archive(str(root), "alpha") + self.assertEqual(plan["inbound_ref_count"], 2) + self.assertEqual(sorted(plan["inbound_refs_scrubbed"]), + ["beta", "gamma"]) + self.assertTrue(plan["shard_archived"]) + self.assertTrue(plan["index_own_row_removed"]) + + def test_plan_predicts_suffixed_destination(self): + with tempfile.TemporaryDirectory() as tmp: + root = self._make_root(tmp) + (root / ".archive" / "alpha").mkdir(parents=True) + plan = ap.plan_archive(str(root), "alpha") + self.assertEqual(plan["archived_dir"], + str(root / ".archive" / "alpha-2")) + + def test_plan_matches_execution(self): + """A dry-run must predict exactly what archiving then does.""" + with tempfile.TemporaryDirectory() as tmp: + root = self._make_root(tmp) + plan = ap._summary(ap.plan_archive(str(root), "alpha"), "dry_run") + actual = ap.archive_project(str(root), "alpha", date="2026-07-17") + self.assertEqual(plan["status"], "dry_run") + self.assertEqual(actual["status"], "archived") + for key in ap.SUMMARY_KEYS: + self.assertEqual(plan[key], actual[key], f"mismatch on {key}") + + def test_plan_no_cross_refs_reports_no_shard(self): + with tempfile.TemporaryDirectory() as tmp: + root = self._make_root(tmp, with_cross_refs=False) + plan = ap.plan_archive(str(root), "alpha") + self.assertFalse(plan["shard_archived"]) + self.assertFalse(plan["index_own_row_removed"]) + self.assertEqual(plan["inbound_ref_count"], 0) + + def test_plan_missing_project_raises(self): + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(ValueError): + ap.plan_archive(tmp, "ghost") + + def test_summary_strips_internals(self): + with tempfile.TemporaryDirectory() as tmp: + root = self._make_root(tmp) + summary = ap._summary(ap.plan_archive(str(root), "alpha"), "dry_run") + self.assertFalse([k for k in summary if k.startswith("_")]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_session_router.py b/tests/test_session_router.py new file mode 100644 index 0000000..9b37ed3 --- /dev/null +++ b/tests/test_session_router.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Tests for session_router.py — verb dispatch and discovery predicates. + +Covers: +- unknown / legacy verbs return mode 'unknown_verb' with guidance +- learn discovery (resume picker) is journal-based +- archive discovery (picker + resolution) is plan-based, so a plan-only + project (initialized, never a session) is archivable +- learn on a plan-only project routes to 'fresh'; on a journal project to 'resume' +- archive on a project with neither plan nor journal -> 'archive_no_match' +- bare verbs open the picker with the correct 'action' and predicate +""" + +import os +import tempfile +import unittest +from pathlib import Path + +import session_router as sr + + +class RouterTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self._prev_env = os.environ.get("SAGE_LEARNING_ROOT") + os.environ["SAGE_LEARNING_ROOT"] = str(self.root) + + def tearDown(self): + if self._prev_env is None: + os.environ.pop("SAGE_LEARNING_ROOT", None) + else: + os.environ["SAGE_LEARNING_ROOT"] = self._prev_env + self.tmp.cleanup() + + def _make(self, slug, plan=False, journal=False): + learning = self.root / slug / "learning" + learning.mkdir(parents=True, exist_ok=True) + if plan: + (learning / "plan.md").write_text("# Plan\n") + if journal: + jdir = learning / "journal" + jdir.mkdir(exist_ok=True) + (jdir / "index.md").write_text("| 1 | 2026-07-10 |\n") + + +class TestVerbDispatch(RouterTestCase): + def test_legacy_bare_topic_is_unknown_verb(self): + out = sr.route("/sage", "react hooks") + self.assertEqual(out["mode"], "unknown_verb") + + def test_legacy_keyword_suggests_learn(self): + out = sr.route("/sage", "continue") + self.assertEqual(out["mode"], "unknown_verb") + self.assertEqual(out["suggestion"], "learn") + + +class TestDiscoveryPredicates(RouterTestCase): + def test_learn_picker_is_journal_based(self): + self._make("has-journal", plan=True, journal=True) + self._make("plan-only", plan=True, journal=False) + out = sr.route("/sage", "learn") + slugs = {p["slug"] for p in out["projects"]} + self.assertEqual(out["action"], "learn") + self.assertIn("has-journal", slugs) + self.assertNotIn("plan-only", slugs) + + def test_archive_picker_is_plan_based(self): + self._make("has-journal", plan=True, journal=True) + self._make("plan-only", plan=True, journal=False) + self._make("empty-dir", plan=False, journal=False) + out = sr.route("/sage", "archive") + slugs = {p["slug"] for p in out["projects"]} + self.assertEqual(out["action"], "archive") + self.assertIn("has-journal", slugs) + self.assertIn("plan-only", slugs) # initialized but never started + self.assertNotIn("empty-dir", slugs) # not a real project + + +class TestResolution(RouterTestCase): + def test_archive_plan_only_project_matches(self): + self._make("plan-only", plan=True, journal=False) + out = sr.route("/sage", "archive plan-only") + self.assertEqual(out["mode"], "archive") + self.assertEqual(out["slug"], "plan-only") + + def test_archive_no_plan_no_match(self): + self._make("empty-dir", plan=False, journal=False) + out = sr.route("/sage", "archive empty-dir") + self.assertEqual(out["mode"], "archive_no_match") + + def test_archive_no_match_suggests_close_slug(self): + self._make("react-hooks", plan=True) + out = sr.route("/sage", "archive raect-hooks") + self.assertEqual(out["mode"], "archive_no_match") + self.assertEqual(out["suggestion"], "react-hooks") + + def test_learn_plan_only_is_fresh(self): + self._make("plan-only", plan=True, journal=False) + out = sr.route("/sage", "learn plan-only") + self.assertEqual(out["mode"], "fresh") + + def test_learn_journal_is_resume(self): + self._make("has-journal", plan=True, journal=True) + out = sr.route("/sage", "learn has-journal") + self.assertEqual(out["mode"], "resume") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/archive_project.py b/tools/archive_project.py new file mode 100644 index 0000000..59fef04 --- /dev/null +++ b/tools/archive_project.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Archive a Sage Project. + +Retires a Project (its on-disk container) by moving the whole directory +under `/.archive//`, co-locating its cross-ref shard +there, and scrubbing every reference to it from the cross-refs `INDEX.md`. +Archival is one-way-but-recoverable: nothing is deleted, and the removed +INDEX fragments + provenance are stashed in `archive-meta.json` inside the +archive directory. See adr/0003-archive-by-move-recoverable.md. + +This tool is STATELESS and assumes a QUIESCENT project — the coach must +checkpoint any live session for this slug before invoking it. + +Usage: + python3 archive_project.py [--date YYYY-MM-DD] + +Zero external dependencies — Python 3.8+ stdlib only. +""" + +import argparse +import json +import os +import shutil +import sys + + +# --------------------------------------------------------------------------- +# Pure helpers (no filesystem side effects) — unit-tested directly. +# --------------------------------------------------------------------------- + +def _split_overlaps(cell): + """Parse an 'Overlaps With' cell into a list of project slugs. + + Empty / em-dash / hyphen placeholders mean 'no overlaps' → [].""" + cell = cell.strip() + if cell in ("", "-", "—", "–"): + return [] + return [tok.strip() for tok in cell.split(",") if tok.strip()] + + +def _join_overlaps(tokens): + """Render a list of slugs back into a cell value, '—' when empty.""" + return ", ".join(tokens) if tokens else "—" + + +def _is_table_row(line): + return line.lstrip().startswith("|") + + +def _is_separator_row(line): + return set(line.strip()) <= set("|-: ") + + +def parse_index(text, slug): + """Scrub `slug` from a cross-refs INDEX.md. + + Removes the project's own row and strips it from every other row's + 'Overlaps With' cell. Returns (new_text, fragments) where fragments is: + { + "own_row": "", + "own_overlaps": [], + "inbound": [{"project": , "row": }, ...], + } + + Non-table lines (title, blockquote, blanks) and the header/separator + rows are preserved verbatim. Idempotent: scrubbing an absent slug is a + no-op that returns empty fragments. + """ + fragments = {"own_row": None, "own_overlaps": [], "inbound": []} + out_lines = [] + header_seen = False + + for line in text.split("\n"): + if not _is_table_row(line) or _is_separator_row(line): + out_lines.append(line) + continue + + cells = [c.strip() for c in line.strip().strip("|").split("|")] + # Header row (the one naming the columns) — pass through once. + if not header_seen and cells and cells[0].lower() == "project": + header_seen = True + out_lines.append(line) + continue + + if len(cells) < 2: + out_lines.append(line) + continue + + project, overlaps_cell = cells[0], cells[1] + + if project == slug: + # The archived project's own row — drop it, stash its overlaps. + fragments["own_row"] = line + fragments["own_overlaps"] = _split_overlaps(overlaps_cell) + continue + + tokens = _split_overlaps(overlaps_cell) + if slug in tokens: + fragments["inbound"].append({"project": project, "row": line}) + tokens = [t for t in tokens if t != slug] + out_lines.append(f"| {project} | {_join_overlaps(tokens)} |") + else: + out_lines.append(line) + + return "\n".join(out_lines), fragments + + +def next_archive_dir(archive_base, slug): + """Lowest non-colliding archive directory name for `slug`. + + Returns an absolute path: `/` if free, else + `/-2`, `-3`, ... Deterministic → testable.""" + candidate = os.path.join(archive_base, slug) + if not os.path.exists(candidate): + return candidate + n = 2 + while True: + candidate = os.path.join(archive_base, f"{slug}-{n}") + if not os.path.exists(candidate): + return candidate + n += 1 + + +# --------------------------------------------------------------------------- +# Orchestrator (filesystem side effects). +# --------------------------------------------------------------------------- + +# Keys of a plan that make up the caller-facing summary. Everything else +# in a plan is an internal the executor needs (prefixed `_`). +SUMMARY_KEYS = ( + "slug", + "project_path", + "archived_dir", + "shard_archived", + "index_own_row_removed", + "inbound_refs_scrubbed", + "inbound_ref_count", +) + + +def _summary(plan, status): + """Strip internals from a plan, stamping it with a status.""" + out = {"status": status} + out.update({k: plan[k] for k in SUMMARY_KEYS}) + return out + + +def plan_archive(learning_root, slug): + """Compute the full archive plan WITHOUT touching disk. + + Every fact the coach shows in its confirmation prompt — the real + destination (including any numeric suffix), whether a shard will move, + and the exact inbound references that will be scrubbed — is resolved + here, so the prompt never depends on the model eyeballing INDEX.md. + + Raises ValueError if the project does not resolve to an existing + directory. Returns a dict: SUMMARY_KEYS plus `_`-prefixed internals. + """ + project_path = os.path.join(learning_root, slug) + if not os.path.isdir(project_path): + raise ValueError(f"No project directory at {project_path}") + + cross_refs_dir = os.path.join(learning_root, "cross-refs") + index_path = os.path.join(cross_refs_dir, "INDEX.md") + shard_path = os.path.join(cross_refs_dir, f"{slug}.md") + + # INDEX edits are computed in memory — no writes here, and none later + # until every move has succeeded, so a filesystem failure can never + # leave a corrupted INDEX behind. + new_index = None + fragments = {"own_row": None, "own_overlaps": [], "inbound": []} + if os.path.isfile(index_path): + with open(index_path, "r") as f: + new_index, fragments = parse_index(f.read(), slug) + + # Resolve the non-colliding destination. Deliberately no mkdir — a plan + # must leave the filesystem untouched, including `.archive/` itself. + archive_base = os.path.join(learning_root, ".archive") + dest = next_archive_dir(archive_base, slug) + inbound_projects = [item["project"] for item in fragments["inbound"]] + + return { + "slug": slug, + "project_path": project_path, + "archived_dir": dest, + "shard_archived": os.path.isfile(shard_path), + "index_own_row_removed": fragments["own_row"] is not None, + "inbound_refs_scrubbed": inbound_projects, + "inbound_ref_count": len(inbound_projects), + # internals for the executor + "_index_path": index_path, + "_shard_path": shard_path, + "_archive_base": archive_base, + "_new_index": new_index, + "_fragments": fragments, + } + + +def archive_project(learning_root, slug, date=None): + """Archive the Project `slug` by executing a freshly computed plan. + + Returns a summary dict. Raises ValueError if the project does not + resolve to an existing directory (never half-moves).""" + plan = plan_archive(learning_root, slug) + dest = plan["archived_dir"] + fragments = plan["_fragments"] + + # 1. Move the project directory (the big irreversible-ish step). + os.makedirs(plan["_archive_base"], exist_ok=True) + shutil.move(plan["project_path"], dest) + + # 2. Co-locate the cross-ref shard, if any. + if plan["shard_archived"]: + shutil.move(plan["_shard_path"], os.path.join(dest, "cross-refs.md")) + + # 3. Write the recovery stash + provenance. + meta = { + "original_slug": slug, + "archived_date": date, + "archived_dir": os.path.basename(dest), + "index": { + "own_row": fragments["own_row"], + "own_overlaps": fragments["own_overlaps"], + "inbound_rows": fragments["inbound"], + }, + "inbound_ref_count": plan["inbound_ref_count"], + "shard_archived": plan["shard_archived"], + } + with open(os.path.join(dest, "archive-meta.json"), "w") as f: + json.dump(meta, f, indent=2) + + # 4. Write the scrubbed INDEX last. + if plan["_new_index"] is not None: + with open(plan["_index_path"], "w") as f: + f.write(plan["_new_index"]) + + return _summary(plan, "archived") + + +def main(): + parser = argparse.ArgumentParser(description="Archive a Sage Project.") + parser.add_argument("learning_root", help="Absolute path to the learning root") + parser.add_argument("slug", help="Project slug to archive") + parser.add_argument( + "--date", + default=None, + help="Archived date (YYYY-MM-DD), passed in by the coach for determinism", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the archive plan as JSON without touching disk. The coach " + "uses this to build its confirmation prompt from computed facts.", + ) + args = parser.parse_args() + + try: + if args.dry_run: + summary = _summary(plan_archive(args.learning_root, args.slug), "dry_run") + else: + summary = archive_project(args.learning_root, args.slug, date=args.date) + except ValueError as exc: + print(json.dumps({"status": "error", "error": str(exc)}), file=sys.stderr) + sys.exit(1) + + print(json.dumps(summary, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/session_router.py b/tools/session_router.py index 70956a0..f405f03 100644 --- a/tools/session_router.py +++ b/tools/session_router.py @@ -1,16 +1,21 @@ #!/usr/bin/env python3 """Session router for Sage. -Single entry point for session start. Resolves config, derives slug, -checks for existing journal, and returns structured JSON so the coach -can branch on mode without multiple tool calls. +Single entry point for every `/sage` invocation. The grammar is +`/sage ` with exactly two verbs, `learn` and `archive` +(see adr/0002-mandatory-command-verbs.md). The router parses the verb, +resolves config, and returns structured JSON so the coach can branch on +`mode` without multiple tool calls. It is a read-only dispatcher — it +never mutates the filesystem. Archival mutation lives in +`archive_project.py`, which the coach invokes on `mode: "archive"`. Usage: - python3 session_router.py + python3 session_router.py [topic...] Zero external dependencies — Python 3.8+ stdlib only. """ +import difflib import json import os import re @@ -20,27 +25,53 @@ from config import get_learning_root -RESUME_KEYWORDS = {"continue", "resume", "pick", "list"} +# The only two valid verbs. Everything else is an error. +VERBS = {"learn", "archive"} +# Pre-verb-grammar resume keywords. No longer valid; kept only so the +# router can point users at the new `learn` verb. +LEGACY_RESUME_KEYWORDS = {"continue", "resume", "pick", "list"} -def is_resume_intent(topic): - """Check if topic is a resume keyword rather than a real topic.""" - return topic.lower().strip() in RESUME_KEYWORDS +def parse_invocation(raw): + """Split raw args into (verb, topic_string). Verb is lowercased.""" + parts = raw.strip().split() + if not parts: + return "", "" + verb = parts[0].lower() + rest = " ".join(parts[1:]).strip() + return verb, rest -def list_projects(learning_root): - """Scan for existing projects with learning artifacts. Returns list sorted by last session date.""" + +def list_projects(learning_root, require="journal"): + """Scan for existing projects. Returns list sorted by last session date. + + `require` selects the existence predicate, because the two pickers ask + different questions: + - "journal" (default) — projects with session history, i.e. something + to *resume*. Gate: `learning/journal/index.md`. Used by `learn`. + - "plan" — any *initialized* project, whether or not a session ever + ran. Gate: `learning/plan.md`. Used by `archive`, so a project you + set up but never started is still archivable. + """ + marker = os.path.join("journal", "index.md") if require == "journal" else "plan.md" projects = [] if not os.path.isdir(learning_root): return projects for entry in sorted(os.listdir(learning_root)): + # Skip hidden entries (notably `.archive/`, where archived + # projects live) so archived projects never appear in discovery. + if entry.startswith("."): + continue project_learning = os.path.join(learning_root, entry, "learning") - journal_index = os.path.join(project_learning, "journal", "index.md") - if not os.path.isfile(journal_index): + if not os.path.isfile(os.path.join(project_learning, marker)): continue + # Best-effort last-session date from the journal index (a plan-only + # project has none — it sorts last). last_date = None + journal_index = os.path.join(project_learning, "journal", "index.md") try: with open(journal_index, "r") as f: for line in f: @@ -76,69 +107,146 @@ def find_journal(topic_path): return None -def route(sage_root, topic): - """Resolve session mode and return structured result.""" +def find_plan(topic_path): + """Check for a learning plan — the marker of an initialized project. + + Returns the path if found, None otherwise. This is the archivability + predicate: a project counts as archivable once it has a plan, even if + no session ever ran.""" + plan = os.path.join(topic_path, "plan.md") + if os.path.isfile(plan): + return plan + return None + + +def suggest_slug(slug, learning_root): + """Closest archivable project slug to a no-match archive target, or None.""" + existing = [p["slug"] for p in list_projects(learning_root, require="plan")] + matches = difflib.get_close_matches(slug, existing, n=1, cutoff=0.6) + return matches[0] if matches else None + + +def _unknown_verb(verb, topic, sage_root): + """Build a helpful error for an unrecognized leading verb.""" + if verb in LEGACY_RESUME_KEYWORDS: + hint = f"/sage learn {topic}".strip() + message = f"'{verb}' is no longer a command. Did you mean `{hint}`?" + suggestion = "learn" + else: + # Most likely a legacy bare-topic invocation like `/sage react hooks`. + full = f"{verb} {topic}".strip() + message = ( + f"Unknown verb '{verb}'. Commands now require a verb: " + f"`/sage learn {full}` to learn it, or `/sage archive ` to archive it." + ) + suggestion = None + return { + "mode": "unknown_verb", + "verb": verb, + "suggestion": suggestion, + "message": message, + "sage_root": sage_root, + } + + +def route(sage_root, raw_args): + """Resolve invocation mode and return structured result.""" + verb, topic = parse_invocation(raw_args) learning_root = get_learning_root() - if is_resume_intent(topic): - if not learning_root: - return { - "mode": "needs_config", - "slug": "", - "sage_root": sage_root, - } - projects = list_projects(str(learning_root)) + if verb == "": return { - "mode": "pick", - "projects": projects, - "learning_root": str(learning_root), + "mode": "unknown_verb", + "verb": "", + "suggestion": None, + "message": "Usage: `/sage learn ` or `/sage archive `.", "sage_root": sage_root, } - slug = derive_slug(topic) + if verb not in VERBS: + return _unknown_verb(verb, topic, sage_root) if not learning_root: return { "mode": "needs_config", - "slug": slug, + "verb": verb, "sage_root": sage_root, } learning_root_str = str(learning_root) - topic_path = os.path.join(learning_root_str, slug, "learning") + # Bare verb with no topic → the project picker. `action` tells the + # coach what to do with the learner's selection. `learn` lists + # resumable projects (journal); `archive` lists any initialized + # project (plan). + if not topic: + return { + "mode": "pick", + "action": verb, + "projects": list_projects( + learning_root_str, require="plan" if verb == "archive" else "journal" + ), + "learning_root": learning_root_str, + "sage_root": sage_root, + } + + slug = derive_slug(topic) + project_path = os.path.join(learning_root_str, slug) + topic_path = os.path.join(project_path, "learning") journal = find_journal(topic_path) - if journal: - has_insights = os.path.isfile( - os.path.join(topic_path, "coach-insights.md") - ) + + if verb == "learn": + if journal: + has_insights = os.path.isfile( + os.path.join(topic_path, "coach-insights.md") + ) + return { + "mode": "resume", + "slug": slug, + "learning_root": learning_root_str, + "topic_path": topic_path, + "project_path": project_path, + "sage_root": sage_root, + "has_coach_insights": has_insights, + } return { - "mode": "resume", + "mode": "fresh", "slug": slug, "learning_root": learning_root_str, "topic_path": topic_path, + "project_path": project_path, "sage_root": sage_root, - "has_coach_insights": has_insights, } + # verb == "archive": target must resolve to an initialized project + # (has a learning plan) — session history is not required. + if not find_plan(topic_path): + return { + "mode": "archive_no_match", + "slug": slug, + "suggestion": suggest_slug(slug, learning_root_str), + "learning_root": learning_root_str, + "sage_root": sage_root, + } return { - "mode": "fresh", + "mode": "archive", "slug": slug, "learning_root": learning_root_str, "topic_path": topic_path, + "project_path": project_path, "sage_root": sage_root, } def main(): if len(sys.argv) < 3: - print("Usage: session_router.py ", file=sys.stderr) + print("Usage: session_router.py [topic...]", file=sys.stderr) sys.exit(1) sage_root = sys.argv[1] - topic = " ".join(sys.argv[2:]) + raw_args = " ".join(sys.argv[2:]) - result = route(sage_root, topic) + result = route(sage_root, raw_args) print(json.dumps(result, indent=2))