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
10 changes: 8 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@
"name": "sage",
"source": "./",
"description": "Evidence-based learning coach with spaced repetition, retrieval practice, and mastery tracking",
"version": "1.0.0",
"version": "1.0.1",
"author": {
"name": "0-BSCode"
},
"category": "productivity",
"homepage": "https://github.com/0-BSCode/sage",
"tags": ["learning", "spaced-repetition", "flashcards", "mastery-tracking", "socratic-tutoring"]
"tags": [
"learning",
"spaced-repetition",
"flashcards",
"mastery-tracking",
"socratic-tutoring"
]
}
]
}
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
"name": "sage",
"version": "1.0.0",
"version": "1.0.1",
"description": "Evidence-based learning coach with spaced repetition, retrieval practice, and mastery tracking",
"author": {
"name": "0-BSCode",
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ These agents are backed by ~30 Python CLI tools handling SRS scheduling, card ma
<details>
<summary><strong>What Claude Code plan do I need?</strong></summary>

Sage uses Opus and spawns multiple subagents per session. The **Max plan** is recommended. Pro plan users will likely hit rate limits mid-session, which interrupts the teaching flow. Token consumption is tracked per session in your journal entries.
Sage uses Opus and spawns multiple subagents per session. The **Max plan** is recommended. Pro plan users will likely hit rate limits mid-session, which interrupts the teaching flow. Session duration is recorded in your journal entries.
</details>

<details>
Expand Down
36 changes: 10 additions & 26 deletions agents/artifact-clerk.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,21 +176,10 @@ Session Data:
- Session number: [N]
- Date: [YYYY-MM-DD]
- Session type: [Quick/Deep/Spaced Review]
- Metrics file: [path, e.g. "/tmp/session-metrics-frontend-maintainability.txt" — if provided]
- Duration: [pre-formatted by coach, e.g. "42m15s"]
- Context: [pre-formatted by coach, e.g. "12% of 1M (120000 tokens)"]
- Conversation: [pre-formatted by coach, e.g. "📥 26000 in / 📤 79700 out"]
- Subagents: [pre-formatted by coach — full per-invocation breakdown with timestamps, written verbatim]
- Grand total: [pre-formatted by coach, e.g. "2409835"]
- Duration: [wall time, e.g. "42m15s" — from session_duration.py via wrapup, if provided]
- Resumed from: [previous savepoint or "Fresh start"]

**Metrics file passthrough (preferred):** If the coach provides a metrics file path (e.g., `Metrics file: /tmp/session-metrics-<slug>.txt`), read that file with the Read tool and write its contents verbatim into the Session Metrics section of the journal entry. Do NOT reformat, summarize, or drop any fields. The file content replaces Duration/Context/Conversation/Subagents/Grand total fields — do not also write inline metrics if the file is available.

**Metrics validation (fallback):** If metrics are provided inline (no file path), validate before writing:
1. Every subagent line MUST contain all four fields: `in:`, `out:`, `cache-create:`, `cache-read:`
2. Every agent group MUST have per-invocation timestamp lines indented below it
3. The aggregate line MUST include `fresh` keyword and all four fields
If any field is missing, read `<path>/logs/subagent-tokens.jsonl` directly, filter entries by session date, and reconstruct the correct format. Emit WARN: "Metrics were incomplete — reconstructed from JSONL log."
**Duration handling:** Write the provided `Duration` value into the journal entry's `**Duration:**` field. If no duration was provided (e.g. wrapup could not resolve the session transcript), ask the learner for the session wall time. Do NOT reconstruct from token logs — session token tracking has been removed.

What Was Covered:
[bullet list from coach]
Expand Down Expand Up @@ -280,11 +269,7 @@ weak_spots:

**Focus:** [topics covered]
**Session Type:** [Quick / Deep / Spaced Review]
**Duration:** [wall time]
**Context:** [from metrics file or coach — e.g. "12% of 1M (120000 tokens)"]
**Conversation:** [from metrics file or coach — e.g. "📥 26000 in / 📤 79700 out"]
**Subagents:** [from metrics file or coach — full per-invocation breakdown with all four fields: in, out, cache-create, cache-read]
**Grand total:** [from metrics file or coach — e.g. "2409835"]
**Duration:** [wall time — from session_duration.py via wrapup]
**Resumed from:** [Session N-1 savepoint / Fresh start]

### What Was Covered
Expand Down Expand Up @@ -603,7 +588,7 @@ Run these checks and collect results:

6. **Capstone-to-knowledge-map:** If `capstone.md` exists, check the Technical Requirements table. Verify that every concept listed there still has status >= `solid` in `knowledge-map.md`. If any concept has regressed below `solid`, WARN: "Capstone requirement [concept] has regressed to [status] — consider a review session or running `/capstone` to review the spec."

7. **Session metrics provided:** If `Duration:`, `Context:`, `Conversation:`, or `Grand total:` is missing, empty, or contains the word "approximate", emit a WARN: "Session metrics incomplete — check that session-tokens.sh ran successfully."
7. **Session duration provided:** If `Duration:` is missing or empty, emit a WARN: "Session duration missing — check that session_duration.py resolved the transcript, or ask the learner for wall time."

8. **Card type coverage:** Parse the tags of each new card and count the distribution by type. Report it in the confirmation report as `Cards by type: <N> fact, <N> why, <N> process, <N> discrimination, <N> transfer, <N> reverse, <N> error`. If more than 70% of new cards in this session share a single type, emit a WARN: "Card type distribution skewed toward <type> (<N>/<total>). Consider whether other cognitive operations on this material are being tested."

Expand Down Expand Up @@ -710,23 +695,22 @@ Path: <topic-slug>/learning/

## Operation: `patch-metrics`

**Purpose:** Append session token metrics to the latest journal entry after all post-checkpoint work is complete.
**Purpose:** Patch the session duration into the latest journal entry after wrapup computes it (wrapup runs after the checkpoint that wrote the entry, so the duration arrives late).

**Input format:**
```
Operation: patch-metrics
Path: <topic-slug>/learning/
Metrics file: /tmp/session-metrics-<topic-slug>.txt
Duration: <wall time, e.g. "42m15s">
```

**Steps:**

1. Read the metrics file at the path provided.
2. Find the latest `journal/session-NN.md` file (highest NN).
3. Append a `### Token Metrics` section to the end of that file with the metrics file contents verbatim. Do NOT reformat, summarize, or edit the metrics — the file is the source of truth.
4. If the metrics file doesn't exist or is empty, report "No metrics file found" and skip.
1. Find the latest `journal/session-NN.md` file (highest NN).
2. Set that entry's `**Duration:**` field to the provided value (replacing any placeholder or empty value).
3. If no `Duration` was provided, report "No duration provided" and skip.

**Output:** Confirmation of what was appended and to which journal entry.
**Output:** Confirmation of the duration patched and to which journal entry.

---

Expand Down
9 changes: 5 additions & 4 deletions docs/ref-session-end.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,18 @@ python3 "$SAGE_ROOT/tools/session_wrapup.py" "$SAGE_ROOT" "<topic_path>" "<topic

If `coach_metrics_flags` is non-empty, mention the flags in your session summary.
If `insight_updates` is non-empty, update the corresponding CI-# entries in `coach-insights.md`.
The wrapup returns `duration` (current-sitting wall time from the session transcript, or null) — used in step 7.
If any `errors`, note them but don't block — these are non-critical.

## 7. Patch Metrics into Journal
## 7. Patch Duration into Journal

```
Task(subagent_type="artifact-clerk", prompt="Operation: patch-metrics\nPath: <topic-slug>/learning/\nMetrics file: /tmp/session-metrics-<topic-slug>.txt")
Task(subagent_type="artifact-clerk", prompt="Operation: patch-metrics\nPath: <topic-slug>/learning/\nDuration: <duration from step 6>")
```

The clerk reads the metrics file and appends it to the latest journal entry.
The clerk patches the session duration into the latest journal entry.

**Fallback:** If the metrics file wasn't created (check `metrics_ok` from step 6), ask the learner to report from their status line: wall time, context percentage and window size, input tokens, and output tokens. For duration, read `duration_ms` from `/tmp/claude-session-metrics.json` and convert to human-readable (e.g., 2535000 → "42m15s").
**Fallback:** If `duration` from step 6 is null (wrapup could not resolve the transcript), ask the learner for the session wall time and pass that as the `Duration` value instead.

## 8. Confirm to Learner

Expand Down
1 change: 0 additions & 1 deletion hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ Each entry: `HH:MM:SS <hook-name>: <outcome>`.
| Reset verification | PostToolUse (Agent) | `scripts/reset-verification.sh` | Resets counter when verification-gate agent is called. Creates counter file on first call. |
| Checkpoint guard | PreToolUse (Agent) | `scripts/checkpoint-guard.sh` | Guards checkpoint calls. |
| Enforce cross-refs | Stop | `scripts/enforce-cross-refs.sh` | Blocks session end if knowledge maps were modified but cross-refs/ wasn't updated. |
| Track subagent | SubagentStop | `scripts/track-subagent.sh` | Tracks subagent token consumption. |

## Known Issues

Expand Down
11 changes: 0 additions & 11 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,6 @@
}
]
}
],
"SubagentStop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/track-subagent.sh"
}
]
}
]
}
}
52 changes: 0 additions & 52 deletions hooks/scripts/track-subagent.sh

This file was deleted.

2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@
if p not in sys.path:
sys.path.insert(0, p)

# Top-level tools/ for session_metrics, mcp_server
# Top-level tools/ for session_duration, mcp_server
if str(TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(TOOLS_DIR))
168 changes: 168 additions & 0 deletions tests/test_session_duration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Tests for session_duration.py — current-sitting wall time from a transcript.

Covers:
- current_sitting counts only the last sitting when a >30min gap exists
- current_sitting returns the full span when there is no big gap
- fmt_duration formatting (s / m / h)
- project_dir encodes every non-alphanumeric char as '-' (slash AND dot)
- find_transcript prefers session_id, falls back to most-recent .jsonl
- run() end-to-end against a fixture transcript
- missing transcript -> run() None and CLI non-zero exit
- lines without timestamps are ignored
"""

import datetime
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))
import session_duration as sd # noqa: E402

TOOL = Path(__file__).resolve().parent.parent / "tools" / "session_duration.py"


def _dt(iso):
return datetime.datetime.fromisoformat(iso.replace("Z", "+00:00"))


class TestCurrentSitting(unittest.TestCase):
def test_last_sitting_only_after_big_gap(self):
stamps = [
_dt("2026-01-01T00:00:00Z"),
_dt("2026-01-01T00:05:00Z"),
# >30min gap here
_dt("2026-01-01T01:05:00Z"),
_dt("2026-01-01T01:20:00Z"),
_dt("2026-01-01T01:30:00Z"),
]
start, end = sd.current_sitting(stamps)
self.assertEqual(start, _dt("2026-01-01T01:05:00Z"))
self.assertEqual(end, _dt("2026-01-01T01:30:00Z"))

def test_no_gap_returns_full_span(self):
stamps = [
_dt("2026-01-01T00:00:00Z"),
_dt("2026-01-01T00:10:00Z"),
_dt("2026-01-01T00:20:00Z"),
]
start, end = sd.current_sitting(stamps)
self.assertEqual(start, stamps[0])
self.assertEqual(end, stamps[-1])

def test_empty_returns_none(self):
self.assertIsNone(sd.current_sitting([]))


class TestFmtDuration(unittest.TestCase):
def test_seconds(self):
self.assertEqual(sd.fmt_duration(45_000), "45s")

def test_minutes(self):
self.assertEqual(sd.fmt_duration(25 * 60 * 1000), "25m00s")

def test_hours(self):
self.assertEqual(sd.fmt_duration((2 * 3600 + 5 * 60 + 3) * 1000), "2h05m03s")


class TestProjectDir(unittest.TestCase):
def test_encodes_slash_and_dot(self):
pdir = sd.project_dir("/home/u/.claude/commands")
self.assertTrue(pdir.endswith("-home-u--claude-commands"))

def test_preserves_case_and_hyphen(self):
pdir = sd.project_dir("/home/b/Documents/sage-plugin")
self.assertTrue(pdir.endswith("-home-b-Documents-sage-plugin"))


class TestTranscriptLookupAndRun(unittest.TestCase):
def setUp(self):
self.home = tempfile.mkdtemp()
self._orig_home = os.environ.get("HOME")
os.environ["HOME"] = self.home
self.cwd = "/fake/project"
self.pdir = sd.project_dir(self.cwd)
os.makedirs(self.pdir, exist_ok=True)

def tearDown(self):
if self._orig_home is not None:
os.environ["HOME"] = self._orig_home
else:
del os.environ["HOME"]
import shutil

shutil.rmtree(self.home, ignore_errors=True)

def _write_transcript(self, name, timestamps, extra_noise=True):
path = os.path.join(self.pdir, name)
with open(path, "w") as f:
if extra_noise:
# metadata lines without a timestamp — must be ignored
f.write(json.dumps({"type": "summary", "leafUuid": "x"}) + "\n")
for ts in timestamps:
f.write(json.dumps({"type": "assistant", "timestamp": ts}) + "\n")
return path

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)
self.assertTrue(found.endswith("aaa.jsonl"))

def test_find_falls_back_to_most_recent(self):
old = self._write_transcript("old.jsonl", ["2026-01-01T00:00:00Z"])
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)
self.assertTrue(found.endswith("new.jsonl"))

def test_run_end_to_end_last_sitting(self):
self._write_transcript(
"s.jsonl",
[
"2026-01-01T00:00:00Z",
"2026-01-01T00:05:00Z",
# >30min gap
"2026-01-01T01:05:00Z",
"2026-01-01T01:30:00Z",
],
)
self.assertEqual(sd.run(session_id="s", cwd=self.cwd), "25m00s")

def test_run_missing_transcript_returns_none(self):
self.assertIsNone(sd.run(session_id="nope", cwd="/no/such/project"))

def test_end_is_last_timestamp_not_now(self):
# A single sitting entirely in the distant past. If the end were derived
# from datetime.now() instead of stamps[-1], the duration would be years,
# not the 10-minute transcript span. Pins the deliberate stamps[-1] choice.
self._write_transcript(
"past.jsonl",
["2020-01-01T00:00:00Z", "2020-01-01T00:10:00Z"],
)
self.assertEqual(sd.run(session_id="past", cwd=self.cwd), "10m00s")


class TestCLI(unittest.TestCase):
def test_missing_transcript_exits_nonzero(self):
env = dict(os.environ)
env["HOME"] = tempfile.mkdtemp()
proc = subprocess.run(
[sys.executable, str(TOOL), "does-not-exist"],
capture_output=True,
text=True,
cwd="/tmp",
env=env,
)
self.assertNotEqual(proc.returncode, 0)
self.assertEqual(proc.stdout.strip(), "")


if __name__ == "__main__":
unittest.main()
Loading
Loading