diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a5c3d07..2295d60 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.1", + "version": "1.0.2", "author": { "name": "0-BSCode" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index fae76c8..069e089 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.1", + "version": "1.0.2", "description": "Evidence-based learning coach with spaced repetition, retrieval practice, and mastery tracking", "author": { "name": "0-BSCode", @@ -9,4 +9,5 @@ }, "skills": [ "./" - ]} + ] +} diff --git a/SKILL.md b/SKILL.md index f241132..5641640 100644 --- a/SKILL.md +++ b/SKILL.md @@ -15,8 +15,8 @@ $ARGUMENTS ## Step 0: Session Setup The command grammar is `/sage ` with exactly two verbs — `learn` -and `archive` (see `adr/0002-mandatory-command-verbs.md`). The router parses the -leading verb. Run it, passing `$ARGUMENTS` verbatim (it already includes the verb): +and `archive`. The verb is mandatory; there is no verb-less form. The router parses +the leading verb. Run it, passing `$ARGUMENTS` verbatim (it already includes the verb): ```bash SAGE_ROOT=$(cat /tmp/.sage-plugin-root) python3 "$SAGE_ROOT/tools/session_router.py" "$SAGE_ROOT" "$ARGUMENTS" @@ -55,12 +55,12 @@ command and none is planned. Nothing is deleted (the artifacts stay readable for reference), but the learner is giving up the tracking state: knowledge map, cards, and SRS schedule. Coming back to the topic means starting a fresh project. Make sure the learner understands that before proceeding — it is the whole point of the -confirmation. See `adr/0003-archive-by-move-recoverable.md`. +confirmation. 1. **Quiescent-project invariant.** `archive_project.py` only ever operates on an at-rest project. If the target `slug` is the project you have been teaching in *this* conversation and its state is unsaved, first run the full end-of-session - checklist (`docs/ref-session-end.md`) to persist journal, savepoint, and + checklist (`references/ref-session-end.md`) to persist journal, savepoint, and cross-refs. Only then proceed. (Cold targets — any project you are not actively teaching — are already quiescent; skip straight to step 2.) diff --git a/tests/test_session_duration.py b/tests/test_session_duration.py index edaffcd..025ba9f 100644 --- a/tests/test_session_duration.py +++ b/tests/test_session_duration.py @@ -10,6 +10,11 @@ - run() end-to-end against a fixture transcript - missing transcript -> run() None and CLI non-zero exit - lines without timestamps are ignored +- an id is resolved from ANY cwd (the cwd-resolution bug, ADR-0004) +- an unresolvable id fails hard instead of guessing +- $CLAUDE_CODE_SESSION_ID is used when no id is passed; argv overrides it +- the no-id fallback warns that it guessed +- the three failure modes emit distinguishable messages """ import datetime @@ -85,6 +90,9 @@ def setUp(self): self.home = tempfile.mkdtemp() self._orig_home = os.environ.get("HOME") os.environ["HOME"] = self.home + # The real session's id leaks in from the environment and would be + # resolved ahead of any fixture. Tests opt in explicitly instead. + self._orig_sid = os.environ.pop(sd.SESSION_ID_ENV, None) self.cwd = "/fake/project" self.pdir = sd.project_dir(self.cwd) os.makedirs(self.pdir, exist_ok=True) @@ -94,6 +102,10 @@ def tearDown(self): os.environ["HOME"] = self._orig_home else: del os.environ["HOME"] + if self._orig_sid is not None: + os.environ[sd.SESSION_ID_ENV] = self._orig_sid + else: + os.environ.pop(sd.SESSION_ID_ENV, None) import shutil shutil.rmtree(self.home, ignore_errors=True) @@ -148,6 +160,69 @@ def test_end_is_last_timestamp_not_now(self): ) self.assertEqual(sd.run(session_id="past", cwd=self.cwd), "10m00s") + # --- ADR-0004: resolve by session id, never by cwd --- + + def test_id_resolves_from_unrelated_cwd(self): + # THE regression test. The transcript lives under /fake/project's slug; + # we look it up from a cwd that has no transcript directory at all. + # Pre-fix this returned None, and the coach asked for a stopwatch reading. + self._write_transcript( + "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") + + def test_unknown_id_fails_even_when_cwd_dir_exists(self): + # Pre-fix this silently returned the newest transcript's duration, exit 0. + self._write_transcript("real.jsonl", ["2026-01-01T00:00:00Z", "2026-01-01T00:15:00Z"]) + duration, error, _ = sd.compute(session_id="not-a-real-id", cwd=self.cwd) + self.assertIsNone(duration) + self.assertIn("not-a-real-id", error) + self.assertIn("refusing to guess", error) + + 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") + + 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") + + def test_error_names_the_id_source(self): + _, from_argv, _ = sd.compute(session_id="nope", cwd=self.cwd) + os.environ[sd.SESSION_ID_ENV] = "nope" + _, from_env, _ = sd.compute(cwd=self.cwd) + self.assertIn("from argument", from_argv) + self.assertIn(f"from ${sd.SESSION_ID_ENV}", from_env) + + # --- no-id fallback: still works, but announces the guess --- + + def test_no_id_fallback_warns_on_success(self): + self._write_transcript("guess.jsonl", ["2026-01-01T00:00:00Z", "2026-01-01T00:30:00Z"]) + duration, error, warning = sd.compute(cwd=self.cwd) + self.assertEqual(duration, "30m00s") + self.assertIsNone(error) + self.assertIn("guessed newest transcript", warning) + + def test_three_failure_modes_are_distinguishable(self): + # 1. no id, no transcript directory for this cwd + _, no_dir, _ = sd.compute(cwd="/no/such/project") + # 2. no id, directory exists but is empty + _, no_files, _ = sd.compute(cwd=self.cwd) + # 3. transcript found but nothing parseable in it + path = os.path.join(self.pdir, "empty.jsonl") + with open(path, "w") as f: + f.write(json.dumps({"type": "summary"}) + "\n") + _, no_stamps, _ = sd.compute(session_id="empty", cwd=self.cwd) + + self.assertIn("no transcript directory for cwd", no_dir) + self.assertIn("contains no .jsonl files", no_files) + self.assertIn("no parseable timestamps", no_stamps) + self.assertEqual(len({no_dir, no_files, no_stamps}), 3) + class TestCLI(unittest.TestCase): def test_missing_transcript_exits_nonzero(self): @@ -162,6 +237,30 @@ def test_missing_transcript_exits_nonzero(self): ) self.assertNotEqual(proc.returncode, 0) self.assertEqual(proc.stdout.strip(), "") + self.assertIn("does-not-exist", proc.stderr) + + def test_bogus_id_does_not_silently_return_a_duration(self): + # Pre-fix regression: a nonexistent id from a directory that HAS + # transcripts printed the newest one's duration and exited 0. + home = tempfile.mkdtemp() + pdir = os.path.join(home, ".claude", "projects", "-tmp") + os.makedirs(pdir) + with open(os.path.join(pdir, "someone-else.jsonl"), "w") as f: + for ts in ("2026-01-01T00:00:00Z", "2026-01-01T00:42:00Z"): + f.write(json.dumps({"type": "assistant", "timestamp": ts}) + "\n") + + env = dict(os.environ) + env["HOME"] = home + proc = subprocess.run( + [sys.executable, str(TOOL), "00000000-dead-beef-0000-000000000000"], + capture_output=True, + text=True, + cwd="/tmp", + env=env, + ) + self.assertNotEqual(proc.returncode, 0) + self.assertEqual(proc.stdout.strip(), "") + self.assertNotIn("42m", proc.stderr) if __name__ == "__main__": diff --git a/tools/archive_project.py b/tools/archive_project.py index 59fef04..f96a2b1 100644 --- a/tools/archive_project.py +++ b/tools/archive_project.py @@ -6,7 +6,8 @@ there, and scrubbing every reference to it from the cross-refs `INDEX.md`. Archival is one-way-but-recoverable: nothing is deleted, and the removed INDEX fragments + provenance are stashed in `archive-meta.json` inside the -archive directory. See adr/0003-archive-by-move-recoverable.md. +archive directory. There is no `unarchive` verb, by design — recovery is a +documented manual procedure, not a command. This tool is STATELESS and assumes a QUIESCENT project — the coach must checkpoint any live session for this slug before invoking it. diff --git a/tools/session_duration.py b/tools/session_duration.py index 43c8296..dfc9e21 100644 --- a/tools/session_duration.py +++ b/tools/session_duration.py @@ -1,23 +1,38 @@ #!/usr/bin/env python3 """Current-sitting wall time for a Sage session, from its transcript. -Reads the Claude Code transcript JSONL at -``~/.claude/projects//.jsonl`` and computes the duration of -the CURRENT sitting: the last timestamp minus the first timestamp after the most -recent gap > SITTING_GAP_SECONDS. Compact/resume keeps the same session id and -appends to the same file, so a naive first->last would span days across sittings. +Resolves the Claude Code transcript **by session id**, then computes the duration +of the CURRENT sitting: the last timestamp minus the first timestamp after the +most recent gap > SITTING_GAP_SECONDS. Compact/resume keeps the same session id +and appends to the same file, so a naive first->last would span days across +sittings. -The is the working directory with every non-alphanumeric character -replaced by '-' (Claude Code's encoding — e.g. ``/home/u/.claude`` -> ``-home-u--claude``). +Transcript resolution, in order: + +1. An explicit session id argument, if given. +2. ``$CLAUDE_CODE_SESSION_ID`` — Claude Code puts this in the environment and it + is inherited by subprocesses, so no plumbing is needed. + + Either way the id is looked up as ``~/.claude/projects/*/.jsonl``. A known + id that resolves to nothing is a HARD FAILURE — the tool will not fall back to + guessing, because a duration from the wrong session is indistinguishable from a + correct one once it reaches the journal. + +3. No id at all (manual invocation from a terminal): fall back to the newest + .jsonl in the directory derived from the cwd, and warn on stderr that the + answer was guessed. + +Note that the directory name encodes the directory Claude Code was +LAUNCHED in, not the current one — and the Bash tool persists ``cd`` across calls. +That is why the slug is not used to find the transcript: any ``cd`` during the +session would silently repoint the lookup at a directory that never existed. Usage: python3 session_duration.py [session_id] -If session_id is given, that transcript is used; otherwise the most-recently -modified .jsonl in the project's transcript folder is used. - Prints the formatted duration (e.g. "42m15s") to stdout on success. -Exits non-zero with no stdout if no transcript or timestamps can be resolved. +Exits non-zero with no stdout, and a message naming the specific failure, if no +transcript or timestamps can be resolved. Zero external dependencies — Python 3.8+ stdlib only. """ @@ -30,24 +45,71 @@ import sys SITTING_GAP_SECONDS = 30 * 60 # a quiet gap longer than this starts a new sitting +SESSION_ID_ENV = "CLAUDE_CODE_SESSION_ID" + + +def projects_root(): + return os.path.join(os.path.expanduser("~"), ".claude", "projects") def project_dir(cwd=None): + """Transcript directory for a working directory, using Claude Code's encoding. + + Only used by the no-id fallback — see the module docstring for why this is not + the primary lookup. + """ cwd = cwd if cwd is not None else os.getcwd() slug = re.sub(r"[^a-zA-Z0-9]", "-", cwd) - return os.path.join(os.path.expanduser("~"), ".claude", "projects", slug) + return os.path.join(projects_root(), slug) -def find_transcript(session_id="", cwd=None): +def resolve_transcript(session_id="", cwd=None): + """Locate the transcript. Returns (path, error, warning); path is None on failure. + + An id — explicit or from the environment — is authoritative: if it resolves to + nothing, that is an error, never a reason to guess. + """ + sid = session_id or os.environ.get(SESSION_ID_ENV, "") + source = "argument" if session_id else f"${SESSION_ID_ENV}" + + if sid: + matches = glob.glob(os.path.join(projects_root(), "*", f"{sid}.jsonl")) + if not matches: + return None, ( + f"session id {sid} (from {source}): no transcript found under " + f"{os.path.join(projects_root(), '*')}/ — refusing to guess" + ), None + warning = None + if len(matches) > 1: + warning = ( + f"session id {sid} matched {len(matches)} transcripts; " + f"using the most recently modified" + ) + return max(matches, key=os.path.getmtime), None, warning + + # No id at all — manual invocation. Guess, but say so. pdir = project_dir(cwd) - if session_id: - candidate = os.path.join(pdir, f"{session_id}.jsonl") - if os.path.isfile(candidate): - return candidate + if not os.path.isdir(pdir): + return None, ( + f"no session id (${SESSION_ID_ENV} unset, none given) and no transcript " + f"directory for cwd '{cwd if cwd is not None else os.getcwd()}' " + f"(looked in {pdir})" + ), None + files = glob.glob(os.path.join(pdir, "*.jsonl")) if not files: - return None - return max(files, key=os.path.getmtime) + return None, f"transcript directory {pdir} contains no .jsonl files", None + + newest = max(files, key=os.path.getmtime) + return newest, None, ( + f"no session id; guessed newest transcript in {pdir} " + f"({os.path.basename(newest)}) — may belong to another session" + ) + + +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): @@ -99,23 +161,33 @@ def fmt_duration(ms): return f"{secs}s" -def run(session_id="", cwd=None): - path = find_transcript(session_id, cwd) - if not path: - return None +def compute(session_id="", cwd=None): + """Return (duration, error, warning). duration is None on failure.""" + path, error, warning = resolve_transcript(session_id, cwd) + if path is None: + return None, error, warning + sitting = current_sitting(parse_timestamps(path)) if not sitting: - return None + return None, f"transcript {path} has no parseable timestamps", warning + start, end = sitting ms = int((end - start).total_seconds() * 1000) - return fmt_duration(ms) + 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 = run(session_id=session_id) + duration, error, warning = compute(session_id=session_id) + if warning: + print(f"warning: {warning}", file=sys.stderr) if duration is None: - print("No transcript or timestamps found", file=sys.stderr) + print(error or "No transcript or timestamps found", file=sys.stderr) sys.exit(1) print(duration) diff --git a/tools/session_router.py b/tools/session_router.py index f405f03..4286d27 100644 --- a/tools/session_router.py +++ b/tools/session_router.py @@ -2,8 +2,8 @@ """Session router for Sage. Single entry point for every `/sage` invocation. The grammar is -`/sage ` with exactly two verbs, `learn` and `archive` -(see adr/0002-mandatory-command-verbs.md). The router parses the verb, +`/sage ` with exactly two verbs, `learn` and `archive`. +The verb is mandatory; there is no verb-less form. The router parses it, resolves config, and returns structured JSON so the coach can branch on `mode` without multiple tool calls. It is a read-only dispatcher — it never mutates the filesystem. Archival mutation lives in