From dc6f7ca0b5aaf87caebda0573cbb9d9e010f17b3 Mon Sep 17 00:00:00 2001 From: 0-BSCode Date: Wed, 29 Jul 2026 21:11:30 +0800 Subject: [PATCH 1/6] Move assessment engine tests --- tests/conftest.py | 2 +- {tools/assessment => tests}/test_assessment_engine.py | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) rename {tools/assessment => tests}/test_assessment_engine.py (99%) diff --git a/tests/conftest.py b/tests/conftest.py index 8693af0..1cc0b4e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,6 @@ if p not in sys.path: sys.path.insert(0, p) -# Top-level tools/ for session_duration, mcp_server +# Top-level tools/ for session_duration if str(TOOLS_DIR) not in sys.path: sys.path.insert(0, str(TOOLS_DIR)) diff --git a/tools/assessment/test_assessment_engine.py b/tests/test_assessment_engine.py similarity index 99% rename from tools/assessment/test_assessment_engine.py rename to tests/test_assessment_engine.py index 4651780..a256508 100644 --- a/tools/assessment/test_assessment_engine.py +++ b/tests/test_assessment_engine.py @@ -2,8 +2,7 @@ """Tests for the Assessment Engine. Covers: AdaptiveSelector, QuestionBank, KnowledgeMapReader, CLI end-to-end. -Run: python3 -m pytest tools/assessment/test_assessment_engine.py -v - or: python3 -m unittest tools/assessment/test_assessment_engine.py -v +Run: python3 -m pytest tests/test_assessment_engine.py -v """ import json @@ -14,8 +13,6 @@ import unittest from pathlib import Path -# Ensure the assessment engine module is importable -sys.path.insert(0, str(Path(__file__).parent)) from assessment_engine import ( AdaptiveSelector, KnowledgeMapReader, @@ -27,7 +24,7 @@ QUESTION_TYPES, ) -ENGINE_PATH = str(Path(__file__).parent / "assessment_engine.py") +ENGINE_PATH = str(Path(__file__).resolve().parent.parent / "tools" / "assessment" / "assessment_engine.py") # --------------------------------------------------------------------------- From 0637a9ab9145551da6b3c71450625c4a591d62ab Mon Sep 17 00:00:00 2001 From: 0-BSCode Date: Wed, 29 Jul 2026 21:29:54 +0800 Subject: [PATCH 2/6] Delete uninvoked tool subcommands --- agents/artifact-clerk.md | 5 +- agents/assessment-agent.md | 21 ++ agents/demo-generator.md | 4 + tests/test_assessment_engine.py | 130 +--------- tests/test_coach_metrics.py | 72 ------ tests/test_find_duplicate_cards.py | 267 ------------------- tests/test_journal_writer.py | 78 ------ tests/test_weak_spot_writer.py | 29 +-- tools/assessment/assessment_engine.py | 163 +----------- tools/coach/coach_metrics.py | 125 +-------- tools/srs/find_duplicate_cards.py | 156 ----------- tools/srs/journal_writer.py | 57 ---- tools/srs/kmap_writer.py | 163 +----------- tools/srs/srs_engine.py | 9 +- tools/srs/weak_spot_writer.py | 360 ++------------------------ 15 files changed, 65 insertions(+), 1574 deletions(-) delete mode 100644 tests/test_find_duplicate_cards.py delete mode 100644 tools/srs/find_duplicate_cards.py diff --git a/agents/artifact-clerk.md b/agents/artifact-clerk.md index 6954212..a654bc4 100644 --- a/agents/artifact-clerk.md +++ b/agents/artifact-clerk.md @@ -423,6 +423,9 @@ Where `` is: python3 "$SAGE_ROOT/tools/srs/card_writer.py" fix /cards.md ``` This normalizes all cards to canonical compact format. Run this even if no new cards were added — it catches drift from prior sessions. +- **Manual repair only:** if a hand-edit is suspected of breaking `cards.md`, + `card_writer.py validate /cards.md` reports what is wrong without + changing anything. It is a diagnostic — `fix` is what repairs. ### Step 5: Run SRS sync ```bash @@ -728,7 +731,7 @@ Duration: All artifact formats are defined in the Sage skill. You must match them exactly: - Journal entry format: One file per session in `journal/session-NN.md`, starting with `## Session N — YYYY-MM-DD` with subsections - Journal index: NEVER write to `journal/index.md` directly. Use `journal_writer.py append --stdin`. Canonical 8-column format: `| # | Date | Type | Focus | Reviews | Avg Grade | Summary | File |`. -- Knowledge map: markdown table with columns `| Concept | Status | Introduced | Last Tested | Notes |`. The `Introduced` column is set once when a concept is first added (`S` or `prior`) and never modified. Concepts table rows are managed by `kmap_writer.py` — use `add-concept` to add new rows and `update-status` to change status/last-tested/notes (preserves Introduced automatically). Status legend and Status Changelog use `fix-legend`, `ensure-sections`, `changelog-append` subcommands. Weak spot tracking has moved to `weak-spots.md` via `weak_spot_writer.py`. +- Knowledge map: markdown table with columns `| Concept | Status | Introduced | Last Tested | Notes |`. The `Introduced` column is set once when a concept is first added (`S` or `prior`) and never modified. Concepts table rows are managed by `kmap_writer.py` — use `add-concept` to add new rows and `update-status` to change status/last-tested/notes (preserves Introduced automatically). The Status Changelog uses the `changelog-append` subcommand. Weak spot tracking has moved to `weak-spots.md` via `weak_spot_writer.py`. - Cross-refs: table columns are exactly `| Concept | Also Covered In | Status | Notes |`. Do not rename or reorder columns. - Cards: NEVER write to `cards.md` directly. Use `card_writer.py append --stdin`. Canonical format: `**Q:**`, `**A:**`, `**Tags:**`. - Weak spots and coach errors: NEVER write entries directly to `weak-spots.md` or `coach-errors.md`. Use `weak_spot_writer.py append --kind --stdin`. Kind routes the entry to the correct file and prefix namespace. The writer refuses to write a coach entry to `weak-spots.md` and vice versa. Canonical formats: learner `## WS-[N] — [description]` (with Category, Correct model, History subsection), coach content `## CE-[N] — [description]`, coach process `## CP-[N] — [description]`. WS field set: Category, Session, Last tested, What happened, Correct model, Why it matters, Cards, Concepts, Status + History subsection. CE/CP field set: Session, What happened, Root cause, Correction, Why it matters, Follow-up, Source, Cards, Status. diff --git a/agents/assessment-agent.md b/agents/assessment-agent.md index ddf1c2e..9283f78 100644 --- a/agents/assessment-agent.md +++ b/agents/assessment-agent.md @@ -15,6 +15,27 @@ SAGE_ROOT=$(cat /tmp/.sage-plugin-root) ``` Then use `$SAGE_ROOT/tools/...` in all subsequent commands within the same bash call. +## The Question Bank + +Every operation below reads or writes `questions.json`, the per-topic question +bank. It does not exist until it is created, and every other subcommand fails +with `Error: … not found. Run \`init\` first.` until it does. + +**Create it once, on first use for a topic:** +```bash +SAGE_ROOT=$(cat /tmp/.sage-plugin-root) +python3 "$SAGE_ROOT/tools/assessment/assessment_engine.py" init +``` +`init` reads the topic's `knowledge-map.md` and seeds one coverage entry per +concept. It refuses to overwrite an existing bank without `--force`, so running +it when unsure is safe. + +**`` is always the `learning/` directory** — `/learning/`, +never the topic directory above it. The engine resolves any non-directory +argument to its parent, so a path pointed one level too high silently creates a +*second*, empty bank at `/questions.json` while the real one sits +untouched in `learning/`. Two banks, split state, no error. Pass `learning/`. + ## Operations You support four operations, determined by the `Operation:` field in your prompt. diff --git a/agents/demo-generator.md b/agents/demo-generator.md index 0cf6292..667714c 100644 --- a/agents/demo-generator.md +++ b/agents/demo-generator.md @@ -174,6 +174,10 @@ Where `` is: ``` All fields except `related_reference` are required. If no related reference doc exists, omit the field or pass an empty string. +If the index looks wrong — a demo file listed that no longer exists, or an entry +that vanished — `demo_index_writer.py validate /docs/demos/` reports the +problems without modifying anything. + ### Step 8: Return confirmation ```markdown diff --git a/tests/test_assessment_engine.py b/tests/test_assessment_engine.py index a256508..2e3642b 100644 --- a/tests/test_assessment_engine.py +++ b/tests/test_assessment_engine.py @@ -250,37 +250,6 @@ def test_next_id_after_adds(self): QuestionBank.add_question(bank, "b", 2, "conceptual", "Q2?", "A2") self.assertEqual(QuestionBank.next_id(bank), "q-3") - def test_calibrate_from_results(self): - bank = QuestionBank.create("test", {}, "2026-02-12") - # Add questions at different difficulties - QuestionBank.add_question(bank, "easy", 1, "free_recall", "Q1?", "A1") - QuestionBank.add_question(bank, "hard", 4, "analysis", "Q2?", "A2") - # Easy one: correct. Hard one: incorrect. - QuestionBank.record_result(bank, "q-1", 1, today="2026-02-12") - QuestionBank.record_result(bank, "q-2", 0, today="2026-02-12") - level = QuestionBank.calibrate(bank) - # Easy correct (d=1, weight=1, contributes 1*1=1), hard incorrect (d=4, weight=4, contributes 0) - # total_weighted=1, total_weight=1+16=17... wait, let me re-check the algorithm - # weight = difficulty, if correct: total_weighted += difficulty * weight = d^2 - # q-1: d=1, correct => weighted += 1*1=1, weight += 1 - # q-2: d=4, incorrect => weighted += 0, weight += 4 - # level = 1/5 = 0.2 -> clamped to 1.0 - self.assertEqual(level, 1.0) - - def test_calibrate_all_correct_high_difficulty(self): - bank = QuestionBank.create("test", {}, "2026-02-12") - QuestionBank.add_question(bank, "hard", 5, "transfer", "Q?", "A") - QuestionBank.record_result(bank, "q-1", 1, today="2026-02-12") - level = QuestionBank.calibrate(bank) - # d=5, correct => weighted += 25, weight += 5 => 25/5 = 5.0 - self.assertEqual(level, 5.0) - - def test_calibrate_no_results(self): - bank = QuestionBank.create("test", {}, "2026-02-12") - QuestionBank.add_question(bank, "foo", 2, "conceptual", "Q?", "A") - level = QuestionBank.calibrate(bank) - self.assertEqual(level, 3.0) # default when no results - # --------------------------------------------------------------------------- # TestAdaptiveSelector @@ -555,22 +524,6 @@ def test_select_min_mastery_introduced_only_excludes_not_started(self): class TestFormatters(unittest.TestCase): - def test_coverage_format(self): - bank = QuestionBank.create("test", {"foo": {"status": "introduced"}}, "2026-02-12") - QuestionBank.add_question(bank, "foo", 2, "conceptual", "Q?", "A", today="2026-02-12") - output = MarkdownFormatter.coverage(bank) - self.assertIn("Assessment Coverage", output) - self.assertIn("foo", output) - self.assertIn("1", output) # 1 question - - def test_stats_format(self): - bank = QuestionBank.create("test", {}, "2026-02-12") - QuestionBank.add_question(bank, "foo", 2, "conceptual", "Q?", "A", today="2026-02-12") - QuestionBank.record_result(bank, "q-1", 1, today="2026-02-12") - output = MarkdownFormatter.stats(bank) - self.assertIn("Assessment Statistics", output) - self.assertIn("1", output) - def test_select_list_empty(self): output = MarkdownFormatter.select_list([]) self.assertIn("No questions", output) @@ -756,59 +709,8 @@ def test_record_with_json_output(self): self.assertEqual(data["score"], 0) self.assertEqual(data["quality"], "wrong") - def test_coverage_report_format(self): - with tempfile.TemporaryDirectory() as tmpdir: - topic_dir = make_topic_dir(tmpdir) - self._run_cmd(["init", str(topic_dir)]) - self._run_cmd([ - "add", str(topic_dir), - "--concept", "closures", "--difficulty", "2", - "--type", "conceptual", "--text", "Q?", "--answer", "A", - ]) - result = self._run_cmd(["coverage", str(topic_dir)]) - self.assertEqual(result.returncode, 0) - self.assertIn("Coverage", result.stdout) - self.assertIn("closures", result.stdout) - - def test_stats_report_format(self): - with tempfile.TemporaryDirectory() as tmpdir: - topic_dir = make_topic_dir(tmpdir) - self._run_cmd(["init", str(topic_dir)]) - result = self._run_cmd(["stats", str(topic_dir)]) - self.assertEqual(result.returncode, 0) - self.assertIn("Statistics", result.stdout) - - def test_calibrate_recomputes_level(self): - with tempfile.TemporaryDirectory() as tmpdir: - topic_dir = make_topic_dir(tmpdir) - self._run_cmd(["init", str(topic_dir)]) - self._run_cmd([ - "add", str(topic_dir), - "--concept", "closures", "--difficulty", "3", - "--type", "application", "--text", "Q?", "--answer", "A", - ]) - self._run_cmd(["record", str(topic_dir), "q-1", "1", "--quality", "strong"]) - result = self._run_cmd(["calibrate", str(topic_dir)]) - self.assertEqual(result.returncode, 0) - self.assertIn("level", result.stdout) - - def test_calibrate_json_output(self): - with tempfile.TemporaryDirectory() as tmpdir: - topic_dir = make_topic_dir(tmpdir) - self._run_cmd(["init", str(topic_dir)]) - self._run_cmd([ - "add", str(topic_dir), - "--concept", "closures", "--difficulty", "3", - "--type", "application", "--text", "Q?", "--answer", "A", - ]) - self._run_cmd(["record", str(topic_dir), "q-1", "1"]) - result = self._run_cmd(["calibrate", str(topic_dir), "--json"]) - self.assertEqual(result.returncode, 0) - data = json.loads(result.stdout) - self.assertIn("level", data) - def test_full_workflow_init_add_select_record(self): - """End-to-end: init -> add -> select -> record -> stats.""" + """End-to-end: init -> add -> select -> record.""" with tempfile.TemporaryDirectory() as tmpdir: topic_dir = make_topic_dir(tmpdir) @@ -845,18 +747,13 @@ def test_full_workflow_init_add_select_record(self): "--quality", "wrong", "--session", "1"]) self.assertEqual(r.returncode, 0) - # Stats - r = self._run_cmd(["stats", str(topic_dir), "--json"]) - self.assertEqual(r.returncode, 0) - stats = json.loads(r.stdout) - self.assertEqual(stats["total"], 3) - self.assertEqual(stats["total_assessments"], 2) - - # Coverage - r = self._run_cmd(["coverage", str(topic_dir), "--json"]) - self.assertEqual(r.returncode, 0) - cov = json.loads(r.stdout) - self.assertIn("closures", cov["coverage"]) + # Bank state reflects every step + bank = json.loads((topic_dir / "questions.json").read_text()) + self.assertEqual(len(bank["questions"]), 3) + self.assertEqual( + sum(q["times_asked"] for q in bank["questions"].values()), 2 + ) + self.assertIn("closures", bank["coverage"]) def test_record_invalid_question_id(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -891,17 +788,6 @@ def test_json_output_all_commands(self): r = self._run_cmd(["record", str(topic_dir), "q-1", "1", "--json"]) json.loads(r.stdout) - # coverage --json - r = self._run_cmd(["coverage", str(topic_dir), "--json"]) - json.loads(r.stdout) - - # stats --json - r = self._run_cmd(["stats", str(topic_dir), "--json"]) - json.loads(r.stdout) - - # calibrate --json - r = self._run_cmd(["calibrate", str(topic_dir), "--json"]) - json.loads(r.stdout) if __name__ == "__main__": diff --git a/tests/test_coach_metrics.py b/tests/test_coach_metrics.py index 561d336..fdc4cdf 100644 --- a/tests/test_coach_metrics.py +++ b/tests/test_coach_metrics.py @@ -243,77 +243,5 @@ def test_snapshot_ignores_table_format_legend(self) -> None: self.assertEqual(data["metrics"]["total_concepts"], 2) -class TrendsTests(unittest.TestCase): - def setUp(self) -> None: - self.tmp = Path(tempfile.mkdtemp(prefix="metrics_")) - self.learning = self.tmp / "learning" - self.learning.mkdir() - (self.learning / "metrics").mkdir() - - def tearDown(self) -> None: - shutil.rmtree(self.tmp, ignore_errors=True) - - def test_trends_insufficient_history(self) -> None: - (self.learning / "metrics" / "history.json").write_text("[]") - result = run_tool("trends", str(self.learning)) - data = json.loads(result.stdout) - self.assertIn("Insufficient", data["message"]) - - def test_trends_with_two_snapshots(self) -> None: - history = [ - {"session": 5, "date": "2026-01-05", "metrics": { - "time_to_solid_avg": 5.0, "review_efficiency": 0.70, - "regression_rate": 0.10, "mastery_velocity_slope": -0.2, - }, "flags": []}, - {"session": 10, "date": "2026-01-10", "metrics": { - "time_to_solid_avg": 4.0, "review_efficiency": 0.75, - "regression_rate": 0.08, "mastery_velocity_slope": -0.3, - }, "flags": []}, - ] - (self.learning / "metrics" / "history.json").write_text(json.dumps(history)) - - result = run_tool("trends", str(self.learning)) - self.assertEqual(result.returncode, 0, result.stderr) - data = json.loads(result.stdout) - self.assertEqual(data["snapshots_analyzed"], 2) - self.assertIn("time_to_solid", data["trends"]) - - -class CompareTests(unittest.TestCase): - def setUp(self) -> None: - self.tmp = Path(tempfile.mkdtemp(prefix="metrics_")) - - for name in ("proj1", "proj2"): - learning = self.tmp / name / "learning" - learning.mkdir(parents=True) - journal = learning / "journal" - journal.mkdir() - (journal / "index.md").write_text(make_journal_index(10)) - - concepts = [ - {"name": "A", "status": "Solid", "introduced": "S1"}, - {"name": "B", "status": "Solid", "introduced": "S2"}, - ] - changelog = [ - {"concept": "A", "from": "Developing", "to": "Solid", "session": 3}, - {"concept": "B", "from": "Developing", "to": "Solid", "session": 5}, - ] - (learning / "knowledge-map.md").write_text(make_kmap(concepts, changelog)) - - def tearDown(self) -> None: - shutil.rmtree(self.tmp, ignore_errors=True) - - def test_compare_two_projects(self) -> None: - p1 = str(self.tmp / "proj1" / "learning") - p2 = str(self.tmp / "proj2" / "learning") - result = run_tool("compare", p1, p2) - self.assertEqual(result.returncode, 0, result.stderr) - data = json.loads(result.stdout) - self.assertIn("project_1", data) - self.assertIn("project_2", data) - self.assertIn("comparison", data) - self.assertEqual(data["comparison"]["faster_mastery"], "tied") - - if __name__ == "__main__": unittest.main() diff --git a/tests/test_find_duplicate_cards.py b/tests/test_find_duplicate_cards.py deleted file mode 100644 index 2cae7e9..0000000 --- a/tests/test_find_duplicate_cards.py +++ /dev/null @@ -1,267 +0,0 @@ -#!/usr/bin/env python3 -"""Regression-safety tests for find_duplicate_cards.py. - -Tests the CLI contract via subprocess — no internal imports. -Verifies duplicate detection, case-insensitive matching, retired-card -exclusion, multi-deck scanning, and edge cases (empty root, no decks). -""" - -import subprocess -import tempfile -import textwrap -import unittest -from pathlib import Path - -TOOL_PATH = str( - Path(__file__).resolve().parent.parent / "tools" / "srs" / "find_duplicate_cards.py" -) - - -def _make_cards_md(cards): - """Build a minimal cards.md from a list of (number, question, retired) tuples.""" - lines = ["# Flashcards: Test Deck", "", "Last updated: 2026-06-01", "", "---", ""] - for number, question, retired in cards: - header = f"### Card {number}" - if retired: - header += " [RETIRED]" - lines.append(header) - lines.append(f"**Q:** {question}") - lines.append(f"**A:** Answer for card {number}") - lines.append("**Tags:** test, type:fact") - lines.append("") - lines.append("---") - lines.append("") - return "\n".join(lines) - - -def _run(root_dir, extra_args=None): - """Run find_duplicate_cards.py via subprocess.""" - cmd = ["python3", TOOL_PATH, "--root", str(root_dir)] - if extra_args: - cmd.extend(extra_args) - result = subprocess.run(cmd, capture_output=True, text=True) - return result - - -class TestFindDuplicateCardsNoDuplicates(unittest.TestCase): - """No duplicates — clean report.""" - - def test_no_duplicates_clean_report(self): - with tempfile.TemporaryDirectory() as root: - deck_dir = Path(root) / "topic-a" / "learning" - deck_dir.mkdir(parents=True) - cards_md = deck_dir / "cards.md" - cards_md.write_text( - _make_cards_md([ - (1, "What is X?", False), - (2, "What is Y?", False), - ]) - ) - - result = _run(root) - - self.assertEqual(result.returncode, 0) - self.assertIn("No duplicates found", result.stdout) - - -class TestFindDuplicateCardsExactDuplicates(unittest.TestCase): - """Exact duplicate questions detected.""" - - def test_exact_duplicate_detected(self): - with tempfile.TemporaryDirectory() as root: - deck_dir = Path(root) / "topic-a" / "learning" - deck_dir.mkdir(parents=True) - cards_md = deck_dir / "cards.md" - cards_md.write_text( - _make_cards_md([ - (1, "What is a closure?", False), - (2, "What is a closure?", False), - (3, "What is a variable?", False), - ]) - ) - - result = _run(root) - - self.assertEqual(result.returncode, 0) - self.assertIn("Duplicate group", result.stdout) - self.assertIn("2 cards", result.stdout) - self.assertIn("card-1", result.stdout) - self.assertIn("card-2", result.stdout) - - -class TestFindDuplicateCardsCaseInsensitive(unittest.TestCase): - """Case-insensitive duplicates detected.""" - - def test_case_insensitive_duplicate(self): - with tempfile.TemporaryDirectory() as root: - deck_dir = Path(root) / "topic-a" / "learning" - deck_dir.mkdir(parents=True) - cards_md = deck_dir / "cards.md" - cards_md.write_text( - _make_cards_md([ - (1, "What is X?", False), - (2, "what is x?", False), - ]) - ) - - result = _run(root) - - self.assertEqual(result.returncode, 0) - self.assertIn("Duplicate group", result.stdout) - self.assertIn("2 cards", result.stdout) - - -class TestFindDuplicateCardsRetiredExcluded(unittest.TestCase): - """Retired cards excluded from comparison.""" - - def test_retired_card_not_counted_as_duplicate(self): - with tempfile.TemporaryDirectory() as root: - deck_dir = Path(root) / "topic-a" / "learning" - deck_dir.mkdir(parents=True) - cards_md = deck_dir / "cards.md" - cards_md.write_text( - _make_cards_md([ - (1, "What is a closure?", True), # retired - (2, "What is a closure?", False), # active — no dup partner - ]) - ) - - result = _run(root) - - self.assertEqual(result.returncode, 0) - self.assertIn("No duplicates found", result.stdout) - - def test_retired_both_no_duplicate_group(self): - """Two retired cards with same question should not form a group.""" - with tempfile.TemporaryDirectory() as root: - deck_dir = Path(root) / "topic-a" / "learning" - deck_dir.mkdir(parents=True) - cards_md = deck_dir / "cards.md" - cards_md.write_text( - _make_cards_md([ - (1, "What is a closure?", True), - (2, "What is a closure?", True), - (3, "What is a variable?", False), - ]) - ) - - result = _run(root) - - self.assertEqual(result.returncode, 0) - self.assertIn("No duplicates found", result.stdout) - - -class TestFindDuplicateCardsMultipleDecks(unittest.TestCase): - """Multiple decks scanned under root.""" - - def test_multiple_decks_scanned(self): - with tempfile.TemporaryDirectory() as root: - # Deck A — no duplicates - deck_a = Path(root) / "topic-a" / "learning" - deck_a.mkdir(parents=True) - (deck_a / "cards.md").write_text( - _make_cards_md([ - (1, "What is X?", False), - (2, "What is Y?", False), - ]) - ) - - # Deck B — has duplicates - deck_b = Path(root) / "topic-b" / "learning" - deck_b.mkdir(parents=True) - (deck_b / "cards.md").write_text( - _make_cards_md([ - (1, "What is Z?", False), - (2, "What is Z?", False), - ]) - ) - - result = _run(root) - - self.assertEqual(result.returncode, 0) - self.assertIn("Scanning 2 deck(s)", result.stdout) - self.assertIn("topic-a", result.stdout) - self.assertIn("topic-b", result.stdout) - # Summary should show 1 deck with duplicates - self.assertIn("Decks with duplicates: 1", result.stdout) - - def test_summary_footer_present(self): - with tempfile.TemporaryDirectory() as root: - deck_dir = Path(root) / "topic-a" / "learning" - deck_dir.mkdir(parents=True) - (deck_dir / "cards.md").write_text( - _make_cards_md([ - (1, "What is X?", False), - ]) - ) - - result = _run(root) - - self.assertEqual(result.returncode, 0) - self.assertIn("SUMMARY", result.stdout) - self.assertIn("Decks scanned:", result.stdout) - - -class TestFindDuplicateCardsNoDecks(unittest.TestCase): - """No decks found — informative message.""" - - def test_no_decks_found_message(self): - with tempfile.TemporaryDirectory() as root: - # Root exists but has no */learning/cards.md structure - result = _run(root) - - self.assertEqual(result.returncode, 0) - self.assertIn("No decks found", result.stdout) - - -class TestFindDuplicateCardsEmptyRoot(unittest.TestCase): - """Empty root directory.""" - - def test_empty_root_directory(self): - with tempfile.TemporaryDirectory() as root: - result = _run(root) - - self.assertEqual(result.returncode, 0) - self.assertIn("No decks found", result.stdout) - - def test_nonexistent_root_exits_with_error(self): - result = _run("/tmp/nonexistent_root_12345678") - - self.assertEqual(result.returncode, 1) - self.assertIn("does not exist", result.stderr) - - -class TestFindDuplicateCardsExitCode(unittest.TestCase): - """Exit code is always 0 for valid roots (report tool, not CI gate).""" - - def test_exit_zero_with_duplicates(self): - with tempfile.TemporaryDirectory() as root: - deck_dir = Path(root) / "topic-a" / "learning" - deck_dir.mkdir(parents=True) - (deck_dir / "cards.md").write_text( - _make_cards_md([ - (1, "Same question", False), - (2, "Same question", False), - ]) - ) - - result = _run(root) - self.assertEqual(result.returncode, 0) - - def test_exit_zero_without_duplicates(self): - with tempfile.TemporaryDirectory() as root: - deck_dir = Path(root) / "topic-a" / "learning" - deck_dir.mkdir(parents=True) - (deck_dir / "cards.md").write_text( - _make_cards_md([ - (1, "Question A", False), - (2, "Question B", False), - ]) - ) - - result = _run(root) - self.assertEqual(result.returncode, 0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_journal_writer.py b/tests/test_journal_writer.py index 024b6ff..646d850 100644 --- a/tests/test_journal_writer.py +++ b/tests/test_journal_writer.py @@ -31,27 +31,6 @@ | 2 | 2026-06-02 | review | Closures | 3 | 3.67 | Review session | session-02.md | """ -# A 5-column file missing Reviews, Avg Grade, Summary — validate must reject it -FEWER_COLUMNS_FILE = """\ -# Session Index - -| # | Date | Type | Focus | File | -|---|---|---|---|---| -| 1 | 2026-06-01 | learn | React Hooks | session-01.md | -| 2 | 2026-06-02 | review | Closures | session-02.md | -""" - -# A malformed file with wrong column count in a row -MALFORMED_FILE = """\ -# Session Index - -| # | Date | Type | Focus | Reviews | Avg Grade | Summary | File | -|---|---|---|---|---|---|---|---| -| 1 | 2026-06-01 | learn | React Hooks | 5 | 4.20 | First session | session-01.md | -| 2 | 2026-06-02 | review | -""" - - def _run(args, stdin_data=None): """Run the journal_writer CLI and return the CompletedProcess.""" cmd = ["python3", TOOL_PATH] + args @@ -232,56 +211,6 @@ def test_session_number_with_suffix(self): self.assertIn("session-3b.md", content) -class TestValidatePassesOnWellFormatted(unittest.TestCase): - """validate — passes on well-formatted file.""" - - def setUp(self): - self.tmpdir = tempfile.mkdtemp() - self.index_path = os.path.join(self.tmpdir, "index.md") - Path(self.index_path).write_text(WELL_FORMATTED_FILE) - - def tearDown(self): - shutil.rmtree(self.tmpdir) - - def test_returns_zero_on_valid_file(self): - result = _run(["validate", self.index_path]) - - self.assertEqual(result.returncode, 0) - self.assertIn("OK", result.stdout) - self.assertIn("no format violations", result.stdout) - - -class TestValidateReportsViolations(unittest.TestCase): - """validate — reports violations on malformed file.""" - - def setUp(self): - self.tmpdir = tempfile.mkdtemp() - self.index_path = os.path.join(self.tmpdir, "index.md") - - def tearDown(self): - shutil.rmtree(self.tmpdir) - - def test_reports_column_count_mismatch(self): - Path(self.index_path).write_text(MALFORMED_FILE) - result = _run(["validate", self.index_path]) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("issue", result.stdout.lower()) - - def test_reports_missing_columns(self): - Path(self.index_path).write_text(FEWER_COLUMNS_FILE) - result = _run(["validate", self.index_path]) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("Missing columns", result.stdout) - - def test_missing_file_exits_nonzero(self): - result = _run(["validate", os.path.join(self.tmpdir, "nonexistent.md")]) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("does not exist", result.stderr) - - class TestErrorCases(unittest.TestCase): """Error cases: invalid JSON, missing file, missing args.""" @@ -312,13 +241,6 @@ def test_append_without_json_or_stdin_exits_nonzero(self): self.assertNotEqual(result.returncode, 0) self.assertIn("--json", result.stderr) - def test_validate_missing_file_exits_nonzero(self): - path = os.path.join(self.tmpdir, "nonexistent.md") - result = _run(["validate", path]) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("does not exist", result.stderr) - def test_no_command_exits_nonzero(self): result = _run([]) diff --git a/tests/test_weak_spot_writer.py b/tests/test_weak_spot_writer.py index f489e35..7516b57 100644 --- a/tests/test_weak_spot_writer.py +++ b/tests/test_weak_spot_writer.py @@ -366,32 +366,10 @@ def test_all_valid_categories_accepted(self) -> None: shutil.rmtree(tmp, ignore_errors=True) # ------------------------------------------------------------------ - # Validate command + # History round-trip # ------------------------------------------------------------------ - def test_validate_kind_ws_passes_on_clean_file(self) -> None: - run_writer( - "append", str(self.tmp), "--kind", "WS", "--stdin", - stdin=make_ws_entry("clean entry"), - ) - result = run_writer("validate", str(self.tmp), "--kind", "WS") - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - self.assertIn("OK", result.stdout) - - def test_validate_kind_ce_passes_on_clean_file(self) -> None: - run_writer( - "append", str(self.tmp), "--kind", "CE", "--stdin", - stdin=make_ce_entry("clean entry"), - ) - result = run_writer("validate", str(self.tmp), "--kind", "CE") - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - self.assertIn("OK", result.stdout) - - # ------------------------------------------------------------------ - # Fix command preserves History - # ------------------------------------------------------------------ - - def test_fix_preserves_history(self) -> None: + def test_append_preserves_history(self) -> None: entry = json.dumps({ "description": "Gap", "session": 3, @@ -401,11 +379,10 @@ def test_fix_preserves_history(self) -> None: "status": "active", "history": "Confused X with Y", }) - run_writer( + result = run_writer( "append", str(self.tmp), "--kind", "WS", "--stdin", stdin=entry, ) - result = run_writer("fix", str(self.tmp), "--kind", "WS") self.assertEqual(result.returncode, 0, result.stderr) body = (self.tmp / "weak-spots.md").read_text() self.assertIn("### History", body) diff --git a/tools/assessment/assessment_engine.py b/tools/assessment/assessment_engine.py index 570df9f..80ddd07 100755 --- a/tools/assessment/assessment_engine.py +++ b/tools/assessment/assessment_engine.py @@ -2,8 +2,8 @@ """Assessment Engine — Adaptive question bank management for Sage. Manages a per-topic question bank (questions.json), implements adaptive -selection based on concept mastery and learner performance, records results, -and tracks coverage. +selection based on concept mastery and learner performance, and records +results. Commands: init Create questions.json from knowledge-map @@ -11,9 +11,6 @@ add-batch Add questions from stdin (JSON array) select [--concept C] [--count N] [--min-mastery L] Adaptive question selection record [--session N] [--quality Q] [--notes TEXT] - coverage Per-concept coverage report - stats Aggregate statistics - calibrate Recompute learner calibration All commands output markdown by default, --json for machine-readable output. Zero external dependencies — Python 3.8+ stdlib only. @@ -238,34 +235,6 @@ def record_result(bank: Dict[str, Any], qid: str, score: int, cal["total_questions_answered"] += 1 bank["last_updated"] = today - @staticmethod - def calibrate(bank: Dict[str, Any]) -> float: - """Recompute learner calibration from all result history.""" - total_weighted = 0.0 - total_weight = 0.0 - for q in bank["questions"].values(): - if q["retired"] or q["times_asked"] == 0: - continue - difficulty = q["difficulty"] - for result in q["result_history"]: - weight = difficulty # harder questions weigh more - if result["score"] > 0: - total_weighted += difficulty * weight - total_weight += weight - - if total_weight > 0: - level = round(total_weighted / total_weight, 2) - level = max(1.0, min(5.0, level)) - else: - level = 3.0 - - bank["learner_calibration"]["estimated_level"] = level - bank["learner_calibration"]["level_history"].append({ - "date": date.today().isoformat(), - "level": level, - }) - return level - # --------------------------------------------------------------------------- # Adaptive Selector — stateless selection algorithm @@ -509,68 +478,6 @@ def record_result(qid: str, score: int, quality: str) -> str: status = "correct" if score > 0 else "incorrect" return f"Recorded: **{qid}** — {status} ({quality})" - @staticmethod - def coverage(bank: Dict[str, Any]) -> str: - cov = bank.get("coverage", {}) - if not cov: - return "No coverage data. Run `init` first." - lines = ["## Assessment Coverage", ""] - lines.append("| Concept | Questions | By Difficulty | Last Assessed | Assessments |") - lines.append("|---------|-----------|---------------|---------------|-------------|") - for concept, data in sorted(cov.items()): - total = data.get("total_questions", 0) - by_diff = data.get("questions_by_difficulty", {}) - diff_str = ", ".join(f"D{k}:{v}" for k, v in sorted(by_diff.items())) - last = data.get("last_assessed") or "never" - count = data.get("assessment_count", 0) - lines.append(f"| {concept} | {total} | {diff_str} | {last} | {count} |") - - # Summary - total_q = sum(d.get("total_questions", 0) for d in cov.values()) - never_assessed = sum(1 for d in cov.values() if not d.get("last_assessed")) - lines.append("") - lines.append(f"**Total:** {total_q} questions across {len(cov)} concepts. {never_assessed} concepts never assessed.") - return "\n".join(lines) - - @staticmethod - def stats(bank: Dict[str, Any]) -> str: - questions = bank.get("questions", {}) - active = {k: v for k, v in questions.items() if not v.get("retired")} - retired = len(questions) - len(active) - cal = bank.get("learner_calibration", {}) - - by_diff: Dict[int, int] = {} - by_type: Dict[str, int] = {} - total_asked = 0 - total_correct = 0 - for q in active.values(): - d = q.get("difficulty", 0) - by_diff[d] = by_diff.get(d, 0) + 1 - t = q.get("question_type", "unknown") - by_type[t] = by_type.get(t, 0) + 1 - total_asked += q.get("times_asked", 0) - total_correct += q.get("times_correct", 0) - - overall_rate = round(total_correct / total_asked, 2) if total_asked > 0 else 0.0 - - lines = [ - f"## Assessment Statistics: {bank.get('topic', 'unknown')}", - "", - f"- **Total questions:** {len(questions)} ({len(active)} active, {retired} retired)", - f"- **By difficulty:** {', '.join(f'D{k}: {v}' for k, v in sorted(by_diff.items()))}", - f"- **By type:** {', '.join(f'{k}: {v}' for k, v in sorted(by_type.items()))}", - f"- **Total assessments:** {total_asked}", - f"- **Overall success rate:** {overall_rate}", - f"- **Learner level:** {cal.get('estimated_level', '?')}", - f"- **Created:** {bank.get('created', '?')}", - f"- **Last updated:** {bank.get('last_updated', '?')}", - ] - return "\n".join(lines) - - @staticmethod - def calibrate_result(level: float) -> str: - return f"Learner calibration recomputed: **level {level:.2f}**" - class JsonFormatter: @staticmethod @@ -714,54 +621,6 @@ def cmd_record(args: argparse.Namespace) -> str: return MarkdownFormatter.record_result(qid, score, quality) -def cmd_coverage(args: argparse.Namespace) -> str: - path = _resolve_path(args.path) - bp = QuestionBank.bank_path(path) - if not bp.exists(): - return f"Error: {bp} not found. Run `init` first." - - bank = QuestionBank.load(bp) - if args.json: - return JsonFormatter.output({"action": "coverage", "coverage": bank.get("coverage", {})}) - return MarkdownFormatter.coverage(bank) - - -def cmd_stats(args: argparse.Namespace) -> str: - path = _resolve_path(args.path) - bp = QuestionBank.bank_path(path) - if not bp.exists(): - return f"Error: {bp} not found. Run `init` first." - - bank = QuestionBank.load(bp) - if args.json: - questions = bank["questions"] - active = {k: v for k, v in questions.items() if not v.get("retired")} - return JsonFormatter.output({ - "action": "stats", - "total": len(questions), - "active": len(active), - "retired": len(questions) - len(active), - "total_assessments": sum(q.get("times_asked", 0) for q in active.values()), - "learner_level": bank.get("learner_calibration", {}).get("estimated_level"), - }) - return MarkdownFormatter.stats(bank) - - -def cmd_calibrate(args: argparse.Namespace) -> str: - path = _resolve_path(args.path) - bp = QuestionBank.bank_path(path) - if not bp.exists(): - return f"Error: {bp} not found. Run `init` first." - - bank = QuestionBank.load(bp) - level = QuestionBank.calibrate(bank) - QuestionBank.save(bp, bank) - - if args.json: - return JsonFormatter.output({"action": "calibrate", "level": level}) - return MarkdownFormatter.calibrate_result(level) - - # --------------------------------------------------------------------------- # CLI Entry Point # --------------------------------------------------------------------------- @@ -815,21 +674,6 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--notes", default="") p.add_argument("--json", action="store_true") - # coverage - p = subparsers.add_parser("coverage", help="Coverage report") - p.add_argument("path") - p.add_argument("--json", action="store_true") - - # stats - p = subparsers.add_parser("stats", help="Aggregate statistics") - p.add_argument("path") - p.add_argument("--json", action="store_true") - - # calibrate - p = subparsers.add_parser("calibrate", help="Recompute learner calibration") - p.add_argument("path") - p.add_argument("--json", action="store_true") - return parser @@ -839,9 +683,6 @@ def build_parser() -> argparse.ArgumentParser: "add-batch": cmd_add_batch, "select": cmd_select, "record": cmd_record, - "coverage": cmd_coverage, - "stats": cmd_stats, - "calibrate": cmd_calibrate, } diff --git a/tools/coach/coach_metrics.py b/tools/coach/coach_metrics.py index 62c8746..798bb0a 100644 --- a/tools/coach/coach_metrics.py +++ b/tools/coach/coach_metrics.py @@ -9,8 +9,6 @@ Commands: snapshot Compute metrics, write history + dashboard - trends Analyze metric trends from history - compare Compare metrics across two projects Zero external dependencies — Python 3.8+ stdlib only. """ @@ -533,122 +531,14 @@ def cmd_snapshot(path: Path) -> None: print(json.dumps(snapshot, indent=2)) -def cmd_trends(path: Path) -> None: - """Analyze metric trends from history.json.""" - history_path = path / "metrics" / "history.json" - if not history_path.exists(): - print(json.dumps({"message": "No metrics history found"})) - return - - history = json.loads(history_path.read_text(encoding="utf-8")) - if len(history) < 2: - print(json.dumps({"message": "Insufficient history for trend analysis"})) - return - - def trend_for(key: str) -> Dict[str, Any]: - values = [s["metrics"].get(key) for s in history if s["metrics"].get(key) is not None] - if len(values) < 2: - return {"direction": "insufficient_data"} - - current = values[-1] - previous = values[-2] - n = len(values) - x = list(range(n)) - x_mean = sum(x) / n - y_mean = sum(values) / n - num = sum((x[i] - x_mean) * (values[i] - y_mean) for i in range(n)) - den = sum((x[i] - x_mean) ** 2 for i in range(n)) - slope = round(num / den, 4) if den != 0 else 0.0 - - if abs(slope) < 0.01: - direction = "stable" - elif slope > 0: - direction = "improving" if key == "review_efficiency" else "degrading" - else: - direction = "degrading" if key == "review_efficiency" else "improving" - - return { - "direction": direction, - "current": current, - "previous": previous, - "slope": slope, - } - - result = { - "snapshots_analyzed": len(history), - "trends": { - "time_to_solid": trend_for("time_to_solid_avg"), - "review_efficiency": trend_for("review_efficiency"), - "regression_rate": trend_for("regression_rate"), - "mastery_velocity": trend_for("mastery_velocity_slope"), - }, - "flags": [], - } - - print(json.dumps(result, indent=2)) - - -def cmd_compare(path1: Path, path2: Path) -> None: - """Compare coach effectiveness across two projects.""" - def project_metrics(path: Path) -> Dict[str, Any]: - introduced = parse_introduced_column(path) - statuses = parse_concept_statuses(path) - solid_sessions = parse_changelog_solid_sessions(path) - regressions = parse_changelog_regressions(path) - reviews = parse_review_history(path) - - tts_avg, _ = compute_time_to_solid(introduced, solid_sessions) - review_eff, _ = compute_review_efficiency(reviews) - reg_rate, _ = compute_regression_rate(statuses, solid_sessions, regressions) - - return { - "name": path.parent.name, - "time_to_solid_avg": tts_avg, - "review_efficiency": review_eff, - "regression_rate": reg_rate, - } - - m1 = project_metrics(path1) - m2 = project_metrics(path2) - - def better(key: str, lower_is_better: bool) -> Optional[str]: - v1, v2 = m1.get(key), m2.get(key) - if v1 is None or v2 is None: - return None - if v1 == v2: - return "tied" - if lower_is_better: - return m1["name"] if v1 < v2 else m2["name"] - return m1["name"] if v1 > v2 else m2["name"] - - result = { - "project_1": m1, - "project_2": m2, - "comparison": { - "faster_mastery": better("time_to_solid_avg", lower_is_better=True), - "better_retention": better("review_efficiency", lower_is_better=False), - "fewer_regressions": better("regression_rate", lower_is_better=True), - }, - } - - print(json.dumps(result, indent=2)) - - # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- -def _resolve_path(path_arg: str, require_kmap: bool = True) -> Path: +def _resolve_path(path_arg: str) -> Path: p = Path(path_arg).expanduser() if p.name == "knowledge-map.md": p = p.parent - if require_kmap: - kmap = p / "knowledge-map.md" - if not kmap.exists(): - basename = Path(p.parts[-1]) if len(p.parts) > 1 else p - alt_kmap = basename / "knowledge-map.md" - hint = f" Did you mean '{basename}'?" if alt_kmap.exists() else "" - sys.exit(f"Error: '{kmap}' not found.{hint} (cwd: {Path.cwd()})") return p @@ -659,21 +549,10 @@ def main() -> None: p_snap = subparsers.add_parser("snapshot", help="Compute metrics snapshot") p_snap.add_argument("path", help="Path to learning directory") - p_trend = subparsers.add_parser("trends", help="Analyze metric trends") - p_trend.add_argument("path", help="Path to learning directory") - - p_cmp = subparsers.add_parser("compare", help="Compare two projects") - p_cmp.add_argument("path1", help="Path to first learning directory") - p_cmp.add_argument("path2", help="Path to second learning directory") - args = parser.parse_args() if args.command == "snapshot": - cmd_snapshot(_resolve_path(args.path, require_kmap=False)) - elif args.command == "trends": - cmd_trends(_resolve_path(args.path, require_kmap=False)) - elif args.command == "compare": - cmd_compare(_resolve_path(args.path1), _resolve_path(args.path2)) + cmd_snapshot(_resolve_path(args.path)) if __name__ == "__main__": diff --git a/tools/srs/find_duplicate_cards.py b/tools/srs/find_duplicate_cards.py deleted file mode 100644 index 23f6dc5..0000000 --- a/tools/srs/find_duplicate_cards.py +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env python3 -"""Scan Sage decks for strict question-text duplicates. - -Walks all `learning/cards.md` files under a configurable root and reports -groups of cards that share the same normalized question text. Read-only — -makes no modifications. The user reviews the report and manually retires -duplicates by editing cards.md. - -Strict normalization only (lowercase, whitespace collapse, trailing -punctuation strip). Different question phrasings of the same fact are NOT -detected; that is intentional. Fuzzy/semantic detection is out of scope. - -Retired cards (`### Card N [RETIRED]`) are excluded from comparison. - -Usage: - python3 find_duplicate_cards.py [--root ] - -Defaults to the configured learning root (see tools/config.py). - -Exit code is always 0 — this is a report tool, not a CI gate. - -Zero external dependencies — Python 3.8+ stdlib only. -""" - -import argparse -import sys -from collections import defaultdict -from pathlib import Path -from typing import Dict, List - -# Import shared helpers from the sibling card_writer module so normalization -# stays in lockstep with the writer's dedup logic. -sys.path.insert(0, str(Path(__file__).parent)) -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from card_writer import ( # noqa: E402 - normalize_question, - parse_existing_cards, -) -from config import get_learning_root # noqa: E402 - - -def find_decks(root: Path) -> List[Path]: - """Find all learning/cards.md files under the root.""" - return sorted(root.glob("*/learning/cards.md")) - - -def scan_deck(path: Path) -> Dict[str, List[Dict]]: - """Scan one deck. Return {normalized_q: [card_records]} for groups >1.""" - text = path.read_text(encoding="utf-8") - cards = parse_existing_cards(text) - - groups: Dict[str, List[Dict]] = defaultdict(list) - for card in cards: - if card["retired"]: - continue - normalized = normalize_question(card["question"]) - if not normalized: - continue - groups[normalized].append(card) - - return {k: v for k, v in groups.items() if len(v) > 1} - - -def format_deck_report(deck_path: Path, root: Path, duplicate_groups: Dict[str, List[Dict]]) -> str: - """Format the per-deck section of the report.""" - try: - rel = deck_path.relative_to(root) - except ValueError: - rel = deck_path - - lines = [f"=== {rel} ==="] - - if not duplicate_groups: - lines.append("No duplicates found.") - lines.append("") - return "\n".join(lines) - - total_dupes = sum(len(group) for group in duplicate_groups.values()) - lines.append( - f"Found {len(duplicate_groups)} duplicate group(s) " - f"covering {total_dupes} cards." - ) - lines.append("") - - for normalized, group in sorted(duplicate_groups.items()): - lines.append( - f"Duplicate group ({len(group)} cards): \"{normalized}\"" - ) - for card in sorted(group, key=lambda c: c["number"]): - q_preview = card["question"] - if len(q_preview) > 100: - q_preview = q_preview[:97] + "..." - lines.append(f" - card-{card['number']}: {q_preview}") - lines.append("") - - return "\n".join(lines) - - -def main() -> None: - default_root = get_learning_root() - - parser = argparse.ArgumentParser( - description="Scan Sage decks for strict question-text duplicates." - ) - parser.add_argument( - "--root", - type=Path, - default=default_root, - help="Root directory to scan" - + (f" (default: {default_root})" if default_root else ""), - ) - args = parser.parse_args() - - if args.root is None: - print( - "Error: no learning root configured. " - "Pass --root or set SAGE_LEARNING_ROOT.", - file=sys.stderr, - ) - sys.exit(1) - - if not args.root.exists(): - print(f"Error: root directory does not exist: {args.root}", file=sys.stderr) - sys.exit(1) - - decks = find_decks(args.root) - if not decks: - print(f"No decks found under {args.root} (looked for */learning/cards.md)") - return - - print(f"Scanning {len(decks)} deck(s) under {args.root}\n") - - total_decks_with_dupes = 0 - total_groups = 0 - total_dupe_cards = 0 - - for deck in decks: - groups = scan_deck(deck) - if groups: - total_decks_with_dupes += 1 - total_groups += len(groups) - total_dupe_cards += sum(len(g) for g in groups.values()) - print(format_deck_report(deck, args.root, groups)) - - # Summary footer - print("=" * 60) - print("SUMMARY") - print("=" * 60) - print(f"Decks scanned: {len(decks)}") - print(f"Decks with duplicates: {total_decks_with_dupes}") - print(f"Duplicate groups total: {total_groups}") - print(f"Cards in dup groups: {total_dupe_cards}") - - -if __name__ == "__main__": - main() diff --git a/tools/srs/journal_writer.py b/tools/srs/journal_writer.py index 58cab86..df1ce90 100644 --- a/tools/srs/journal_writer.py +++ b/tools/srs/journal_writer.py @@ -7,7 +7,6 @@ Commands: append --json '' Append a new row from JSON append --stdin Read row JSON from stdin - validate Check for format violations Canonical format (8-column): | # | Date | Type | Focus | Reviews | Avg Grade | Summary | File | @@ -182,56 +181,6 @@ def cmd_append(path: Path, row_json: Dict[str, Any]) -> None: print(f"Appended session {session_num} to {path}") -def cmd_validate(path: Path) -> None: - """Check journal/index.md for format violations.""" - if not path.exists(): - print(f"Error: {path} does not exist", file=sys.stderr) - sys.exit(1) - - text = path.read_text(encoding="utf-8") - headers, rows, _, _ = _parse_table(text) - issues: List[str] = [] - - if headers is None: - issues.append("No markdown table found") - else: - # Check headers - canonical_map = _map_headers(headers) - mapped = {m for m in canonical_map if m} - if mapped != set(CANONICAL_HEADERS): - missing = set(CANONICAL_HEADERS) - mapped - extra = set(headers) - {h for h, m in zip(headers, canonical_map) if m} - if missing: - issues.append(f"Missing columns: {', '.join(sorted(missing))}") - if extra: - issues.append(f"Unrecognized columns: {', '.join(sorted(extra))}") - - # Check row widths - expected_cols = len(headers) - for i, row in enumerate(rows): - if len(row) != expected_cols: - issues.append(f"Row {i + 1}: expected {expected_cols} columns, got {len(row)}") - - # Check for duplicate session numbers - session_nums = [r[0].strip() if r else "" for r in rows] - seen = {} - for i, sn in enumerate(session_nums): - if sn in seen: - issues.append(f"Duplicate session number '{sn}' at rows {seen[sn] + 1} and {i + 1}") - else: - seen[sn] = i - - if not issues: - print(f"OK — no format violations in {path}") - return - - print(f"Found {len(issues)} issue(s) in {path}:\n") - for issue in issues: - print(f" - {issue}") - print() - sys.exit(1) - - # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- @@ -256,9 +205,6 @@ def main() -> None: p_append.add_argument("--json", dest="json_str", help="Row JSON string") p_append.add_argument("--stdin", action="store_true", help="Read JSON from stdin") - p_validate = subparsers.add_parser("validate", help="Check for format violations") - p_validate.add_argument("path", help="Path to journal/index.md or learning directory") - args = parser.parse_args() path = _resolve_path(args.path) @@ -283,9 +229,6 @@ def main() -> None: cmd_append(path, data) - elif args.command == "validate": - cmd_validate(path) - if __name__ == "__main__": main() diff --git a/tools/srs/kmap_writer.py b/tools/srs/kmap_writer.py index 1da8b7d..8f2015b 100644 --- a/tools/srs/kmap_writer.py +++ b/tools/srs/kmap_writer.py @@ -1,14 +1,11 @@ #!/usr/bin/env python3 """Deterministic knowledge-map writer for knowledge-map.md. -Handles Status Changelog appends, Status Legend normalization, and -concepts table manipulation (add/update rows). +Handles Status Changelog appends and concepts table manipulation +(add/update rows). Commands: changelog-append --stdin Append rows to Status Changelog section - fix-legend Normalize Status Legend to canonical format - ensure-sections Create missing Changelog section - validate Check for format violations add-concept --stdin Add a new concept row to the concepts table update-status --stdin Update an existing concept's status @@ -26,14 +23,6 @@ # Canonical formats # --------------------------------------------------------------------------- -CANONICAL_LEGEND = """## Status Legend -- **Not started** — Concept on the plan, not yet introduced -- **Introduced** — Concept presented, not yet retrieval-tested -- **Developing** — Retrieval-tested but inconsistent recall -- **Solid** — Reliable retrieval and application -- **Mastered** — Automatic retrieval, can teach and handle edge cases -- **Prior (from [project])** — Already solid/mastered in a sibling project""" - CANONICAL_STATUSES = {"not started", "introduced", "developing", "solid", "mastered", "prior"} CHANGELOG_HEADER = "| Date | Concept | From | To | Session |" @@ -43,13 +32,6 @@ # Section heading patterns CHANGELOG_HEADING_RE = re.compile(r"^##\s+Status\s+Changelog\s*$", re.IGNORECASE) -LEGEND_HEADING_RE = re.compile(r"^##\s+Status\s+Legend\s*$", re.IGNORECASE) - -# Detect any legend-like content (bracket-style, bullet-style, etc.) -LEGEND_CONTENT_RE = re.compile( - r"Status\s+legend|mastered.*solid.*developing|mastered.*solid.*shaky", - re.IGNORECASE, -) # --------------------------------------------------------------------------- @@ -73,20 +55,6 @@ def _find_section(lines: List[str], heading_re: re.Pattern) -> Optional[Tuple[in return None -def _find_legend_section(lines: List[str]) -> Optional[Tuple[int, int]]: - """Find the Status Legend section, including non-canonical formats.""" - # First try canonical heading - result = _find_section(lines, LEGEND_HEADING_RE) - if result: - return result - - # Try to find inline legend (e.g., "Status legend: [mastered] [solid] ...") - for i, line in enumerate(lines): - if LEGEND_CONTENT_RE.search(line) and not line.strip().startswith("#"): - return (i, i) - return None - - def _last_table_line_in_section(lines: List[str], section: Tuple[int, int]) -> int: """Find the index of the last table line (header, separator, or data row) in a section.""" start, end = section @@ -192,115 +160,6 @@ def cmd_changelog_append(path: Path, entries: List[Dict[str, Any]]) -> None: print(f"Appended {len(new_rows)} changelog row(s) to {path}") -def cmd_fix_legend(path: Path) -> None: - """Replace the Status Legend with the canonical version.""" - if not path.exists(): - print(f"Error: {path} does not exist", file=sys.stderr) - sys.exit(1) - - text = path.read_text(encoding="utf-8") - lines = text.split("\n") - - section = _find_legend_section(lines) - if section is None: - # No legend found — insert after the title line - insert_at = 0 - for i, line in enumerate(lines): - if line.strip().startswith("# "): - insert_at = i + 1 - break - if line.strip().startswith("**Last updated"): - insert_at = i + 1 - break - - # Skip blank lines after title/date - while insert_at < len(lines) and lines[insert_at].strip() == "": - insert_at += 1 - - legend_lines = CANONICAL_LEGEND.split("\n") - for i, ll in enumerate(legend_lines): - lines.insert(insert_at + i, ll) - lines.insert(insert_at + len(legend_lines), "") - - path.write_text("\n".join(lines), encoding="utf-8") - print(f"Inserted canonical Status Legend in {path}") - return - - start, end = section - - # Check if already canonical - existing = "\n".join(lines[start:end + 1]).strip() - if existing == CANONICAL_LEGEND.strip(): - print(f"Status Legend already canonical in {path}") - return - - # Replace - legend_lines = CANONICAL_LEGEND.split("\n") - lines[start:end + 1] = legend_lines - path.write_text("\n".join(lines), encoding="utf-8") - print(f"Replaced Status Legend with canonical version in {path}") - - -def cmd_ensure_sections(path: Path) -> None: - """Create missing Changelog section.""" - if not path.exists(): - print(f"Error: {path} does not exist", file=sys.stderr) - sys.exit(1) - - text = path.read_text(encoding="utf-8") - lines = text.split("\n") - added = [] - - if _find_section(lines, CHANGELOG_HEADING_RE) is None: - lines.append("") - lines.append("## Status Changelog") - lines.append("") - lines.append(CHANGELOG_HEADER) - lines.append(CHANGELOG_SEP) - added.append("Status Changelog") - - if added: - path.write_text("\n".join(lines), encoding="utf-8") - print(f"Added sections to {path}: {', '.join(added)}") - else: - print(f"All sections already present in {path}") - - -def cmd_validate(path: Path) -> None: - """Check knowledge-map.md for format violations.""" - if not path.exists(): - print(f"Error: {path} does not exist", file=sys.stderr) - sys.exit(1) - - text = path.read_text(encoding="utf-8") - lines = text.split("\n") - issues: List[str] = [] - - # Check legend - legend = _find_legend_section(lines) - if legend is None: - issues.append("Status Legend: missing entirely") - else: - existing = "\n".join(lines[legend[0]:legend[1] + 1]).strip() - if existing != CANONICAL_LEGEND.strip(): - issues.append("Status Legend: non-canonical format") - - # Check changelog - changelog = _find_section(lines, CHANGELOG_HEADING_RE) - if changelog is None: - issues.append("Status Changelog: section missing") - - if not issues: - print(f"OK — no format violations in {path}") - return - - print(f"Found {len(issues)} issue(s) in {path}:\n") - for issue in issues: - print(f" - {issue}") - print() - sys.exit(1) - - def cmd_add_concept(path: Path, entry: Dict[str, Any]) -> None: """Add a new concept row to the last concept table.""" if not path.exists(): @@ -402,15 +261,6 @@ def main() -> None: p_cl.add_argument("--json", dest="json_str", help="JSON string") p_cl.add_argument("--stdin", action="store_true", help="Read JSON from stdin") - p_fl = subparsers.add_parser("fix-legend", help="Normalize Status Legend") - p_fl.add_argument("path", help="Path to knowledge-map.md or learning directory") - - p_es = subparsers.add_parser("ensure-sections", help="Create missing sections") - p_es.add_argument("path", help="Path to knowledge-map.md or learning directory") - - p_v = subparsers.add_parser("validate", help="Check for format violations") - p_v.add_argument("path", help="Path to knowledge-map.md or learning directory") - p_ac = subparsers.add_parser("add-concept", help="Add a new concept row") p_ac.add_argument("path", help="Path to knowledge-map.md or learning directory") p_ac.add_argument("--json", dest="json_str", help="JSON string") @@ -443,15 +293,6 @@ def main() -> None: data = [data] cmd_changelog_append(path, data) - elif args.command == "fix-legend": - cmd_fix_legend(path) - - elif args.command == "ensure-sections": - cmd_ensure_sections(path) - - elif args.command == "validate": - cmd_validate(path) - elif args.command in ("add-concept", "update-status"): if args.stdin: raw = sys.stdin.read() diff --git a/tools/srs/srs_engine.py b/tools/srs/srs_engine.py index 398d75d..f20419a 100755 --- a/tools/srs/srs_engine.py +++ b/tools/srs/srs_engine.py @@ -7,7 +7,7 @@ Commands: init Create cards.srs.json from cards.md sync Add new cards, detect retired/edited - due [--date YYYY-MM-DD] [--limit N] [--sort risk] List cards due for review + due [--date YYYY-MM-DD] [--limit N] List cards due for review grade Grade a card (0-5), update schedule [--date YYYY-MM-DD] stats Aggregate statistics forecast [--days N] What's due each day for next N days @@ -534,11 +534,7 @@ def cmd_due(args: argparse.Namespace) -> str: if card["next_review"] <= check_date: due_cards.append((cid, card)) - # Sort - if getattr(args, "sort", None) == "risk": - due_cards.sort(key=lambda x: (x[1].get("easiness_factor", 2.5), -x[1].get("lapses", 0))) - else: - due_cards.sort(key=lambda x: (x[1]["status"] == "new", x[1]["next_review"])) + due_cards.sort(key=lambda x: (x[1]["status"] == "new", x[1]["next_review"])) # Limit if getattr(args, "limit", None): @@ -668,7 +664,6 @@ def build_parser() -> argparse.ArgumentParser: p_due.add_argument("path", help="Path to cards.md or its parent directory") p_due.add_argument("--date", default=None, help="Check date (YYYY-MM-DD), defaults to today") p_due.add_argument("--limit", type=int, default=None, help="Return only top N cards") - p_due.add_argument("--sort", choices=["default", "risk"], default="default", help="Sort order: default (oldest overdue) or risk (lowest EF, most lapses)") p_due.add_argument("--json", action="store_true", help="Output as JSON") # grade diff --git a/tools/srs/weak_spot_writer.py b/tools/srs/weak_spot_writer.py index 291c368..1698979 100644 --- a/tools/srs/weak_spot_writer.py +++ b/tools/srs/weak_spot_writer.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Deterministic writer for weak-spots.md and coach-errors.md. -Appends new entries with auto-numbered IDs and validates/fixes existing -entries to canonical format. Four kinds of entries in two files: +Appends new entries with auto-numbered IDs in canonical format. +Four kinds of entries in two files: --kind WS learner weak spots → weak-spots.md (WS-1, WS-2, ...) --kind M alias for WS (wrong-model) → weak-spots.md (WS-1, WS-2, ...) @@ -17,8 +17,6 @@ Commands: append --kind --stdin Append a new entry from JSON - validate --kind Check for format violations - fix --kind Normalize headings, field names Canonical format (WS): ## WS-[N] — [short description] @@ -70,6 +68,12 @@ # --------------------------------------------------------------------------- KIND_WS = "WS" +# ponytail: KIND_M is a pure alias — it costs a KIND_TO_FILENAME entry and the +# args.kind-vs-kind path dance for something callers could express as +# `--kind WS --category wrong-model`. Kept because it is in real use (28 +# invocations; `wrong-model` in 21 weak-spots.md files). Revisit if the alias +# ever stops carrying its weight — removal also touches the shipped markdown +# that teaches `--kind M`. KIND_M = "M" # Alias → resolves to WS with Category: wrong-model KIND_CE = "CE" KIND_CP = "CP" @@ -134,218 +138,10 @@ def _canonical_fields(kind: str) -> List[str]: return WS_CANONICAL_FIELDS if kind == KIND_WS else CE_CANONICAL_FIELDS -# Field name mapping: non-canonical name (lowercase) → canonical name -# Kind-dependent because WS uses "Correct model" where CE uses "Correction" -_BASE_FIELD_MAP: Dict[str, str] = { - "session": "Session", - "when": "Session", - "what happened": "What happened", - "what was said": "What happened", - "what they thought": "What happened", - "the error": "What happened", - "wrong model": "What happened", - "wrong": "What happened", - "why it matters": "Why it matters", - "cards": "Cards", - "status": "Status", -} - -WS_FIELD_MAP: Dict[str, str] = { - **_BASE_FIELD_MAP, - "category": "Category", - "correct model": "Correct model", - "correction": "Correct model", - "the truth": "Correct model", - "reality": "Correct model", - "what's actually happening": "Correct model", - "corrected model": "Correct model", - "corrected": "Correct model", - "last tested": "Last tested", - "concepts": "Concepts", -} - -CE_FIELD_MAP: Dict[str, str] = { - **_BASE_FIELD_MAP, - "root cause": "Root cause", - "why it's wrong": "Root cause", - "correction": "Correction", - "the truth": "Correction", - "reality": "Correction", - "what's actually happening": "Correction", - "corrected model": "Correction", - "corrected": "Correction", - "correct": "Correction", - "follow-up": "Follow-up", - "follow up": "Follow-up", - "key signal missed": "Follow-up", - "source": "Source", -} - - -def _field_map(kind: str) -> Dict[str, str]: - return WS_FIELD_MAP if kind == KIND_WS else CE_FIELD_MAP - - -# --------------------------------------------------------------------------- -# Heading regexes (kind-aware) -# --------------------------------------------------------------------------- - -def heading_re_for_kind(kind: str) -> re.Pattern: - """Compile a regex matching canonical ## headings for the given kind. - - Permissive on the prefix-to-number separator: hyphen optional, so - ``## WS-1 — desc`` and ``## WS1 — desc`` both parse. The writer - always emits the canonical form (hyphen for all kinds). - """ - return re.compile(rf"^##\s+{re.escape(kind)}-?(\d+)\s*[—–\-:]\s*(.+)$") - - -def h3_heading_re_for_kind(kind: str) -> re.Pattern: - """Same as heading_re_for_kind but for wrong-level ### headings.""" - return re.compile(rf"^###\s+{re.escape(kind)}-?(\d+)\s*[—–\-:]\s*(.+)$") - - -STRUCTURAL_HEADING_RE = re.compile( - r"^#{2,3}\s+(?:Session\s+\d+\w*|Audit\s+Corrections|Notes|Misconception\s+Summary)\b", - re.IGNORECASE, -) -SUBHEADING_RE = re.compile(r"^#{4,}\s+") -HISTORY_HEADING_RE = re.compile(r"^###\s+History\s*$", re.IGNORECASE) - -FIELD_LINE_RE = re.compile(r"^\*\*(.+?)(?:\*\*:\s*|\*\*\s*:\s*|:\*\*\s*)(.*)$") -BULLET_FIELD_RE = re.compile(r"^-\s+\*\*(.+?)(?:\*\*:\s*|\*\*\s*:\s*|:\*\*\s*)(.*)$") - - # --------------------------------------------------------------------------- # Parsing # --------------------------------------------------------------------------- -def _parse_entries(text: str, kind: str) -> Tuple[str, List[Dict[str, Any]]]: - """Parse a weak-spots/coach-errors file into header + entry list. - - Each entry has: number (int or None), description (str), fields (dict), - heading_line (int), extra_lines (list), and optionally history (list). - Only headings matching ``kind`` are recognized as entries. - """ - field_map = _field_map(kind) - lines = text.split("\n") - entries: List[Dict[str, Any]] = [] - header_lines: List[str] = [] - current: Optional[Dict[str, Any]] = None - collecting_field: Optional[str] = None - in_history = False - - for i, line in enumerate(lines): - stripped = line.strip() - - # Sub-headings (####) belong to the current entry - if SUBHEADING_RE.match(stripped): - if current: - if in_history: - current.setdefault("history", []).append(stripped) - elif collecting_field: - current["fields"][collecting_field] = ( - current["fields"].get(collecting_field, "") + "\n" + stripped - ) - else: - current["extra_lines"].append(line) - else: - header_lines.append(line) - continue - - # Structural headings — not entries - if STRUCTURAL_HEADING_RE.match(stripped): - if current: - entries.append(current) - current = None - collecting_field = None - in_history = False - continue - - # History subsection (WS entries only) - if kind == KIND_WS and current is not None and HISTORY_HEADING_RE.match(stripped): - collecting_field = None - in_history = True - continue - - entry_match = _match_heading(stripped, kind) - if entry_match: - if current: - entries.append(current) - number, description, _ = entry_match - current = { - "number": number, - "description": description, - "fields": {}, - "extra_lines": [], - "heading_line": i, - } - collecting_field = None - in_history = False - continue - - if current is None: - header_lines.append(line) - continue - - # Separator - if stripped == "---": - collecting_field = None - in_history = False - continue - - # History content - if in_history: - if stripped: - current.setdefault("history", []).append(stripped) - continue - - # Try field line - field_match = FIELD_LINE_RE.match(stripped) or BULLET_FIELD_RE.match(stripped) - if field_match: - raw_name = field_match.group(1).strip() - value = field_match.group(2).strip() - canonical = field_map.get(raw_name.lower()) - if canonical: - current["fields"][canonical] = value - collecting_field = canonical - else: - current["fields"][raw_name] = value - collecting_field = raw_name - continue - - # Continuation line - if stripped and collecting_field: - current["fields"][collecting_field] = ( - current["fields"].get(collecting_field, "") + " " + stripped - ) - continue - - # Blank line — stop collecting - if not stripped: - collecting_field = None - - if current: - entries.append(current) - - header = "\n".join(header_lines) - return header, entries - - -def _match_heading( - line: str, kind: str -) -> Optional[Tuple[Optional[int], str, str]]: - """Try to match an entry heading for the given kind. - - Returns (number, description, raw_line) or None. - """ - for pattern in [heading_re_for_kind(kind), h3_heading_re_for_kind(kind)]: - m = pattern.match(line) - if m: - return (int(m.group(1)), m.group(2).strip(), line) - return None - - def _find_max_number(text: str, kind: str) -> int: """Find the highest number in the file for the given kind's namespace.""" max_n = 0 @@ -578,108 +374,6 @@ def cmd_append( print(f"Appended {label} — {description} to {path}") -def cmd_validate(path: Path, kind: str) -> None: - """Check the file for format violations against the given kind.""" - if not path.exists(): - print(f"Error: {path} does not exist", file=sys.stderr) - sys.exit(1) - - text = path.read_text(encoding="utf-8") - _, entries = _parse_entries(text, kind) - issues: List[str] = [] - canonical = _canonical_fields(kind) - - if not entries: - print(f"OK — no entries to validate in {path}") - return - - canonical_re = heading_re_for_kind(kind) - sep = _heading_separator(kind) - - for entry in entries: - num = entry.get("number") - desc = entry.get("description", "?") - label = f"{kind}{sep}{num}" if num else f'"{desc}"' - - all_lines = text.split("\n") - heading_line = ( - all_lines[entry["heading_line"]] - if entry["heading_line"] < len(all_lines) - else "" - ) - if not canonical_re.match(heading_line.strip()): - issues.append(f"{label}: non-canonical heading format") - - # Category validation for WS - if kind == KIND_WS: - cat = entry["fields"].get("Category", "") - if cat and cat not in VALID_CATEGORIES: - issues.append( - f"{label}: invalid category '{cat}' — " - f"must be one of: {', '.join(sorted(VALID_CATEGORIES))}" - ) - - for raw_name in entry["fields"]: - if raw_name not in canonical: - field_m = _field_map(kind) - c = field_m.get(raw_name.lower()) - if c: - issues.append(f'{label}: field "{raw_name}" should be "{c}"') - else: - issues.append( - f'{label}: unrecognized field "{raw_name}" (will be preserved)' - ) - - if not issues: - print(f"OK — no format violations in {path}") - return - - print(f"Found {len(issues)} issue(s) in {path}:\n") - for issue in issues: - print(f" - {issue}") - print() - sys.exit(1) - - -def cmd_fix(path: Path, kind: str) -> None: - """Normalize the file to canonical format for the given kind.""" - if not path.exists(): - print(f"Error: {path} does not exist", file=sys.stderr) - sys.exit(1) - - text = path.read_text(encoding="utf-8") - header, entries = _parse_entries(text, kind) - - if not entries: - print(f"No entries to fix in {path}") - return - - # Category validation for WS entries during fix - if kind == KIND_WS: - for entry in entries: - cat = entry["fields"].get("Category", "") - if cat and cat not in VALID_CATEGORIES: - print( - f"Error: entry '{entry['description']}' has invalid " - f"category '{cat}'. Must be one of: " - f"{', '.join(sorted(VALID_CATEGORIES))}", - file=sys.stderr, - ) - sys.exit(1) - - parts = [header.rstrip()] - for i, entry in enumerate(entries, start=1): - desc = entry["description"] - fields = entry["fields"] - history = entry.get("history") - parts.append("") - parts.append(_format_entry(kind, i, desc, fields, history=history)) - - content = "\n".join(parts) + "\n" - path.write_text(content, encoding="utf-8") - print(f"Fixed {len(entries)} entries in {path}") - - # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- @@ -717,31 +411,17 @@ def main() -> None: help="Path to knowledge-map.md for concept validation", ) - p_validate = subparsers.add_parser( - "validate", help="Check for format violations" - ) - p_validate.add_argument("path", help="Path to file or learning directory") - - p_fix = subparsers.add_parser("fix", help="Normalize to canonical format") - p_fix.add_argument("path", help="Path to file or learning directory") - p_fix.add_argument( - "--knowledge-map", - dest="knowledge_map", - help="Path to knowledge-map.md for concept validation", + p_append.add_argument( + "--kind", + choices=list(ALL_KINDS), + default=KIND_WS, + help=( + "Entry kind: WS for learner weak spots (default), " + "M for wrong-model alias (writes WS with Category: wrong-model), " + "CE for coach content errors, CP for coach process failures" + ), ) - for subparser in (p_append, p_validate, p_fix): - subparser.add_argument( - "--kind", - choices=list(ALL_KINDS), - default=KIND_WS, - help=( - "Entry kind: WS for learner weak spots (default), " - "M for wrong-model alias (writes WS with Category: wrong-model), " - "CE for coach content errors, CP for coach process failures" - ), - ) - args = parser.parse_args() # Resolve M alias to WS early @@ -783,12 +463,6 @@ def main() -> None: cmd_append(path, kind, data, kmap_path=kmap_path) - elif args.command == "validate": - cmd_validate(path, kind) - - elif args.command == "fix": - cmd_fix(path, kind) - if __name__ == "__main__": main() From 323aca5f87f7900773d0de1e28713c95fb251b43 Mon Sep 17 00:00:00 2001 From: 0-BSCode Date: Wed, 29 Jul 2026 21:46:51 +0800 Subject: [PATCH 3/6] Replace demo index html with md file --- agents/artifact-clerk.md | 2 +- agents/demo-generator.md | 4 +- tests/test_demo_index_writer.py | 40 ++--- tools/coach/coach_reflector.py | 24 +-- tools/demo/demo_index_writer.py | 258 +++++++++++--------------------- 5 files changed, 113 insertions(+), 215 deletions(-) diff --git a/agents/artifact-clerk.md b/agents/artifact-clerk.md index a654bc4..2e5b9d8 100644 --- a/agents/artifact-clerk.md +++ b/agents/artifact-clerk.md @@ -47,7 +47,7 @@ The `Project:` field is optional. When provided, use it as the canonical project - `coach-insights.md` — coach behavioral rules. Read this file. If it does not exist, note "None — file not present" in the Coach Insights section. - `metrics/dashboard.md` (coach effectiveness metrics, if present) - `docs/references/index.md` (reference document index, if it exists) - - `docs/demos/index.html` (demo index, if it exists) + - `docs/demos/index.md` (demo index, if it exists) - `../capstone/capstone.md` (capstone project spec, if it exists — lives in `capstone/` sibling to `learning/`) - `cross-refs/INDEX.md` (cross-project topic registry index — look for the `cross-refs/` directory by walking up from the learning path to the repo root. Search up to 4 parent directories from the specified path.) - From INDEX.md, find the current project's row and load `cross-refs/.md` plus each file listed in the "Overlaps With" column. From overlapping project files, extract only rows where the current project appears in "Also Covered In." diff --git a/agents/demo-generator.md b/agents/demo-generator.md index 667714c..9eff790 100644 --- a/agents/demo-generator.md +++ b/agents/demo-generator.md @@ -154,7 +154,7 @@ Write to `docs/demos/.html`. ### Step 7: Update the demo index -Do NOT write to `docs/demos/index.html` directly. Use the `demo_index_writer.py` script which guarantees canonical HTML format, handles deduplication by WS-number, and creates the index file if it doesn't exist. +Do NOT write to `docs/demos/index.md` directly. Use the `demo_index_writer.py` script which guarantees canonical markdown format, handles deduplication by WS-number, and creates the index file if it doesn't exist. Build a JSON object from the demo metadata and pipe it to the script: ```bash @@ -213,7 +213,7 @@ Updates: 1. Read the existing demo file from `docs/demos/` 2. Read current state of `weak-spots.md` to get updated weak spot details 3. Apply the requested updates while preserving the interaction design -4. Update the index entry in `docs/demos/index.html` +4. Update the index entry in `docs/demos/index.md` 5. Return a confirmation showing what changed --- diff --git a/tests/test_demo_index_writer.py b/tests/test_demo_index_writer.py index 6058015..40f987a 100644 --- a/tests/test_demo_index_writer.py +++ b/tests/test_demo_index_writer.py @@ -55,7 +55,7 @@ def _run_validate(demos_dir): class TestAppendCreatesNewIndex(unittest.TestCase): - """append — creates new index.html from scratch.""" + """append — creates new index.md from scratch.""" def test_creates_index_when_none_exists(self): with tempfile.TemporaryDirectory() as demos_dir: @@ -69,14 +69,14 @@ def test_creates_index_when_none_exists(self): self.assertEqual(result.returncode, 0) self.assertIn("Appended", result.stdout) - index_path = demos / "index.html" + index_path = demos / "index.md" self.assertTrue(index_path.exists()) - html = index_path.read_text() - self.assertIn("WS-1", html) - self.assertIn("test weak spot", html) - self.assertIn("Test Demo", html) - self.assertIn("test-demo.html", html) + text = index_path.read_text() + self.assertIn("WS-1", text) + self.assertIn("test weak spot", text) + self.assertIn("Test Demo", text) + self.assertIn("test-demo.html", text) def test_creates_index_via_stdin(self): with tempfile.TemporaryDirectory() as demos_dir: @@ -87,7 +87,7 @@ def test_creates_index_via_stdin(self): result = _run_append(demos_dir, entry, use_stdin=True) self.assertEqual(result.returncode, 0) - self.assertTrue((demos / "index.html").exists()) + self.assertTrue((demos / "index.md").exists()) class TestAppendDeduplication(unittest.TestCase): @@ -117,9 +117,9 @@ def test_updates_existing_ws_entry(self): self.assertIn("Updated", result.stdout) self.assertIn("Total entries: 1", result.stdout) - html = (demos / "index.html").read_text() - self.assertIn("Updated Title", html) - self.assertNotIn("Original Title", html) + text = (demos / "index.md").read_text() + self.assertIn("Updated Title", text) + self.assertNotIn("Original Title", text) class TestAppendSortsByDate(unittest.TestCase): @@ -146,10 +146,10 @@ def test_entries_sorted_chronologically(self): created="2026-02-01" )) - html = (demos / "index.html").read_text() - pos_first = html.index("WS-1") - pos_second = html.index("WS-2") - pos_third = html.index("WS-3") + text = (demos / "index.md").read_text() + pos_first = text.index("WS-1") + pos_second = text.index("WS-2") + pos_third = text.index("WS-3") self.assertLess(pos_first, pos_second) self.assertLess(pos_second, pos_third) @@ -168,8 +168,8 @@ def test_no_reference_placeholder(self): self.assertEqual(result.returncode, 0) - html = (demos / "index.html").read_text() - self.assertIn("No reference doc yet", html) + text = (demos / "index.md").read_text() + self.assertIn("No reference doc yet", text) def test_omitted_reference_placeholder(self): with tempfile.TemporaryDirectory() as demos_dir: @@ -187,8 +187,8 @@ def test_omitted_reference_placeholder(self): result = _run_append(demos_dir, entry) self.assertEqual(result.returncode, 0) - html = (demos / "index.html").read_text() - self.assertIn("No reference doc yet", html) + text = (demos / "index.md").read_text() + self.assertIn("No reference doc yet", text) class TestValidatePassesOnValid(unittest.TestCase): @@ -238,7 +238,7 @@ def test_missing_demo_file_reported(self): class TestValidateNoIndex(unittest.TestCase): - """validate — fails when index.html does not exist.""" + """validate — fails when index.md does not exist.""" def test_missing_index_exits_with_error(self): with tempfile.TemporaryDirectory() as demos_dir: diff --git a/tools/coach/coach_reflector.py b/tools/coach/coach_reflector.py index 62333f4..b59af8d 100644 --- a/tools/coach/coach_reflector.py +++ b/tools/coach/coach_reflector.py @@ -18,6 +18,9 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Set, Tuple +# Same directory, so sys.path[0] already covers this when run as a script. +from coach_metrics import parse_current_session + # --------------------------------------------------------------------------- # Parsers # --------------------------------------------------------------------------- @@ -152,27 +155,6 @@ def parse_coach_insights(path: Path) -> List[Dict[str, Any]]: return insights -def parse_current_session(path: Path) -> int: - """Determine current session number from journal/index.md.""" - index = path / "journal" / "index.md" - if not index.exists(): - return 0 - - text = index.read_text(encoding="utf-8") - max_session = 0 - for line in text.split("\n"): - if not line.strip().startswith("|"): - continue - cells = [c.strip() for c in line.strip().split("|")[1:-1]] - if not cells or cells[0] == "#" or "---" in cells[0]: - continue - m = re.match(r"(\d+)", cells[0]) - if m: - max_session = max(max_session, int(m.group(1))) - - return max_session - - # --------------------------------------------------------------------------- # Clustering # --------------------------------------------------------------------------- diff --git a/tools/demo/demo_index_writer.py b/tools/demo/demo_index_writer.py index af92b5e..f83847b 100755 --- a/tools/demo/demo_index_writer.py +++ b/tools/demo/demo_index_writer.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -"""Deterministic index writer for docs/demos/index.html. +"""Deterministic index writer for docs/demos/index.md. -Manages the demo index file in a guaranteed canonical HTML format. -The demo-generator agent produces demo content; this script enforces -index formatting, deduplication, and validation. +Manages the demo index as a markdown table. The demo-generator agent produces +demo content; this script enforces index formatting, deduplication, and +validation. Commands: append --json '' Append a new demo entry from JSON @@ -29,70 +29,19 @@ import json import re import sys -from datetime import date from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List -# --------------------------------------------------------------------------- -# Canonical index template -# --------------------------------------------------------------------------- +INDEX_HEADER = """\ +# Interactive Demos + +Targeted demos for persistent weak spots. Each demo corrects a specific wrong mental model. -INDEX_TEMPLATE = """\ - - - - - - Interactive Demos - - - -

Interactive Demos

-

Targeted demos for persistent weak spots. Each demo corrects a specific wrong mental model.

- - - - - - - - - - -{rows} - -
Weak SpotDemoRelated ReferenceCreated
- - +| Weak Spot | Demo | Related Reference | Created | +|-----------|------|-------------------|---------| """ -ROW_TEMPLATE = ( - ' \n' - ' {weak_spot_id}: {weak_spot_description}\n' - ' {demo_title}\n' - ' {ref_cell}\n' - ' {created_date}\n' - ' ' -) - -# Regex to extract existing rows from the tbody -ROW_RE = re.compile( - r"\s*" - r"(WS-\d+):\s*(.*?)\s*" - r"(.*?)\s*" - r"(.*?)\s*" - r"(\d{4}-\d{2}-\d{2})\s*" - r"", - re.DOTALL, -) - -REF_LINK_RE = re.compile(r'(.*?)') +LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]*)\)") NO_REF_TEXT = "No reference doc yet" @@ -101,22 +50,9 @@ # Core functions # --------------------------------------------------------------------------- -def parse_existing_rows(html: str) -> List[Dict[str, str]]: - """Parse existing demo entries from the index HTML.""" - rows = [] - for m in ROW_RE.finditer(html): - ref_cell_raw = m.group(5).strip() - ref_match = REF_LINK_RE.search(ref_cell_raw) - - rows.append({ - "weak_spot_id": m.group(1), - "weak_spot_description": m.group(2).strip(), - "demo_filename": m.group(3), - "demo_title": m.group(4).strip(), - "related_reference": ref_match.group(1) if ref_match else "", - "created_date": m.group(6), - }) - return rows +def cells(row: str) -> List[str]: + """Split a markdown table row into its cells.""" + return [c.strip() for c in row.strip().strip("|").split("|")] def format_ref_cell(related_reference: str) -> str: @@ -124,32 +60,41 @@ def format_ref_cell(related_reference: str) -> str: if not related_reference or related_reference == "—": return NO_REF_TEXT # Extract concept name from slug: ref-hypothesis-testing.md -> Hypothesis Testing - name = related_reference - name = re.sub(r"^ref-", "", name) + name = re.sub(r"^ref-", "", related_reference) name = re.sub(r"\.md$", "", name) name = name.replace("-", " ").title() - return f'{name}' - - -def format_row(entry: Dict[str, str]) -> str: - """Format a single table row.""" - return ROW_TEMPLATE.format( - weak_spot_id=entry["weak_spot_id"], - weak_spot_description=entry["weak_spot_description"], - demo_filename=entry["demo_filename"], - demo_title=entry["demo_title"], - ref_cell=format_ref_cell(entry.get("related_reference", "")), - created_date=entry["created_date"], + return f"[{name}](../references/{related_reference})" + + +def format_row(entry: Dict[str, Any]) -> str: + """Format a single markdown table row.""" + return ( + f"| {entry['weak_spot_id']}: {entry['weak_spot_description']} " + f"| [{entry['demo_title']}]({entry['demo_filename']}) " + f"| {format_ref_cell(entry.get('related_reference', ''))} " + f"| {entry['created_date']} |" ) -def build_index(rows: List[Dict[str, str]]) -> str: - """Build the complete index.html from a list of row entries.""" - if not rows: - row_html = "" - else: - row_html = "\n".join(format_row(r) for r in rows) + "\n" - return INDEX_TEMPLATE.format(rows=row_html) +def read_rows(index_path: Path) -> List[str]: + """Read existing table rows, excluding the header and its separator. + + Every data row is kept verbatim, including hand-written ones this script + did not produce. Only rows carrying a WS id are ever rewritten; anything + else rides along untouched rather than being dropped on the next append. + """ + if not index_path.exists(): + return [] + return [ + line for line in index_path.read_text(encoding="utf-8").splitlines() + if line.startswith("|") + and not line.startswith("| Weak Spot") + and not set(line) <= set("|- ") + ] + + +def write_index(index_path: Path, rows: List[str]) -> None: + index_path.write_text(INDEX_HEADER + "".join(r + "\n" for r in rows), encoding="utf-8") # --------------------------------------------------------------------------- @@ -158,7 +103,6 @@ def build_index(rows: List[Dict[str, str]]) -> str: def cmd_append(demos_dir: Path, entry: Dict[str, Any]) -> None: """Append a new demo entry to the index.""" - # Validate required fields required = ["weak_spot_id", "weak_spot_description", "demo_title", "demo_filename", "created_date"] missing = [f for f in required if not entry.get(f)] @@ -166,97 +110,69 @@ def cmd_append(demos_dir: Path, entry: Dict[str, Any]) -> None: print(f"Error: missing required fields: {', '.join(missing)}", file=sys.stderr) sys.exit(1) - # Validate weak_spot_id format - if not re.match(r"^WS-\d+$", entry["weak_spot_id"]): - print(f"Error: weak_spot_id must match WS-, got: {entry['weak_spot_id']}", + ws_id = entry["weak_spot_id"] + if not re.match(r"^WS-\d+$", ws_id): + print(f"Error: weak_spot_id must match WS-, got: {ws_id}", file=sys.stderr) sys.exit(1) - # Validate demo file exists - demo_path = demos_dir / entry["demo_filename"] - if not demo_path.exists(): - print(f"Warning: demo file not found: {demo_path}", file=sys.stderr) - - index_path = demos_dir / "index.html" - - # Parse existing or start fresh - if index_path.exists(): - html = index_path.read_text(encoding="utf-8") - rows = parse_existing_rows(html) - else: - rows = [] - - # Check for duplicate WS-number — update if exists - existing_idx = None - for i, row in enumerate(rows): - if row["weak_spot_id"] == entry["weak_spot_id"]: - existing_idx = i - break - - clean_entry = { - "weak_spot_id": entry["weak_spot_id"], - "weak_spot_description": entry["weak_spot_description"], - "demo_title": entry["demo_title"], - "demo_filename": entry["demo_filename"], - "related_reference": entry.get("related_reference", ""), - "created_date": entry["created_date"], - } + if not (demos_dir / entry["demo_filename"]).exists(): + print(f"Warning: demo file not found: {demos_dir / entry['demo_filename']}", + file=sys.stderr) - if existing_idx is not None: - rows[existing_idx] = clean_entry - action = "Updated" - else: - rows.append(clean_entry) - action = "Appended" + index_path = demos_dir / "index.md" + rows = read_rows(index_path) - # Sort by created date (chronological) - rows.sort(key=lambda r: r["created_date"]) + # Same weak spot replaces its previous entry + kept = [r for r in rows if not r.startswith(f"| {ws_id}:")] + action = "Updated" if len(kept) < len(rows) else "Appended" - # Write - index_path.write_text(build_index(rows), encoding="utf-8") + kept.append(format_row(entry)) + kept.sort(key=lambda r: cells(r)[-1]) # chronological by Created (always last) + write_index(index_path, kept) print(f"{action} demo entry in {index_path}") - print(f" Weak spot: {clean_entry['weak_spot_id']}: {clean_entry['weak_spot_description']}") - print(f" Demo: {clean_entry['demo_filename']}") - print(f" Total entries: {len(rows)}") + print(f" Weak spot: {ws_id}: {entry['weak_spot_description']}") + print(f" Demo: {entry['demo_filename']}") + print(f" Total entries: {len(kept)}") def cmd_validate(demos_dir: Path) -> None: """Validate the demo index for issues.""" - index_path = demos_dir / "index.html" + index_path = demos_dir / "index.md" if not index_path.exists(): print(f"Error: {index_path} does not exist", file=sys.stderr) sys.exit(1) - html = index_path.read_text(encoding="utf-8") - rows = parse_existing_rows(html) - issues = [] - + rows = read_rows(index_path) if not rows: print(f"OK — index exists but has no entries: {index_path}") return + issues = [] seen_ids = set() for row in rows: - # Check for duplicate WS-numbers - if row["weak_spot_id"] in seen_ids: - issues.append(f"Duplicate weak spot: {row['weak_spot_id']}") - seen_ids.add(row["weak_spot_id"]) - - # Check demo file exists - demo_path = demos_dir / row["demo_filename"] - if not demo_path.exists(): - issues.append(f"Missing demo file: {row['demo_filename']} (for {row['weak_spot_id']})") - - # Check date format - if not re.match(r"^\d{4}-\d{2}-\d{2}$", row["created_date"]): - issues.append(f"Invalid date format for {row['weak_spot_id']}: {row['created_date']}") - - # Check reference file exists (if specified) - if row["related_reference"]: - ref_path = demos_dir.parent / "references" / row["related_reference"] - if not ref_path.exists(): - issues.append(f"Missing reference file: {row['related_reference']} (for {row['weak_spot_id']})") + c = cells(row) + ws_id = c[0].split(":")[0].strip() + if not re.match(r"^WS-\d+$", ws_id): + continue # hand-written row — not this script's to check + + if ws_id in seen_ids: + issues.append(f"Duplicate weak spot: {ws_id}") + seen_ids.add(ws_id) + + demo_link = LINK_RE.search(c[1]) + if not demo_link: + issues.append(f"Malformed demo link for {ws_id}: {c[1]}") + elif not (demos_dir / demo_link.group(1)).exists(): + issues.append(f"Missing demo file: {demo_link.group(1)} (for {ws_id})") + + if not re.match(r"^\d{4}-\d{2}-\d{2}$", c[-1]): + issues.append(f"Invalid date format for {ws_id}: {c[-1]}") + + ref_link = LINK_RE.search(c[2]) + if ref_link and not (demos_dir / ref_link.group(1)).exists(): + issues.append(f"Missing reference file: {ref_link.group(1)} (for {ws_id})") if not issues: print(f"OK — {len(rows)} entries, no issues found in {index_path}") @@ -283,7 +199,7 @@ def _resolve_demos_dir(path_arg: str) -> Path: def main() -> None: parser = argparse.ArgumentParser( - description="Deterministic index writer for docs/demos/index.html", + description="Deterministic index writer for docs/demos/index.md", ) subparsers = parser.add_subparsers(dest="command", required=True) From ab69aac10450ca0a36a31f2681175408fcaa1432 Mon Sep 17 00:00:00 2001 From: 0-BSCode Date: Wed, 29 Jul 2026 21:57:06 +0800 Subject: [PATCH 4/6] Remove debug logging and redundant agent guards --- hooks/README.md | 10 +++++----- hooks/scripts/checkpoint-guard.sh | 7 ------- hooks/scripts/enforce-cross-refs.sh | 10 ---------- hooks/scripts/reset-verification.sh | 9 --------- hooks/scripts/verification-counter.sh | 4 ---- 5 files changed, 5 insertions(+), 35 deletions(-) diff --git a/hooks/README.md b/hooks/README.md index 4ca7853..675d775 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -1,15 +1,15 @@ # Sage Hooks -## Debug Log +## Debugging -All hook scripts write timestamped traces to `/tmp/sage-hook-debug.log`. Check this file to verify whether hooks fired and which branch they took. +To see whether a hook fired and which branch it took, run it under `bash -x` +with a sample event on stdin: ```bash -cat /tmp/sage-hook-debug.log +echo '{"session_id":"test","cwd":"'"$PWD"'","stop_hook_active":false}' \ + | bash -x hooks/scripts/enforce-cross-refs.sh ``` -Each entry: `HH:MM:SS : `. - ## Hook Reference | Hook | Event | Script | Purpose | diff --git a/hooks/scripts/checkpoint-guard.sh b/hooks/scripts/checkpoint-guard.sh index cf8d61c..0d403bb 100755 --- a/hooks/scripts/checkpoint-guard.sh +++ b/hooks/scripts/checkpoint-guard.sh @@ -9,13 +9,6 @@ set -euo pipefail INPUT=$(cat) -TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name') - -# Only care about Agent tool calls -if [ "$TOOL_NAME" != "Agent" ]; then - exit 0 -fi - SUBAGENT_TYPE=$(echo "$INPUT" | jq -r '.tool_input.subagent_type // empty') PROMPT=$(echo "$INPUT" | jq -r '.tool_input.prompt // empty') diff --git a/hooks/scripts/enforce-cross-refs.sh b/hooks/scripts/enforce-cross-refs.sh index 9f15a24..5d2f9e7 100755 --- a/hooks/scripts/enforce-cross-refs.sh +++ b/hooks/scripts/enforce-cross-refs.sh @@ -5,11 +5,8 @@ set -euo pipefail -DEBUG_LOG="/tmp/sage-hook-debug.log" - SAGE_DIR="${SAGE_DIR:-$(cat /tmp/.sage-learning-root 2>/dev/null)}" if [ -z "$SAGE_DIR" ]; then - echo "$(date '+%H:%M:%S') cross-refs: skip — no SAGE_DIR" >> "$DEBUG_LOG" exit 0 fi THRESHOLD=1800 # 30 minutes @@ -20,13 +17,11 @@ CWD=$(echo "$INPUT" | jq -r '.cwd // ""') STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active') if [ "$STOP_HOOK_ACTIVE" = "true" ]; then - echo "$(date '+%H:%M:%S') cross-refs: skip — stop_hook_active" >> "$DEBUG_LOG" exit 0 fi # Only fire in the sage repo (or a subdirectory) if [[ "$CWD" != "$SAGE_DIR"* ]]; then - echo "$(date '+%H:%M:%S') cross-refs: skip — cwd=$CWD not in SAGE_DIR=$SAGE_DIR" >> "$DEBUG_LOG" exit 0 fi @@ -51,12 +46,10 @@ while IFS= read -r km; do done < <(find "$SAGE_DIR" -name "knowledge-map.md" 2>/dev/null) if [ "$KM_MODIFIED" != "true" ]; then - echo "$(date '+%H:%M:%S') cross-refs: skip — no recent knowledge-map changes" >> "$DEBUG_LOG" exit 0 fi if [ "$KM_HAS_PROMOTED" != "true" ]; then - echo "$(date '+%H:%M:%S') cross-refs: skip — knowledge-map modified but no concepts at developing+" >> "$DEBUG_LOG" exit 0 fi @@ -77,15 +70,12 @@ fi # Knowledge map modified but cross-references weren't — block if [ "$CR_UPDATED" != "true" ]; then - echo "$(date '+%H:%M:%S') cross-refs: BLOCK — km modified, cross-refs not updated" >> "$DEBUG_LOG" cat <<'EOF' { "decision": "block", "reason": "Knowledge map(s) were modified this session but cross-refs/ was not updated. Per CLAUDE.md Cross-Reference Protocol: upsert any concept that reached Developing or higher into cross-refs/.md before ending the session." } EOF -else - echo "$(date '+%H:%M:%S') cross-refs: pass — km modified, cross-refs updated" >> "$DEBUG_LOG" fi exit 0 diff --git a/hooks/scripts/reset-verification.sh b/hooks/scripts/reset-verification.sh index 1001cc0..33ffd8a 100755 --- a/hooks/scripts/reset-verification.sh +++ b/hooks/scripts/reset-verification.sh @@ -7,16 +7,9 @@ set -euo pipefail -DEBUG_LOG="/tmp/sage-hook-debug.log" INPUT=$(cat) SESSION_ID=$(echo "$INPUT" | jq -r '.session_id') -TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name') - -# Only care about Agent tool calls -if [ "$TOOL_NAME" != "Agent" ]; then - exit 0 -fi SUBAGENT_TYPE=$(echo "$INPUT" | jq -r '.tool_input.subagent_type // empty') PROMPT=$(echo "$INPUT" | jq -r '.tool_input.prompt // empty') @@ -35,12 +28,10 @@ if [[ "$SUBAGENT_TYPE" == *"artifact-clerk" ]]; then fi if [[ "$SUBAGENT_TYPE" != *"verification-gate" ]]; then - echo "$(date '+%H:%M:%S') reset-verif: skip — subagent_type=$SUBAGENT_TYPE (not verification-gate)" >> "$DEBUG_LOG" exit 0 fi # Reset the message counter (creates it if first call) -echo "$(date '+%H:%M:%S') reset-verif: RESET counter (subagent_type=$SUBAGENT_TYPE)" >> "$DEBUG_LOG" COUNTER_FILE="/tmp/claude-verif-counter-${SESSION_ID}" echo "0" > "$COUNTER_FILE" diff --git a/hooks/scripts/verification-counter.sh b/hooks/scripts/verification-counter.sh index 5ffc891..c1fd069 100755 --- a/hooks/scripts/verification-counter.sh +++ b/hooks/scripts/verification-counter.sh @@ -9,7 +9,6 @@ set -euo pipefail -DEBUG_LOG="/tmp/sage-hook-debug.log" INPUT=$(cat) SESSION_ID=$(echo "$INPUT" | jq -r '.session_id') @@ -23,7 +22,6 @@ fi # No counter file = not in a verification-tracked session if [ ! -f "$COUNTER_FILE" ]; then - echo "$(date '+%H:%M:%S') verif-counter: skip — no counter file (session=$SESSION_ID)" >> "$DEBUG_LOG" exit 0 fi @@ -33,11 +31,9 @@ COUNT=$((COUNT + 1)) echo "$COUNT" > "$COUNTER_FILE" # Warn once at 5+ -echo "$(date '+%H:%M:%S') verif-counter: count=$COUNT (session=$SESSION_ID)" >> "$DEBUG_LOG" if [ "$COUNT" -ge 5 ]; then WARNED_FILE="/tmp/claude-verif-warned-${SESSION_ID}" if [ ! -f "$WARNED_FILE" ]; then - echo "$(date '+%H:%M:%S') verif-counter: WARNING FIRED at count=$COUNT" >> "$DEBUG_LOG" echo "1" > "$WARNED_FILE" cat < Date: Wed, 29 Jul 2026 22:16:00 +0800 Subject: [PATCH 5/6] Remove dead params, CLI flags, and unnecessary wrappers --- references/ref-session-end.md | 2 +- tests/test_assessment_engine.py | 16 +++++++-------- tests/test_plateau_detector.py | 8 ++------ tests/test_session_duration.py | 16 +++++++-------- tests/test_session_router.py | 2 ++ tools/assessment/assessment_engine.py | 17 ++++++---------- tools/coach/coach_metrics.py | 1 - tools/coach/coach_reflector.py | 3 +-- tools/plateau/plateau_detector.py | 17 ---------------- tools/session_duration.py | 10 --------- tools/session_router.py | 29 ++++++--------------------- tools/session_wrapup.py | 14 +++++-------- tools/srs/journal_writer.py | 19 +++++------------- tools/srs/srs_engine.py | 8 ++------ tools/srs/weak_spot_writer.py | 14 +++---------- 15 files changed, 49 insertions(+), 127 deletions(-) diff --git a/references/ref-session-end.md b/references/ref-session-end.md index 321beba..a3b61f6 100644 --- a/references/ref-session-end.md +++ b/references/ref-session-end.md @@ -47,7 +47,7 @@ After all post-checkpoint work is complete, run the wrapup script: ```bash SAGE_ROOT=$(cat /tmp/.sage-plugin-root) -python3 "$SAGE_ROOT/tools/session_wrapup.py" "$SAGE_ROOT" "" "" +python3 "$SAGE_ROOT/tools/session_wrapup.py" "$SAGE_ROOT" "" ``` If `coach_metrics_flags` is non-empty, mention the flags in your session summary. diff --git a/tests/test_assessment_engine.py b/tests/test_assessment_engine.py index 2e3642b..2ec54dd 100644 --- a/tests/test_assessment_engine.py +++ b/tests/test_assessment_engine.py @@ -267,7 +267,7 @@ def test_concept_priority_never_assessed_gets_max_recency(self): with tempfile.TemporaryDirectory() as tmpdir: bank, km, _ = self._make_bank_and_km(tmpdir) # useRef has never been assessed and has no questions - priority = AdaptiveSelector.concept_priority("useRef", bank, "not started", "2026-02-12") + priority = AdaptiveSelector.concept_priority("useRef", bank, "2026-02-12") # recency=5.0 (never assessed) + weakness=0.0 (no questions) + coverage=2.0 (< 3 questions) self.assertAlmostEqual(priority, 7.0, places=1) @@ -282,7 +282,7 @@ def test_concept_priority_recent_assessment_gets_low_recency(self): "last_assessed": "2026-02-12", "assessment_count": 3, } - priority = AdaptiveSelector.concept_priority("useState", bank, "solid", "2026-02-12") + priority = AdaptiveSelector.concept_priority("useState", bank, "2026-02-12") # recency=0.0 (assessed today) + weakness=0.0 (no asked questions) + coverage=0.0 (>=5) self.assertAlmostEqual(priority, 0.0, places=1) @@ -294,7 +294,7 @@ def test_weakness_weight_low_success_rate(self): QuestionBank.record_result(bank, "q-1", 0, today="2026-02-12") QuestionBank.record_result(bank, "q-1", 0, today="2026-02-12") # success_rate = 0.0 < 0.5 => weakness_weight = 3.0 - priority = AdaptiveSelector.concept_priority("closures", bank, "developing", "2026-02-12") + priority = AdaptiveSelector.concept_priority("closures", bank, "2026-02-12") # recency from coverage last_assessed = today => 0/7=0.0 # weakness = 3.0 (avg success 0.0 < 0.5) # coverage: total_questions for closures = 1 => coverage_weight = 2.0 @@ -308,7 +308,7 @@ def test_weakness_weight_high_success_rate(self): QuestionBank.record_result(bank, "q-1", 1, today="2026-02-12") QuestionBank.record_result(bank, "q-1", 1, today="2026-02-12") QuestionBank.record_result(bank, "q-1", 1, today="2026-02-12") - priority = AdaptiveSelector.concept_priority("useState", bank, "solid", "2026-02-12") + priority = AdaptiveSelector.concept_priority("useState", bank, "2026-02-12") # success_rate = 1.0 >= 0.7 => weakness = 0.0 # Check weakness doesn't contribute # recency depends on coverage last_assessed @@ -319,12 +319,12 @@ def test_coverage_weight_few_questions(self): with tempfile.TemporaryDirectory() as tmpdir: bank, km, _ = self._make_bank_and_km(tmpdir) # useEffect has 0 questions => coverage_weight = 2.0 - p1 = AdaptiveSelector.concept_priority("useEffect", bank, "introduced", "2026-02-12") + p1 = AdaptiveSelector.concept_priority("useEffect", bank, "2026-02-12") # Add 3 questions for i in range(3): QuestionBank.add_question(bank, "useEffect", 1, "free_recall", f"Q{i}?", f"A{i}", today="2026-02-12") - p2 = AdaptiveSelector.concept_priority("useEffect", bank, "introduced", "2026-02-12") + p2 = AdaptiveSelector.concept_priority("useEffect", bank, "2026-02-12") # After 3 questions, coverage_weight drops from 2.0 to 1.0 self.assertGreater(p1, p2) @@ -395,7 +395,7 @@ def test_prefer_unasked_questions(self): "Unasked question", "A2", today="2026-02-10") QuestionBank.record_result(bank, "q-1", 1, today="2026-02-11") - result = AdaptiveSelector.select_from_bank(bank, "closures", 2, "conceptual", "2026-02-12") + result = AdaptiveSelector.select_from_bank(bank, "closures", 2, "conceptual") self.assertIsNotNone(result) self.assertEqual(result["question_id"], "q-2") # prefer unasked @@ -410,7 +410,7 @@ def test_prefer_oldest_asked_questions(self): QuestionBank.record_result(bank, "q-1", 1, today="2026-02-01") QuestionBank.record_result(bank, "q-2", 1, today="2026-02-11") - result = AdaptiveSelector.select_from_bank(bank, "closures", 2, "conceptual", "2026-02-12") + result = AdaptiveSelector.select_from_bank(bank, "closures", 2, "conceptual") self.assertIsNotNone(result) self.assertEqual(result["question_id"], "q-1") # older last_asked diff --git a/tests/test_plateau_detector.py b/tests/test_plateau_detector.py index 6ae617c..1517549 100644 --- a/tests/test_plateau_detector.py +++ b/tests/test_plateau_detector.py @@ -288,9 +288,9 @@ def make_review_history(grades: list) -> list: assert_eq("staleness count is 2 (broken by deep)", 2, r["consecutive_recall_sessions"]) assert_eq("staleness does not fire", False, r["rules"]["mode_staleness"]) -# --- Threshold overrides --- +# --- Default thresholds --- print() -print(" [threshold overrides]") +print(" [default thresholds]") with tempfile.TemporaryDirectory() as tmpdir: f = Fixtures(tmpdir) @@ -306,10 +306,6 @@ def make_review_history(grades: list) -> list: r = f.run() assert_eq("3 sessions below default threshold", False, r["rules"]["mode_staleness"]) - # Override threshold to 2 - r = f.run(extra_args=["--mode-staleness-threshold", "2"]) - assert_eq("3 sessions above overridden threshold", True, r["rules"]["mode_staleness"]) - # --- All three rules fire → PLATEAU_LIKELY + interleaved --- print() print(" [all rules fire]") diff --git a/tests/test_session_duration.py b/tests/test_session_duration.py index 025ba9f..a9534c2 100644 --- a/tests/test_session_duration.py +++ b/tests/test_session_duration.py @@ -123,7 +123,7 @@ def _write_transcript(self, name, timestamps, extra_noise=True): def test_find_prefers_session_id(self): self._write_transcript("aaa.jsonl", ["2026-01-01T00:00:00Z"]) self._write_transcript("bbb.jsonl", ["2026-01-02T00:00:00Z"]) - found = sd.find_transcript(session_id="aaa", cwd=self.cwd) + found = sd.resolve_transcript("aaa", self.cwd)[0] self.assertTrue(found.endswith("aaa.jsonl")) def test_find_falls_back_to_most_recent(self): @@ -131,7 +131,7 @@ def test_find_falls_back_to_most_recent(self): new = self._write_transcript("new.jsonl", ["2026-01-02T00:00:00Z"]) os.utime(old, (1_000_000, 1_000_000)) os.utime(new, (2_000_000, 2_000_000)) - found = sd.find_transcript(session_id="", cwd=self.cwd) + found = sd.resolve_transcript("", self.cwd)[0] self.assertTrue(found.endswith("new.jsonl")) def test_run_end_to_end_last_sitting(self): @@ -145,10 +145,10 @@ def test_run_end_to_end_last_sitting(self): "2026-01-01T01:30:00Z", ], ) - self.assertEqual(sd.run(session_id="s", cwd=self.cwd), "25m00s") + self.assertEqual(sd.compute(session_id="s", cwd=self.cwd)[0], "25m00s") def test_run_missing_transcript_returns_none(self): - self.assertIsNone(sd.run(session_id="nope", cwd="/no/such/project")) + self.assertIsNone(sd.compute(session_id="nope", cwd="/no/such/project")[0]) def test_end_is_last_timestamp_not_now(self): # A single sitting entirely in the distant past. If the end were derived @@ -158,7 +158,7 @@ def test_end_is_last_timestamp_not_now(self): "past.jsonl", ["2020-01-01T00:00:00Z", "2020-01-01T00:10:00Z"], ) - self.assertEqual(sd.run(session_id="past", cwd=self.cwd), "10m00s") + self.assertEqual(sd.compute(session_id="past", cwd=self.cwd)[0], "10m00s") # --- ADR-0004: resolve by session id, never by cwd --- @@ -170,7 +170,7 @@ def test_id_resolves_from_unrelated_cwd(self): "abc-123.jsonl", ["2026-01-01T00:00:00Z", "2026-01-01T00:15:00Z"], ) - self.assertEqual(sd.run(session_id="abc-123", cwd="/tmp/somewhere/else"), "15m00s") + self.assertEqual(sd.compute(session_id="abc-123", cwd="/tmp/somewhere/else")[0], "15m00s") def test_unknown_id_fails_even_when_cwd_dir_exists(self): # Pre-fix this silently returned the newest transcript's duration, exit 0. @@ -183,13 +183,13 @@ def test_unknown_id_fails_even_when_cwd_dir_exists(self): def test_env_var_used_when_no_argument(self): self._write_transcript("from-env.jsonl", ["2026-01-01T00:00:00Z", "2026-01-01T00:20:00Z"]) os.environ[sd.SESSION_ID_ENV] = "from-env" - self.assertEqual(sd.run(cwd="/tmp/somewhere/else"), "20m00s") + self.assertEqual(sd.compute(cwd="/tmp/somewhere/else")[0], "20m00s") def test_argument_overrides_env_var(self): self._write_transcript("from-env.jsonl", ["2026-01-01T00:00:00Z", "2026-01-01T00:20:00Z"]) self._write_transcript("from-argv.jsonl", ["2026-01-01T00:00:00Z", "2026-01-01T00:05:00Z"]) os.environ[sd.SESSION_ID_ENV] = "from-env" - self.assertEqual(sd.run(session_id="from-argv", cwd=self.cwd), "5m00s") + self.assertEqual(sd.compute(session_id="from-argv", cwd=self.cwd)[0], "5m00s") def test_error_names_the_id_source(self): _, from_argv, _ = sd.compute(session_id="nope", cwd=self.cwd) diff --git a/tests/test_session_router.py b/tests/test_session_router.py index 9b37ed3..128e261 100644 --- a/tests/test_session_router.py +++ b/tests/test_session_router.py @@ -53,6 +53,8 @@ def test_legacy_keyword_suggests_learn(self): out = sr.route("/sage", "continue") self.assertEqual(out["mode"], "unknown_verb") self.assertEqual(out["suggestion"], "learn") + # The generic branch would read "`/sage learn continue` to learn it". + self.assertNotIn("learn continue", out["message"]) class TestDiscoveryPredicates(RouterTestCase): diff --git a/tools/assessment/assessment_engine.py b/tools/assessment/assessment_engine.py index 80ddd07..ade61b9 100755 --- a/tools/assessment/assessment_engine.py +++ b/tools/assessment/assessment_engine.py @@ -22,7 +22,7 @@ import sys from datetime import date from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional # --------------------------------------------------------------------------- @@ -245,7 +245,7 @@ class AdaptiveSelector: @staticmethod def concept_priority(concept: str, bank: Dict[str, Any], - mastery: str, today: str) -> float: + today: str) -> float: """Compute priority score for a concept.""" cov = bank.get("coverage", {}).get(concept, {}) @@ -328,8 +328,7 @@ def select_question_type(mastery: str, bank: Dict[str, Any], @staticmethod def select_from_bank(bank: Dict[str, Any], concept: str, - difficulty: int, qtype: str, - today: str) -> Optional[Dict[str, Any]]: + difficulty: int, qtype: str) -> Optional[Dict[str, Any]]: """Find the best existing question from the bank. Returns the question dict, or None if no suitable question exists. @@ -397,7 +396,7 @@ def select(bank: Dict[str, Any], knowledge_map: Dict[str, Dict[str, str]], scored = [] for c, info in concepts.items(): mastery = info.get("status", "introduced") - priority = AdaptiveSelector.concept_priority(c, bank, mastery, today) + priority = AdaptiveSelector.concept_priority(c, bank, today) scored.append((priority, MASTERY_ORDER.index(mastery) if mastery in MASTERY_ORDER else 0, c)) # Sort by priority desc, then mastery asc (lower mastery = more need) scored.sort(key=lambda x: (-x[0], x[1])) @@ -415,7 +414,7 @@ def select(bank: Dict[str, Any], knowledge_map: Dict[str, Dict[str, str]], qtype = AdaptiveSelector.select_question_type(mastery, bank, concept) # Step 4: Bank lookup - question = AdaptiveSelector.select_from_bank(bank, concept, difficulty, qtype, today) + question = AdaptiveSelector.select_from_bank(bank, concept, difficulty, qtype) if question and question["question_id"] not in used_qids: used_qids.add(question["question_id"]) @@ -689,12 +688,8 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: Optional[List[str]] = None) -> int: parser = build_parser() args = parser.parse_args(argv) - handler = COMMANDS.get(args.command) - if not handler: - parser.print_help() - return 1 try: - result = handler(args) + result = COMMANDS[args.command](args) print(result) return 0 except Exception as e: diff --git a/tools/coach/coach_metrics.py b/tools/coach/coach_metrics.py index 798bb0a..e41f09e 100644 --- a/tools/coach/coach_metrics.py +++ b/tools/coach/coach_metrics.py @@ -16,7 +16,6 @@ import argparse import json import re -import sys from datetime import date from pathlib import Path from typing import Any, Dict, List, Optional, Tuple diff --git a/tools/coach/coach_reflector.py b/tools/coach/coach_reflector.py index b59af8d..c73c8a2 100644 --- a/tools/coach/coach_reflector.py +++ b/tools/coach/coach_reflector.py @@ -13,10 +13,9 @@ import argparse import json import re -import sys from collections import defaultdict from pathlib import Path -from typing import Any, Dict, List, Optional, Set, Tuple +from typing import Any, Dict, List, Optional, Set # Same directory, so sys.path[0] already covers this when run as a script. from coach_metrics import parse_current_session diff --git a/tools/plateau/plateau_detector.py b/tools/plateau/plateau_detector.py index 2339958..60fe712 100644 --- a/tools/plateau/plateau_detector.py +++ b/tools/plateau/plateau_detector.py @@ -491,26 +491,9 @@ def main() -> None: "--weak-spots", required=True, help="Path to weak-spots.md" ) - parser.add_argument("--stale-ws-threshold", type=int, default=None) - parser.add_argument("--flat-grade-window", type=int, default=None) - parser.add_argument("--flat-grade-threshold", type=float, default=None) - parser.add_argument("--mode-staleness-threshold", type=int, default=None) - parser.add_argument("--plateau-min-rules", type=int, default=None) - args = parser.parse_args() - # Build thresholds with overrides thresholds = dict(THRESHOLDS) - if args.stale_ws_threshold is not None: - thresholds["stale_ws_sessions"] = args.stale_ws_threshold - if args.flat_grade_window is not None: - thresholds["flat_grade_window"] = args.flat_grade_window - if args.flat_grade_threshold is not None: - thresholds["flat_grade_threshold"] = args.flat_grade_threshold - if args.mode_staleness_threshold is not None: - thresholds["mode_staleness_sessions"] = args.mode_staleness_threshold - if args.plateau_min_rules is not None: - thresholds["plateau_signal_min_rules"] = args.plateau_min_rules journal_dir = Path(args.journal_dir) srs_path = Path(args.srs) diff --git a/tools/session_duration.py b/tools/session_duration.py index dfc9e21..47b249b 100644 --- a/tools/session_duration.py +++ b/tools/session_duration.py @@ -107,11 +107,6 @@ def resolve_transcript(session_id="", cwd=None): ) -def find_transcript(session_id="", cwd=None): - """Path to the transcript, or None. Thin wrapper over resolve_transcript().""" - return resolve_transcript(session_id, cwd)[0] - - def parse_timestamps(path): stamps = [] with open(path) as f: @@ -176,11 +171,6 @@ def compute(session_id="", cwd=None): return fmt_duration(ms), None, warning -def run(session_id="", cwd=None): - """Formatted duration, or None. Thin wrapper over compute().""" - return compute(session_id, cwd)[0] - - def main(): session_id = sys.argv[1] if len(sys.argv) > 1 else "" duration, error, warning = compute(session_id=session_id) diff --git a/tools/session_router.py b/tools/session_router.py index 4286d27..5a30bd9 100644 --- a/tools/session_router.py +++ b/tools/session_router.py @@ -99,26 +99,6 @@ def derive_slug(topic): return slug.strip("-") -def find_journal(topic_path): - """Check for existing journal. Returns path if found, None otherwise.""" - current = os.path.join(topic_path, "journal", "index.md") - if os.path.isfile(current): - return current - return None - - -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")] @@ -129,6 +109,9 @@ def suggest_slug(slug, learning_root): def _unknown_verb(verb, topic, sage_root): """Build a helpful error for an unrecognized leading verb.""" if verb in LEGACY_RESUME_KEYWORDS: + # Without this branch the generic message below reads + # "`/sage learn continue` to learn it" — it would interpolate the + # keyword as if it were a topic name. hint = f"/sage learn {topic}".strip() message = f"'{verb}' is no longer a command. Did you mean `{hint}`?" suggestion = "learn" @@ -193,10 +176,10 @@ def route(sage_root, raw_args): 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) + has_journal = os.path.isfile(os.path.join(topic_path, "journal", "index.md")) if verb == "learn": - if journal: + if has_journal: has_insights = os.path.isfile( os.path.join(topic_path, "coach-insights.md") ) @@ -220,7 +203,7 @@ def route(sage_root, raw_args): # verb == "archive": target must resolve to an initialized project # (has a learning plan) — session history is not required. - if not find_plan(topic_path): + if not os.path.isfile(os.path.join(topic_path, "plan.md")): return { "mode": "archive_no_match", "slug": slug, diff --git a/tools/session_wrapup.py b/tools/session_wrapup.py index 0d617e9..07f509f 100644 --- a/tools/session_wrapup.py +++ b/tools/session_wrapup.py @@ -9,7 +9,7 @@ Each step catches failures independently — partial results are returned. Usage: - python3 session_wrapup.py [--session-id SESSION_ID] + python3 session_wrapup.py [--session-id SESSION_ID] Zero external dependencies — Python 3.8+ stdlib only. """ @@ -38,10 +38,7 @@ def run_script(cmd, label): return False, f"{label} failed: {e}" -def run(sage_root, topic_path, topic_slug, session_id=""): - # TODO: remove topic_slug — dead since session token metrics were removed (it only - # named the /tmp metrics file). Removal is a CLI change: also update argv parsing, - # both usage strings, and the caller in references/ref-session-end.md. +def run(sage_root, topic_path, session_id=""): errors = [] coach_metrics_flags = [] insight_updates = [] @@ -100,16 +97,15 @@ def run(sage_root, topic_path, topic_slug, session_id=""): def main(): - if len(sys.argv) < 4: + if len(sys.argv) < 3: print( - "Usage: session_wrapup.py [--session-id ID]", + "Usage: session_wrapup.py [--session-id ID]", file=sys.stderr, ) sys.exit(1) sage_root = sys.argv[1] topic_path = sys.argv[2] - topic_slug = sys.argv[3] session_id = "" if "--session-id" in sys.argv: @@ -117,7 +113,7 @@ def main(): if idx + 1 < len(sys.argv): session_id = sys.argv[idx + 1] - result = run(sage_root, topic_path, topic_slug, session_id) + result = run(sage_root, topic_path, session_id) print(json.dumps(result, indent=2)) diff --git a/tools/srs/journal_writer.py b/tools/srs/journal_writer.py index df1ce90..eb3305a 100644 --- a/tools/srs/journal_writer.py +++ b/tools/srs/journal_writer.py @@ -52,16 +52,13 @@ # Parsing # --------------------------------------------------------------------------- -def _parse_table(text: str) -> Tuple[Optional[List[str]], List[List[str]], str, str]: +def _parse_table(text: str) -> Tuple[Optional[List[str]], List[List[str]]]: """Parse a markdown table from text. - Returns: - (headers, rows, pre_table_text, post_table_text) - headers is None if no table found. + Returns (headers, rows). headers is None if no table found. """ lines = text.split("\n") table_start = None - table_end = None headers: Optional[List[str]] = None rows: List[List[str]] = [] @@ -79,20 +76,14 @@ def _parse_table(text: str) -> Tuple[Optional[List[str]], List[List[str]], str, # Data row cells = [c.strip() for c in stripped.strip("|").split("|")] rows.append(cells) - table_end = i elif table_start is not None and not stripped.startswith("|"): # End of table break if table_start is None: - return None, [], text, "" + return None, [] - if table_end is None: - table_end = table_start + 1 # just header + separator - - pre = "\n".join(lines[:table_start]) - post = "\n".join(lines[table_end + 1:]) - return headers, rows, pre, post + return headers, rows def _map_headers(source_headers: List[str]) -> List[Optional[str]]: @@ -129,7 +120,7 @@ def cmd_append(path: Path, row_json: Dict[str, Any]) -> None: path.write_text(text, encoding="utf-8") text = path.read_text(encoding="utf-8") - headers, rows, pre, post = _parse_table(text) + headers, rows = _parse_table(text) # Build the new row session_num = row_json.get("session_number", "") diff --git a/tools/srs/srs_engine.py b/tools/srs/srs_engine.py index f20419a..66cdb93 100755 --- a/tools/srs/srs_engine.py +++ b/tools/srs/srs_engine.py @@ -21,7 +21,7 @@ import json import re import sys -from datetime import date, datetime, timedelta +from datetime import date, timedelta from pathlib import Path from typing import Any, Dict, List, Optional, Tuple @@ -701,12 +701,8 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: Optional[List[str]] = None) -> int: parser = build_parser() args = parser.parse_args(argv) - handler = COMMANDS.get(args.command) - if not handler: - parser.print_help() - return 1 try: - result = handler(args) + result = COMMANDS[args.command](args) print(result) return 0 except Exception as e: diff --git a/tools/srs/weak_spot_writer.py b/tools/srs/weak_spot_writer.py index 1698979..ca22fb8 100644 --- a/tools/srs/weak_spot_writer.py +++ b/tools/srs/weak_spot_writer.py @@ -61,7 +61,7 @@ import re import sys from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional # --------------------------------------------------------------------------- # Kinds @@ -86,8 +86,6 @@ KIND_CP: "coach-errors.md", } -COACH_KINDS = {KIND_CE, KIND_CP} - VALID_CATEGORIES = frozenset({ "wrong-model", "incomplete-model", @@ -96,11 +94,6 @@ }) -def _heading_separator(kind: str) -> str: - """All kinds use hyphen: WS-1, CE-1, CP-1.""" - return "-" - - # --------------------------------------------------------------------------- # Canonical format (kind-dependent) # --------------------------------------------------------------------------- @@ -162,8 +155,7 @@ def _format_entry( ) -> str: """Format a single entry in canonical format for the given kind.""" canonical = _canonical_fields(kind) - sep = _heading_separator(kind) - lines = [f"## {kind}{sep}{number} — {description}", ""] + lines = [f"## {kind}-{number} — {description}", ""] for field_name in canonical: value = fields.get(field_name, "") if value and value.strip() and value.strip() != "—": @@ -370,7 +362,7 @@ def cmd_append( content += "\n\n" + formatted + "\n" path.write_text(content, encoding="utf-8") - label = f"{kind}{_heading_separator(kind)}{new_n}" + label = f"{kind}-{new_n}" print(f"Appended {label} — {description} to {path}") From 5bdf9eacb1d636f5ef1167337f54b44412222d37 Mon Sep 17 00:00:00 2001 From: 0-BSCode Date: Wed, 29 Jul 2026 22:19:29 +0800 Subject: [PATCH 6/6] Release 1.1.0 --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 47 +++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2295d60..df84d44 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ "name": "sage", "source": "./", "description": "Evidence-based learning coach with spaced repetition, retrieval practice, and mastery tracking", - "version": "1.0.2", + "version": "1.1.0", "author": { "name": "0-BSCode" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 069e089..4e20bd9 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/plugin.schema.json", "name": "sage", - "version": "1.0.2", + "version": "1.1.0", "description": "Evidence-based learning coach with spaced repetition, retrieval practice, and mastery tracking", "author": { "name": "0-BSCode", diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f424fb..75ac226 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,53 @@ All notable changes to the sage plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning rules: see [docs/RELEASING.md](docs/RELEASING.md). +## [1.1.0] - 2026-07-29 + +Over-engineering audit: ~1,900 lines removed from `tools/`, no feature lost. +Every cut was made against evidence of use — invocations recovered from session +transcripts, plus on-disk artifact state where a command writes. Minor, not +major: everything removed is an **Internal Tool**, outside the compatibility +surface. + +### Removed + +- Uninvoked tool subcommands: `journal_writer validate`, `weak_spot_writer + validate|fix`, `kmap_writer validate|fix-legend|ensure-sections`, + `assessment_engine coverage|stats|calibrate`, `coach_metrics trends|compare`, + and `srs_engine due --sort risk`. None had a caller in the shipped markdown + or a single invocation in five weeks of session transcripts. +- `tools/srs/find_duplicate_cards.py` — orphaned; `card_writer append` already + rejects duplicates at write time, which is the root-cause fix. +- Learner-level calibration. `estimated_level` was written only by `calibrate` + and read only by `stats`; the adaptive selector never consulted it. The + `learner_calibration` field stays in existing question banks and is ignored. +- Hook debug logging to `/tmp/sage-hook-debug.log`. Nothing read it, and under + `set -euo pipefail` an unwritable log could kill `enforce-cross-refs` before + it emitted its block decision — the guard would have failed open, silently. +- `plateau_detector`'s five threshold override flags, `session_wrapup`'s unused + `topic_slug` positional (extra arguments are ignored, so existing callers + keep working), and `session_duration`'s `find_transcript`/`run` wrappers. + +### Changed + +- The demo index is now `docs/demos/index.md` instead of `index.html`. The old + writer parsed its own generated HTML back out on every append and had already + dropped a live entry that way; rows are now kept as text and never re-parsed. + **Existing `index.html` files are not migrated** — convert by hand, or the + next appended demo starts a fresh `index.md`. +- `assessment_engine init` is now documented. It was always required — every + other subcommand fails without it — but appeared in no shipped markdown, so + it was being improvised. The agent is also told to pass `/learning/` + explicitly: a path one level too high silently creates a second, empty + question bank instead of erroring. +- `card_writer validate` and `demo_index_writer validate` documented as manual + diagnostics. + +### Fixed + +- The 68 assessment-engine tests never ran. They lived under `tools/`, which + `testpaths` excluded, so CI collected 296 tests instead of 364. + ## [1.0.2] - 2026-07-20 ### Added