Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
5 changes: 3 additions & 2 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
{
"$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",
"email": "bipsanchez.work@gmail.com"
},
"skills": [
"./"
]}
]
}
8 changes: 4 additions & 4 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ $ARGUMENTS
## Step 0: Session Setup

The command grammar is `/sage <verb> <topic>` 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"
Expand Down Expand Up @@ -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.)

Expand Down
99 changes: 99 additions & 0 deletions tests/test_session_duration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand All @@ -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__":
Expand Down
3 changes: 2 additions & 1 deletion tools/archive_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
126 changes: 99 additions & 27 deletions tools/session_duration.py
Original file line number Diff line number Diff line change
@@ -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/<cwd-slug>/<session-id>.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 <cwd-slug> 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/*/<id>.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 <cwd-slug> 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.
"""
Expand All @@ -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):
Expand Down Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions tools/session_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"""Session router for Sage.

Single entry point for every `/sage` invocation. The grammar is
`/sage <verb> <topic>` with exactly two verbs, `learn` and `archive`
(see adr/0002-mandatory-command-verbs.md). The router parses the verb,
`/sage <verb> <topic>` 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
Expand Down
Loading