diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index df84d44..5e61e31 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.1.0", + "version": "1.2.0", "author": { "name": "0-BSCode" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 4e20bd9..568dac7 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,13 +1,14 @@ { "$schema": "https://anthropic.com/claude-code/plugin.schema.json", "name": "sage", - "version": "1.1.0", + "version": "1.2.0", "description": "Evidence-based learning coach with spaced repetition, retrieval practice, and mastery tracking", "author": { "name": "0-BSCode", "email": "bipsanchez.work@gmail.com" }, "skills": [ - "./" - ] + "./skills/sage" + ], + "hooks": "./hooks/claude-codex-hooks.json" } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..95c4892 --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,29 @@ +{ + "name": "sage", + "version": "1.2.0", + "description": "Evidence-based learning coach with spaced repetition, retrieval practice, and mastery tracking", + "author": { + "name": "0-BSCode" + }, + "homepage": "https://github.com/0-BSCode/sage", + "repository": "https://github.com/0-BSCode/sage", + "license": "MIT", + "keywords": [ + "learning", + "spaced-repetition", + "flashcards", + "mastery-tracking", + "socratic-tutoring" + ], + "skills": "./skills/", + "hooks": "./hooks/claude-codex-hooks.json", + "interface": { + "displayName": "Sage", + "shortDescription": "Evidence-based learning coach with spaced repetition", + "longDescription": "Coaches durable mastery of a topic through retrieval practice, spaced repetition, and Socratic questioning rather than lecturing.", + "developerName": "0-BSCode", + "category": "Productivity", + "capabilities": ["Instructions", "Lifecycle hooks"], + "websiteURL": "https://github.com/0-BSCode/sage" + } +} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c92bb4b..25c8689 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,14 +31,25 @@ jobs: with: fetch-depth: 0 + - name: Manifests must pass schema validation + # Catches schema-level rot the runtime tolerates (unrecognized fields, + # missing metadata). Complements tests/test_plugin_manifests.py, which + # checks manifest-vs-layout coherence instead. + run: npx -y @anthropic-ai/claude-code plugin validate . --strict + - name: Version fields must match (plugin.json is authoritative) run: | plugin=$(jq -r .version .claude-plugin/plugin.json) marketplace=$(jq -r '.plugins[0].version' .claude-plugin/marketplace.json) + codex=$(jq -r .version .codex-plugin/plugin.json) if [ "$plugin" != "$marketplace" ]; then echo "::error::marketplace.json ($marketplace) must mirror plugin.json ($plugin) — see docs/RELEASING.md" exit 1 fi + if [ "$plugin" != "$codex" ]; then + echo "::error::.codex-plugin/plugin.json ($codex) must mirror .claude-plugin/plugin.json ($plugin) — see docs/RELEASING.md" + exit 1 + fi echo "Versions in sync: $plugin" - name: Shipping changes must bump version and update changelog @@ -51,13 +62,18 @@ jobs: # Shipped files. Manifest edits count only when something OTHER than # the version fields changed — otherwise a bump alone would make a # docs-only PR look like a shipping change and dodge the rejection. - shipped=$(git diff --name-only "$base"...HEAD -- SKILL.md agents hooks references tools) - for f in $(git diff --name-only "$base"...HEAD -- .claude-plugin); do + shipped=$(git diff --name-only "$base"...HEAD -- SKILL.md agents hooks references skills tools) + for f in $(git diff --name-only "$base"...HEAD -- .claude-plugin .codex-plugin); do case "$f" in - .claude-plugin/plugin.json) strip='del(.version)' ;; - .claude-plugin/marketplace.json) strip='del(.plugins[].version)' ;; + .claude-plugin/plugin.json|.codex-plugin/plugin.json) strip='del(.version)' ;; + .claude-plugin/marketplace.json) strip='del(.plugins[].version)' ;; *) shipped="$shipped"$'\n'"$f"; continue ;; esac + # A manifest absent from base is new — a shipping change, and + # `git show` on it would fail the step. + if ! git cat-file -e "$base:$f" 2>/dev/null; then + shipped="$shipped"$'\n'"$f"; continue + fi if [ "$(git show "$base":"$f" | jq "$strip")" != "$(jq "$strip" "$f")" ]; then shipped="$shipped"$'\n'"$f" fi diff --git a/.gitignore b/.gitignore index 49352aa..895374f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ context/ __pycache__ -CONTEXT.md -docs/adr/ graveyard/ +.obsidian/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 75ac226..96f672b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,78 @@ 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.2.0] - 2026-08-04 + +Multi-Host support: Sage now installs on Codex CLI with one command, alongside +Claude Code. Minor, not major — the **Compatibility Surface** is untouched: +`/sage learn ` still works and every existing **Artifact** reads unchanged. +Most of this improves the Claude build too, because it is mostly deletion. + +Design and decisions: ADRs 0006–0008. + +### Added + +- `.codex-plugin/plugin.json` — Codex install. Both manifests point at one + `hooks/claude-codex-hooks.json`; `${CLAUDE_PLUGIN_ROOT}` expands on both Hosts + and Codex normalizes the event names, so there is no per-Host hook config. +- `skills/sage/agents/openai.yaml` — Codex UI metadata and invocation policy. +- `tools/cross_refs_check.py` — the cross-refs invariant, extracted from the hook. +- `tests/test_plugin_manifests.py` — manifest/layout coherence, Host-neutral prose, + and router grammar messages. +- `scripts/link-skills.sh` — development only; links the working tree into every + Host's skill directory. + +### Changed + +- **Layout:** `SKILL.md` and `references/` moved under `skills/sage/`. Codex's + `skills` field points at a *container* of skill directories. `agents/`, `tools/`, + and `hooks/` stay at the plugin root, so `$SAGE_ROOT` is unchanged. +- **Invocation:** Sage is now user-invoked in both harnesses + (`disable-model-invocation: true`, `allow_implicit_invocation: false`). It + side-effects on invocation, so no Host should fire it implicitly. There is no + longer a conversational path back into a session — type the command. +- **The coach no longer parses the invocation.** The learner's request is passed to + `session_router.py` verbatim; `parse_invocation` remains the only parser on every + Host. Router grammar messages dropped their `/sage` prefix — on a Host without + slash commands they were naming a command that does not exist. +- **Delegation is prose.** All 14 `Task(subagent_type=…)` call sites now name the + Clerk and lead with its spec pointer. No Host API appears in the prompt layer. +- **Hooks:** `checkpoint-guard` moved to `SubagentStart`, `reset-verification` to + `SubagentStop`. Both identify a Clerk by registered type *or* by the spec pointer + in the prompt, since Sage registers no Codex agents. All scripts now fail open and + carry a 5s timeout. +- **The one blocking invariant moved into a tool.** `enforce-cross-refs.sh` is the + only hook that blocks; its check now lives in `tools/cross_refs_check.py` and runs + from `session_wrapup.py` too, so it holds on Hosts with no hooks. The hook is the + automatic trigger on Claude and Codex. +- Every bootstrap line prefers an exported root: + `SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}"`. +- Hook state files renamed `/tmp/claude-*` → `/tmp/sage-*`. +- `ref-subagents.md` gained the three operations it always omitted — + `coach-reflect`, `patch-metrics`, `verify-demo` — all of which the prompt layer + already called. + +### Fixed + +- **Duration could be fabricated off-Claude.** With no `CLAUDE_CODE_SESSION_ID`, + `session_duration.py` fell back to "newest transcript under the cwd" and returned + an unrelated session's wall time with exit 0, straight into the journal. + `session_wrapup.py` now skips the call entirely when no session id identifies a + transcript, and the Clerk asks the learner instead (the degradation ADR 0004 built). +- `tests/test_enforce_cross_refs.py` depended on `/tmp/.sage-plugin-root` existing, + so it only passed on a machine with Sage installed. +- `enforce-cross-refs.sh` cited a `CLAUDE.md` Cross-Reference Protocol that does not + exist in this repo. +- The session-metrics removal plan read `Status: not started` long after the code + shipped. + +### Notes + +- `verification-gate`'s `audit` operation is documented as **not reachable** from the + current grammar — nothing calls it, and adding a verb is an ADR 0002 decision. +- Known issue, accepted: `/tmp/.sage-plugin-root` is one global slot shared by every + Host. See `docs/KNOWN-ISSUES.md` for the escape hatch. + ## [1.1.0] - 2026-07-29 Over-engineering audit: ~1,900 lines removed from `tools/`, no feature lost. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..1e2f136 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,132 @@ +# Sage + +A plugin that turns an agentic coding tool into an evidence-based tutor, teaching through questioning rather than lecturing. Ships to Claude Code and Codex CLI; see **Host** below. + +## Language + +### Learning Structure + +**Learner**: +The person using Sage to build durable mastery of a topic through coached sessions. +_Avoid_: student, user + +**Learning Root**: +The user-configured directory where all learning topic directories live. +_Avoid_: project root, sage directory, base path + +**Topic**: +A single subject the learner is studying (e.g., "react-hooks", "statistics"). The *subject* — distinct from the **Project** directory that realizes it on disk. +_Avoid_: course, module, subject + +**Project**: +The on-disk container for a **Topic** — the `/` directory under the learning root that holds `learning/`, any `capstone/`, and is keyed by the topic's **Cross-Refs** shard. Exactly one Project per Topic. "Project" is the right word when the referent is the directory/unit (discovery, archival, the cross-ref registry); "Topic" is the right word when the referent is the subject. The code has always used "project" for this; it is now a defined term, not a loose synonym for Topic. +_Avoid_: folder (when the container-with-artifacts meaning is intended), topic (when the directory, not the subject, is meant) + +**Archive** (verb): +To retire a **Project** by moving its directory to `/.archive//` (numeric-suffixed on collision), co-locating its cross-ref shard there, and scrubbing every reference to it from the cross-refs `INDEX.md`. An **Archived Project** no longer appears in the `learn` picker. Archival is **one-way by design**: there is no `unarchive` command and none is planned. Nothing is deleted — the artifacts stay readable under `.archive/` for reference, and an `archive-meta.json` stash preserves the removed INDEX fragments so a human can restore by hand — but returning to a topic means starting a fresh **Project**, not reactivating the old one. Archiving is a deliberate, confirmed choice to give up the **Knowledge Map**, **Card**, and SRS state, keeping only the artifacts as a record. +_Avoid_: delete, remove, retire (as the on-disk operation); unarchive, restore (no such operation exists) + +**Artifact**: +A structured file within a topic's `learning/` directory that tracks learning state. Includes plan, journal, knowledge map, cards, weak spots, and coach errors. +_Avoid_: file, document, output + +**Session**: +A single learning interaction between the coach and the learner. Produces journal entries, card updates, and a savepoint. **One Session is exactly one Sitting** — a Session is assumed to be an unbroken stretch of work, so a break long enough to end the **Sitting** ends the Session. Distinct from the *Claude Code session*, the editor's process-level unit (`CLAUDE_CODE_SESSION_ID`, one transcript file), which survives compact/resume and so can span several Sessions. +_Avoid_: conversation, chat; bare "session" when the Claude Code session is meant — always qualify it + +**Sitting**: +An unbroken stretch of activity in a Claude Code transcript, bounded by a quiet gap longer than 30 minutes. A **Session**'s recorded `Duration` is the wall time of its Sitting. Load-bearing in code well before it was a defined term (`SITTING_GAP_SECONDS`, `current_sitting()` in `tools/session_duration.py`). Because one Session is one Sitting, the *last* Sitting in a transcript is by definition the current Session's — which is why duration is measured from the last long gap rather than from the top of the file. +_Avoid_: session (the transcript-level unit), block, stretch, sprint + +**Savepoint**: +A snapshot of where a session ended, enabling seamless resume. Stored in the journal entry. +_Avoid_: checkpoint, bookmark + +**Host**: +The agentic coding tool Sage runs inside — Claude Code, Codex CLI, Gemini CLI, Cursor. A Host supplies the skill entry point, and may or may not supply lifecycle hooks and a subagent facility. Sage's protocol and engine are Host-neutral; only the manifests and the hook wiring are per-Host. Distinct from the *Claude Code session*, which is one Host's process-level unit. +_Avoid_: agent (means a **Clerk** in Sage's vocabulary), harness, editor, platform + +**Clerk**: +One of the six operational subagents (`agents/*.md`) the coach delegates to — artifact-clerk, assessment-agent, verification-gate, reference-clerk, demo-generator, capstone-architect. Clerks exist for context isolation and make no pedagogical decisions. A Host without a subagent facility runs their operations inline, at the cost of context, not correctness. +_Avoid_: agent (unqualified — ambiguous with **Host**), subagent (when the Sage-defined role is meant), helper + +### Mastery Tracking + +**Knowledge Map**: +A table tracking every concept within a topic, its mastery status, and when it was last tested. +_Avoid_: progress tracker, skill tree + +**Card**: +A flashcard with a question and answer, scheduled for spaced review by the SRS engine. +_Avoid_: flashcard, quiz item + +**Weak Spot**: +A specific misconception or knowledge gap identified during a session, tracked for targeted drilling. +_Avoid_: error, mistake, gap + +**Coach Error**: +A mistake made by the coach (wrong fact, incorrect grading), distinct from learner weak spots. Tracked separately in `coach-errors.md`. +_Avoid_: bug, mistake + +### Cross-Topic + +**Cross-Refs**: +A registry of concept overlaps between topics, stored in `cross-refs/` at the learning root. Updated when knowledge maps change. +_Avoid_: cross-references, links, connections + +### Versioning & Release + +**Plugin Version**: +The version of the plugin, as declared in `.claude-plugin/plugin.json` — the single source of truth. The copy in `marketplace.json` is a **mirror** that must always be equal; a disagreement is a defect in the mirror, never in `plugin.json`. +_Avoid_: treating the marketplace copy as independently meaningful + +**Shipping Change**: +A change to anything a user actually installs and runs: the skill definition, tools, agents, hooks, references, or plugin manifests. Every Shipping Change bumps the Plugin Version in the same change set; changes confined to repo docs, tests, or CI must not. +_Avoid_: release (the merge event), change (unqualified) + +**Release**: +The landing of a Shipping Change on the sage repo's main branch. There is no separate release pipeline — merging to main *is* publishing, because installs track the repository directly. Every Release is identified by its Plugin Version (tag `v`) and described by a Changelog entry. +_Avoid_: deploy, publish (as a distinct later step — no such step exists) + +**Changelog**: +The user-facing record of Releases (`CHANGELOG.md` in the sage repo), one entry per Plugin Version. The website reflects it; the file is the source of truth. +_Avoid_: release notes (no separate artifact exists) + +**Compatibility Surface**: +The two things a Breaking Change can break: **invocation** (how the learner invokes and resumes the skill) and **Artifacts** (which outlive upgrades — a new version must read artifacts written by any earlier 1.x version, or ship a migration). +_Avoid_: API (nothing here is an API in the conventional sense) + +**Breaking Change**: +A change that alters the Compatibility Surface; requires a major version bump. Changes to Internal Tools are never Breaking Changes on their own, even when observable behavior changes. +_Avoid_: breaking (for internal-tool behavior changes) + +**Internal Tool**: +A CLI tool or agent only the coach invokes — never the learner directly. Internal Tools upgrade in lockstep with the skill and sit outside the Compatibility Surface. (Precedent: `session_duration`'s exit-code change was a patch.) +_Avoid_: API, public tool + +## Relationships + +- A **Learning Root** contains one or more **Projects** +- A **Topic** is realized on disk as exactly one **Project** (subject ↔ container, 1:1) +- A **Project** contains multiple **Artifacts** in its `learning/` directory +- **Archiving** a **Project** moves its directory under `.archive/` and removes it from `learn` discovery; its **Cross-Refs** shard is co-located and its **INDEX.md** references are scrubbed +- A **Session** produces updates to **Artifacts** and ends with a **Savepoint** +- A **Session** occupies exactly one **Sitting**; one Claude Code session may contain several **Sessions**, each its own **Sitting**, all appended to one transcript +- A **Knowledge Map** tracks **Concepts**, each at a mastery level +- A **Card** belongs to a **Topic** and is scheduled by the SRS engine +- A **Weak Spot** is a learner gap; a **Coach Error** is a coach mistake — they are never mixed +- **Cross-Refs** track overlaps between **Topics** at the **Learning Root** level + +## Example dialogue + +> **Dev:** "When a learner starts a new **Session**, does the coach create a new **Topic**?" +> **Domain expert:** "No — the **Topic** directory is created during the first session's planning phase. On resume, the coach loads the existing **Artifacts** and continues from the **Savepoint**." + +> **Dev:** "Are **Weak Spots** and **Coach Errors** stored in the same file?" +> **Domain expert:** "Never. **Weak Spots** go in `weak-spots.md`, **Coach Errors** go in `coach-errors.md`. The `weak_spot_writer.py` tool enforces this — it refuses to write a CE entry to `weak-spots.md`." + +## Flagged ambiguities + +- "learning root" vs "sage directory" — resolved: **Learning Root** is the canonical term. It's user-configured, not hardcoded. +- "session" (three meanings) — resolved: **Session** is the learning interaction; **Sitting** is the gap-bounded stretch of activity the duration is measured over; the *Claude Code session* is the editor's process-level unit and must always be named in full. Surfaced by the `session_duration.py` cwd-resolution fix, which reads `CLAUDE_CODE_SESSION_ID` and reports a Sitting's wall time as a Session's `Duration` — legitimate only under the **one Session = one Sitting** assumption, which is now stated rather than implied by a constant. +- "topic" vs "project" — resolved: they are *not* synonyms. **Topic** is the subject; **Project** is the on-disk container (1:1). "Project" was undefined-but-load-bearing in the code (`list_projects`, `cross-refs/.md`, INDEX's `| Project |`); it is now a defined term. Use "Project" for the directory/unit, "Topic" for the subject. Surfaced by the `/sage archive` feature, which operates on the container. diff --git a/README.md b/README.md index 172c5eb..886ca62 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ **An AI tutor for serious self-learners.** -Sage runs inside Claude Code and turns study into active recall, Socratic coaching, verified explanations, spaced review, and durable local learning records. +Sage runs inside Claude Code and Codex CLI, and turns study into active recall, Socratic coaching, verified explanations, spaced review, and durable local learning records. > **Beta** — Sage is early. Mostly the happy path has been tested, so straying off it may surface bugs, and features are still changing. Bug reports and feedback are very welcome via [issues](../../issues). @@ -51,19 +51,39 @@ Sage: Good instinct. But you said it's offloaded to "another stack." ## Requirements - Python 3.8+ -- Claude Code +- Claude Code or Codex CLI - No pip packages required (stdlib only) ## Install +**Claude Code** + ```bash /plugin marketplace add 0-BSCode/sage /plugin install sage@sage ``` +**Codex CLI** + +```bash +codex plugin marketplace add 0-BSCode/sage +codex plugin add sage@sage +``` + +Then run `codex`, open `/hooks`, review and trust Sage's four lifecycle hooks, +and start a new thread. Codex pins each hook by hash, so it asks again after an +update that changes one. Until you trust them Sage still teaches — you lose the +verification nudges, not the coaching. See +[docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md). + ## Usage -Every command takes a verb: `/sage learn ` or `/sage archive `. +Every command takes a verb: `learn ` or `archive `. + +On Claude Code these are slash commands — `/sage learn `. In Codex, +invoke the skill explicitly and include the verb: `$sage:sage learn React hooks`. +Everything below is written in the Claude +form; the verb and topic are what matter on either Host. ### First session diff --git a/agents/artifact-clerk.md b/agents/artifact-clerk.md index 2e5b9d8..62d3c99 100644 --- a/agents/artifact-clerk.md +++ b/agents/artifact-clerk.md @@ -1,6 +1,6 @@ --- name: artifact-clerk -description: "Manages Sage learning artifact files. Reads, summarizes, updates, and validates the 6 learning journey artifacts (plan, journal, knowledge-map, cards, weak-spots, coach-errors). Invoked by the /sage skill via Task tool delegation." +description: "Manages Sage learning artifact files. Reads, summarizes, updates, and validates the 6 learning journey artifacts (plan, journal, knowledge-map, cards, weak-spots, coach-errors). Delegated to by the Sage coach." model: haiku color: green --- @@ -11,7 +11,7 @@ You are the Artifact Clerk — a dedicated file management agent for the Sage sy All tool scripts are accessed via the plugin root. Before running any tool command, resolve the path once: ```bash -SAGE_ROOT=$(cat /tmp/.sage-plugin-root) +SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" ``` Then use `$SAGE_ROOT/tools/...` in all subsequent commands within the same bash call. @@ -54,7 +54,7 @@ The `Project:` field is optional. When provided, use it as the canonical project 2. Run SRS engine commands (if `cards.srs.json` exists): ```bash - SAGE_ROOT=$(cat /tmp/.sage-plugin-root) + SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" python3 "$SAGE_ROOT/tools/srs/srs_engine.py" due --json python3 "$SAGE_ROOT/tools/srs/srs_engine.py" stats --json ``` @@ -62,7 +62,7 @@ The `Project:` field is optional. When provided, use it as the canonical project 3. Run the plateau detector (if `cards.srs.json` and `journal/index.md` both exist): ```bash - SAGE_ROOT=$(cat /tmp/.sage-plugin-root) + SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" python3 "$SAGE_ROOT/tools/plateau/plateau_detector.py" \ --journal-dir /journal/ \ --srs /cards.srs.json \ @@ -309,7 +309,7 @@ Metadata block rules: - Do NOT write to `journal/index.md` directly. Use the `journal_writer.py` script which guarantees canonical 8-column format. - Build a JSON object from the session data and pipe it to the script: ```bash - SAGE_ROOT=$(cat /tmp/.sage-plugin-root) + SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" echo '' | python3 "$SAGE_ROOT/tools/srs/journal_writer.py" append --stdin ``` Where `` is: @@ -336,7 +336,7 @@ Metadata block rules: **Adding a NEW concept:** Use `kmap_writer.py add-concept`: ```bash -SAGE_ROOT=$(cat /tmp/.sage-plugin-root) +SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" echo '' | python3 "$SAGE_ROOT/tools/srs/kmap_writer.py" add-concept --stdin ``` Where `` is: @@ -354,7 +354,7 @@ Where `` is: **Updating an EXISTING concept's status:** Use `kmap_writer.py update-status`: ```bash -SAGE_ROOT=$(cat /tmp/.sage-plugin-root) +SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" echo '' | python3 "$SAGE_ROOT/tools/srs/kmap_writer.py" update-status --stdin ``` Where `` is: @@ -375,7 +375,7 @@ Where `` is: - **First session (knowledge-map is being created):** Check `plan.md` for concepts marked "Prior Knowledge (from [project])" in the skill tree. Only use `prior (from [project])` for concepts that are `solid` or `mastered` in the sibling project — this status means "no need to teach this." For concepts that are `developing` or lower in the sibling project, use `developing` with a note like "Also covered in [project]" — the learner still needs work on these. - **Status Changelog:** Do NOT write changelog rows directly. Use the `kmap_writer.py` script: ```bash - SAGE_ROOT=$(cat /tmp/.sage-plugin-root) + SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" echo '' | python3 "$SAGE_ROOT/tools/srs/kmap_writer.py" changelog-append --stdin ``` Where `` is: @@ -397,7 +397,7 @@ Where `` is: - Build a JSON array of card objects from the coach's session notes. If the coach marked a card with `**Remediates:** M`, include `M` in that card's tags list. - Run the script: ```bash - SAGE_ROOT=$(cat /tmp/.sage-plugin-root) + SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" echo '' | python3 "$SAGE_ROOT/tools/srs/card_writer.py" append --stdin ``` Where `` is a JSON array of card objects: @@ -419,7 +419,7 @@ Where `` is: - If no new cards were provided, skip this step. - **Format guard (mandatory):** After writing cards, always run: ```bash - SAGE_ROOT=$(cat /tmp/.sage-plugin-root) + SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" 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. @@ -429,18 +429,18 @@ Where `` is: ### Step 5: Run SRS sync ```bash -SAGE_ROOT=$(cat /tmp/.sage-plugin-root) +SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" python3 "$SAGE_ROOT/tools/srs/srs_engine.py" sync ``` If `cards.srs.json` doesn't exist and new cards were added, run `init` first: ```bash -SAGE_ROOT=$(cat /tmp/.sage-plugin-root) +SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" python3 "$SAGE_ROOT/tools/srs/srs_engine.py" init ``` ### Step 6: Run SRS forecast ```bash -SAGE_ROOT=$(cat /tmp/.sage-plugin-root) +SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" python3 "$SAGE_ROOT/tools/srs/srs_engine.py" forecast --days 14 ``` Use the forecast output to populate the "Spaced reviews due" field in the journal savepoint. If you already wrote the journal entry before getting forecast data, go back and update the savepoint section with the forecast dates. @@ -482,28 +482,28 @@ Do NOT write entries directly. Use the `weak_spot_writer.py` script with the app For a learner weak spot: ```bash -SAGE_ROOT=$(cat /tmp/.sage-plugin-root) +SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" echo '' | python3 "$SAGE_ROOT/tools/srs/weak_spot_writer.py" append --kind WS --stdin ``` For a wrong-model shorthand (auto-sets Category: wrong-model): ```bash -SAGE_ROOT=$(cat /tmp/.sage-plugin-root) +SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" echo '' | python3 "$SAGE_ROOT/tools/srs/weak_spot_writer.py" append --kind M --stdin ``` For a coach content error: ```bash -SAGE_ROOT=$(cat /tmp/.sage-plugin-root) +SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" echo '' | python3 "$SAGE_ROOT/tools/srs/weak_spot_writer.py" append --kind CE --stdin ``` For a coach process failure: ```bash -SAGE_ROOT=$(cat /tmp/.sage-plugin-root) +SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" echo '' | python3 "$SAGE_ROOT/tools/srs/weak_spot_writer.py" append --kind CP --stdin ``` @@ -667,7 +667,7 @@ Path: /learning/ 1. Run the reflection tool: ```bash - SAGE_ROOT=$(cat /tmp/.sage-plugin-root) + SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" python3 "$SAGE_ROOT/tools/coach/coach_reflector.py" reflect ``` 2. Parse the JSON output — each candidate has: pattern, source_entries, proposed_rule, confidence, error_count diff --git a/agents/assessment-agent.md b/agents/assessment-agent.md index 9283f78..6fdecaa 100644 --- a/agents/assessment-agent.md +++ b/agents/assessment-agent.md @@ -1,6 +1,6 @@ --- name: assessment-agent -description: "Generates calibrated assessment questions and manages the question bank for the Sage system. Invoked by the /sage skill via Task tool delegation." +description: "Generates calibrated assessment questions and manages the question bank for the Sage system. Delegated to by the Sage coach." model: sonnet color: orange --- @@ -11,7 +11,7 @@ You are the Assessment Agent — a specialized question generation and evaluatio All tool scripts are accessed via the plugin root. Before running any tool command, resolve the path once: ```bash -SAGE_ROOT=$(cat /tmp/.sage-plugin-root) +SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" ``` Then use `$SAGE_ROOT/tools/...` in all subsequent commands within the same bash call. @@ -23,7 +23,7 @@ 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) +SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" python3 "$SAGE_ROOT/tools/assessment/assessment_engine.py" init ``` `init` reads the topic's `knowledge-map.md` and seeds one coverage entry per @@ -72,7 +72,7 @@ Existing questions for this concept: 3. **Verify factual correctness** of your expected answer. For technical topics, look up official docs or run code to confirm. Do not guess. 4. Persist the question to the bank: ```bash - SAGE_ROOT=$(cat /tmp/.sage-plugin-root) + SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" python3 "$SAGE_ROOT/tools/assessment/assessment_engine.py" add \ --concept "" --difficulty --type \ --text "" --answer "" \ @@ -119,7 +119,7 @@ Existing questions: - All expected answers are factually verified 2. Persist all questions at once: ```bash - SAGE_ROOT=$(cat /tmp/.sage-plugin-root) + SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" echo '' | python3 "$SAGE_ROOT/tools/assessment/assessment_engine.py" add-batch --json ``` 3. Return the full list of generated questions. @@ -157,12 +157,12 @@ Interleave: [true|false, default false — when true, no two adjacent questions 1. Run the adaptive selection algorithm: ```bash - SAGE_ROOT=$(cat /tmp/.sage-plugin-root) + SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" python3 "$SAGE_ROOT/tools/assessment/assessment_engine.py" select --count --json ``` Or with concept filter: ```bash - SAGE_ROOT=$(cat /tmp/.sage-plugin-root) + SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" python3 "$SAGE_ROOT/tools/assessment/assessment_engine.py" select --concept "" --count --json ``` If `Min mastery` is provided, add `--min-mastery ` to the command. @@ -233,7 +233,7 @@ Session: [session number] 3. Record the result: ```bash - SAGE_ROOT=$(cat /tmp/.sage-plugin-root) + SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" python3 "$SAGE_ROOT/tools/assessment/assessment_engine.py" record \ --session --quality --notes "" ``` diff --git a/agents/capstone-architect.md b/agents/capstone-architect.md index 8294334..b2d3c3f 100644 --- a/agents/capstone-architect.md +++ b/agents/capstone-architect.md @@ -1,6 +1,6 @@ --- name: capstone-architect -description: "Analyzes learner mastery profiles and proposes portfolio-worthy capstone projects tailored to a configurable audience. Invoked by the /sage skill via Task tool delegation." +description: "Analyzes learner mastery profiles and proposes portfolio-worthy capstone projects tailored to a configurable audience. Delegated to by the Sage coach." model: sonnet color: magenta --- @@ -148,8 +148,20 @@ Produce 3-5 candidate projects. For each candidate: Before returning, batch all factual claims from your research through the verification gate: +Delegate to `verification-gate` with: + ``` -Task(subagent_type="verification-gate", prompt="Operation: verify-claims\nTopic: [topic]\n\nClaims:\n1. [job market claim — e.g., 'Senior backend roles commonly require experience with message queues']\n2. [ecosystem claim — e.g., 'There is no widely-adopted OSS tool for X in the Y ecosystem']\n3. [technology claim — e.g., 'Library X supports feature Y as of version Z']\n...") +Read $SAGE_ROOT/agents/verification-gate.md in full and follow it exactly — that +file is your complete specification. Do not act before reading it. + +Operation: verify-claims +Topic: [topic] + +Claims: +1. [job market claim — e.g., 'Senior backend roles commonly require experience with message queues'] +2. [ecosystem claim — e.g., 'There is no widely-adopted OSS tool for X in the Y ecosystem'] +3. [technology claim — e.g., 'Library X supports feature Y as of version Z'] +... ``` Apply corrections. Mark unverified claims with caveats: "I haven't been able to verify this — check [source] to confirm." diff --git a/agents/demo-generator.md b/agents/demo-generator.md index 9eff790..904ffdd 100644 --- a/agents/demo-generator.md +++ b/agents/demo-generator.md @@ -1,6 +1,6 @@ --- name: demo-generator -description: "Generates targeted interactive HTML demos to correct persistent misconceptions that text-based interventions have failed to resolve. Invoked by the /sage skill via Task tool delegation." +description: "Generates targeted interactive HTML demos to correct persistent misconceptions that text-based interventions have failed to resolve. Delegated to by the Sage coach." model: sonnet color: magenta --- @@ -158,7 +158,7 @@ Do NOT write to `docs/demos/index.md` directly. Use the `demo_index_writer.py` s Build a JSON object from the demo metadata and pipe it to the script: ```bash -SAGE_ROOT=$(cat /tmp/.sage-plugin-root) +SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" echo '' | python3 "$SAGE_ROOT/tools/demo/demo_index_writer.py" append /docs/demos/ --stdin ``` Where `` is: @@ -222,18 +222,22 @@ Updates: The coach mediates all invocations (same pattern as reference-clerk): +The coach delegates to `demo-generator` with: + ``` -Task(subagent_type="demo-generator", - prompt="Operation: generate - Path: / - Concept: - Context: - - Weak spot: WS-31 — one-sided vs two-sided z-value confusion - Collision point: confuses z_a (one-sided) with z_{a/2} (two-sided) - Learner's wrong model: thinks one-sided a=0.05 uses z_{0.025} = 1.96 - Correct model: one-sided a=0.05 uses z_a = z_{0.05} = 1.645; two-sided uses z_{a/2} = z_{0.025} = 1.96 - What's been tried: mnemonics, warm-ups, consolidation drilling across sessions 14, 15, 24, 27") +Read $SAGE_ROOT/agents/demo-generator.md in full and follow it exactly — that +file is your complete specification. Do not act before reading it. + +Operation: generate +Path: / +Concept: +Context: + +Weak spot: WS-31 — one-sided vs two-sided z-value confusion +Collision point: confuses z_a (one-sided) with z_{a/2} (two-sided) +Learner's wrong model: thinks one-sided a=0.05 uses z_{0.025} = 1.96 +Correct model: one-sided a=0.05 uses z_a = z_{0.05} = 1.645; two-sided uses z_{a/2} = z_{0.025} = 1.96 +What's been tried: mnemonics, warm-ups, consolidation drilling across sessions 14, 15, 24, 27 ``` The coach invokes demo generation when: diff --git a/agents/reference-clerk.md b/agents/reference-clerk.md index 58d197a..d4573b0 100644 --- a/agents/reference-clerk.md +++ b/agents/reference-clerk.md @@ -1,6 +1,6 @@ --- name: reference-clerk -description: "Generates, updates, and validates standardized reference documents for the Sage system. Produces verified, template-compliant deep-dive explanations of concepts. Invoked by the /sage skill via Task tool delegation." +description: "Generates, updates, and validates standardized reference documents for the Sage system. Produces verified, template-compliant deep-dive explanations of concepts. Delegated to by the Sage coach." model: sonnet color: cyan --- @@ -287,7 +287,18 @@ A standalone `/reference` command would work mechanically, but it loses critical ### Coach delegation examples ``` -Task(subagent_type="reference-clerk", prompt="Operation: generate\nPath: scaling-reads/learning/\nConcept: Cache-Aside Pattern\nContext: Learner has mastered implementation but no reference doc exists for review.\n\nSource material:\n- Cache-aside is application-managed: check cache → miss → query DB → populate cache\n- Key distinction from read-through: application owns the logic, cache is passive\n- Critical implementation details: JSON serialization, atomic TTL setting, key naming") +Read $SAGE_ROOT/agents/reference-clerk.md in full and follow it exactly — that +file is your complete specification. Do not act before reading it. + +Operation: generate +Path: scaling-reads/learning/ +Concept: Cache-Aside Pattern +Context: Learner has mastered implementation but no reference doc exists for review. + +Source material: +- Cache-aside is application-managed: check cache → miss → query DB → populate cache +- Key distinction from read-through: application owns the logic, cache is passive +- Critical implementation details: JSON serialization, atomic TTL setting, key naming ``` ### Coach-initiated (no learner request) @@ -311,19 +322,29 @@ The Sage skill includes the following in its "Tools Available to You" section, a deep-dive document - An audit reveals coverage gaps (concepts in the knowledge map without reference docs) - Delegation format: + Delegation format — delegate to `reference-clerk` with: ``` - Task(subagent_type="reference-clerk", prompt="Operation: generate\nPath: /\nConcept: \nContext: \n\nSource material:\n") + Read $SAGE_ROOT/agents/reference-clerk.md in full and follow it exactly — that + file is your complete specification. Do not act before reading it. + + Operation: generate + Path: / + Concept: + Context: + + Source material: + ``` After the clerk returns, tell the learner what was generated and where the file lives. - You can also run an audit to find coverage gaps: + You can also run an audit to find coverage gaps — same spec pointer, then: ``` - Task(subagent_type="reference-clerk", prompt="Operation: audit\nPath: /") + Operation: audit + Path: / ``` ``` diff --git a/agents/verification-gate.md b/agents/verification-gate.md index 77c312e..268255b 100644 --- a/agents/verification-gate.md +++ b/agents/verification-gate.md @@ -1,6 +1,6 @@ --- name: verification-gate -description: "Independently verifies factual claims, code examples, and flashcard answers before they reach the learner. Enforces evidence-backed accuracy as a quality gate for the Sage system. Invoked by the /sage skill via Task tool delegation." +description: "Independently verifies factual claims, code examples, and flashcard answers before they reach the learner. Enforces evidence-backed accuracy as a quality gate for the Sage system. Delegated to by the Sage coach." model: sonnet color: yellow --- @@ -255,6 +255,12 @@ Demo file: [path to the HTML file] ## Operation: `audit` +> **Not reachable from the current grammar.** Nothing in `SKILL.md` or +> `references/` calls this operation, and it is deliberately absent from +> `ref-subagents.md`'s table. Reaching it would need a third command verb, which +> `docs/adr/0002-mandatory-command-verbs.md` locked at two (`learn`, `archive`). The +> spec is kept intact pending that decision. Do not treat it as public API. + **Purpose:** Retroactively verify the factual accuracy of existing learning artifacts — session journals, flashcards, and the knowledge map. Used when a learner wants to check whether past sessions taught correct information. **Input format:** diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md new file mode 100644 index 0000000..ff63e01 --- /dev/null +++ b/docs/KNOWN-ISSUES.md @@ -0,0 +1,144 @@ +# Known Issues + +Tracked defects in the Sage plugin. Newest / most severe first. + +--- + +## ✅ RESOLVED BY REMOVAL — Session token-metrics collector aggregated across sessions & projects + +> **Resolved 2026-07-20.** The token-metrics system was **deleted rather than fixed**, per +> `context/done/REMOVE-SESSION-METRICS-PLAN.md`. None of the components below still exist: +> `tools/session_metrics.py`, `hooks/scripts/track-subagent.sh`, and +> `tests/test_session_metrics.py` are gone, and no `SubagentStop` hook is registered in +> `hooks/hooks.json`. The journal now records **Duration only**, derived from the Claude Code +> transcript by `tools/session_duration.py`. +> +> The diagnosis below is retained as history — it is the reasoning that justified removal, and +> the log-topology table still describes files that exist on disk. **None of the remediation +> options (A/B/C) were taken.** +> +> The removal plan sat marked "not started" long after it had shipped, and this entry stayed +> marked URGENT/OPEN for components that no longer existed. That staleness cost real work: it +> led the `session-duration-cwd-resolution` investigation to plan a shared fix with a sibling +> issue that had already been deleted. + +- **Status:** RESOLVED by removal 2026-07-20 (originally: OPEN, fix deferred 2026-07-01; remediation of affected data done) +- **Severity:** was high — corrupted per-session cost/trend data that the resume brief and coach metrics relied on +- **Discovered:** Session 35, 2026-07-01 (learner noticed stale timestamps in a journal; then flagged a second affected journal) +- **Components (all since deleted):** `tools/session_metrics.py`, `hooks/scripts/track-subagent.sh`, `tools/session_wrapup.py` +- **Related tests (deleted):** `tests/test_session_metrics.py`, `tests/test_track_subagent.py` + +### Symptom +The `### Token Metrics` block that `patch-metrics` appends to a session journal contains subagent-token log entries from **multiple prior sessions and other projects**, not just the current session. Example (auth-and-authz S35 journal): reported ~3.96M fresh tokens and 15 `artifact-clerk` invocations plus six agent types (`Explore`, `verification-gate`, `assessment-agent`, `doc-researcher`, `tldr-clerk`, `learning-git`) that were **never invoked that session**. The entries spanned 2026-06-26 → 2026-07-01 (sessions 32–35). Duration and context% were correct (they come from the main session metrics JSON, not the log). + +### Root cause (two compounding defects) +1. **Consumer over-collects when unscoped.** `tools/session_metrics.py::parse_subagent_log(path, session_id="")` only filters by session when `session_id` is truthy: + ```python + if session_id and entry.get("session_id") != session_id: + continue + ``` + The end-of-session checklist invokes `session_wrapup.py` **without** `--session-id`, so `session_id=""` → the filter is skipped → the entire rolling, global `subagent-tokens.jsonl` (all sessions, all projects) is summed. + +2. **Producer/consumer session-id spaces don't match.** `hooks/scripts/track-subagent.sh` (line 14) stamps each log line with the SubagentStop payload's `.session_id`. Empirically this id does **not** equal the main session id in `claude-session-metrics.json`. On 2026-07-01 the main session was `dc26b906-…`, but that session's own log lines were tagged with a different UUID — **0 matches**. So even if the consumer *were* passed the main session id, filtering would return **zero** entries (worse than over-collecting). This is why passing `--session-id` was effectively abandoned, leaving the unscoped fallback. + +The log is **global across all projects** (`$HOME/.claude/logs/subagent-tokens.jsonl` when no learning root, or `/logs/`), so any concurrent/same-day project session also bleeds in. + +### Log topology — why there are THREE divergent log files (discovered S35) +There is no single logger. **Two independent SubagentStop hooks fire on every subagent stop**, with different routing, and the plugin hook's routing changed mid-project. Result — three `subagent-tokens.jsonl` files: + +| File | Written by | Span (observed) | Notes | +|---|---|---|---| +| `/logs/` e.g. `…/ultralearn/logs/` | **Plugin hook** (current), routes to `$SAGE_LEARNING_ROOT/logs` where `$SAGE_LEARNING_ROOT`=`/tmp/.sage-learning-root` = the **ultralearn root** | 06-26 → present | **This is what `session_metrics.py` reads** (its `find_subagent_log` resolves the same `/tmp/.sage-learning-root`). Cross-**project** bucket shared by every topic under ultralearn. | +| `/learning/logs/` e.g. `…/auth-and-authz/learning/logs/` | **Jarvis hook** `~/.claude/tools/jarvis/hooks/track-subagent.sh` (routes to `$CLAUDE_PROJECT_DIR/learning/logs`); also the plugin hook *before* commit 46f1742 | 05-04 → present | Project-scoped, still actively written by the jarvis hook. | +| `~/.claude/logs/` | Both hooks' fallback when no learning dir / no learning root | 04-13 → 06-30 | Original global default; used for non-Sage projects. | + +Contributing factors: +1. **Duplicate hooks.** `~/.claude/settings.json` registers a personal `jarvis` SubagentStop hook AND the plugin's `hooks/hooks.json` registers the plugin hook. Both run every SubagentStop → the same event is logged to two different files (both show identical last-write timestamps). (Same duplication pattern seen with `enforce-cross-refs.sh`.) +2. **Routing drift.** Plugin commit `46f1742` ("Fix token tracking", ~06-26) changed the plugin hook from `$CLAUDE_PROJECT_DIR/learning/logs` → `$SAGE_LEARNING_ROOT/logs` (learning root). The jarvis hook was never updated, so producer paths diverged. +3. **Cross-project bucket, but session filtering resolves it.** `$SAGE_LEARNING_ROOT` is the **ultralearn root**, not the project, so the plugin log (the one the metrics tool reads) is shared across all topics. However, because a Sage **session maps 1:1 to a project** (one topic per session), correct **session-id filtering (Option B) isolates the project as a byproduct** — the surviving entries are all from the current session, hence all from one project. No separate per-project path scoping is required. **Sole exception (not a practical concern here):** running multiple topics inside a *single* Claude Code conversation (same session id, no `/clear`) would defeat session filtering — but the maintainer confirmed (S35) they always `/clear` or start a fresh session per topic, so each session id maps to exactly one project. Under that workflow Option B fully resolves cross-project pollution with no residual edge case. Option A (time-window) does NOT get this for free: a same-day session in another topic falls inside the time window regardless. Separately, the duplicate-hook + jarvis-copy situation should still be reconciled (double-writes; the jarvis hook lives outside this repo). +4. **Stale install risk.** The running hook is the plugin **cache** copy at `~/.claude/plugins/cache/sage/sage/1.0.0/hooks/scripts/track-subagent.sh` (currently identical to the dev repo). Edits to the dev repo won't take effect until the plugin is reinstalled/synced. + +### Impact +- Per-session token totals in journals are inflated (multi-session cumulative), mislabeled as one session. +- The resume brief reads journals for cost/trend signals; inflated totals can corrupt "tokens/time per session increasing" analysis and could trip false plateau/efficiency flags. +- Only journals with a Token Metrics section are affected. That feature began at **S33**, so historically S33 and S34 were polluted (see remediation). + +### Remediation options (not yet applied — decision deferred) +- **A. Time-window filter in `session_metrics.py` (~6 lines, 1 file).** Derive session start ≈ `now − duration_ms − margin` from the main metrics JSON; drop older log lines. Fixes cross-*day* pollution; ships now; self-contained. Limitation: same-*day* cross-project entries still bleed (log is global). Proposed diff: + ```diff + + from datetime import datetime, timedelta + - def parse_subagent_log(path, session_id=""): + + def parse_subagent_log(path, session_id="", since=None): + ... + if session_id and entry.get("session_id") != session_id: + continue + + if since is not None: + + try: + + if datetime.fromisoformat(entry.get("timestamp", "")) < since: + + continue + + except ValueError: + + pass # unparseable timestamp -> keep (fail-open) + ``` + and in `run()`: + ```diff + + since = None + + if duration_ms and duration_ms > 0: + + since = datetime.now().astimezone() - timedelta(milliseconds=duration_ms) - timedelta(minutes=10) + ... + - entries = parse_subagent_log(sub_log, session_id) + + entries = parse_subagent_log(sub_log, session_id, since=since) + ``` +- **B. Unify the session-id source (the exact fix, ~3 files).** Have the SessionStart hook write `/tmp/.sage-session-id` (the main id it already knows), make `track-subagent.sh` stamp *that* instead of the payload `.session_id`, and make `session_metrics.py` filter by it. Both sides share one id → exact per-session isolation, no same-day bleed. Larger surface; only helps sessions after the fix. +- **C. Ship A now, file B as a follow-up issue.** (Maintainer's leaning at time of writing, but deferred.) + +Whichever is chosen: do it on a branch and **update `tests/test_session_metrics.py` and `tests/test_track_subagent.py`** (existing coverage — a patch without test updates is a regression risk). Note: no fix can retroactively repair the global log's existing entries; it only cleans data going forward. + +### Data remediation already applied (auth-and-authz project, 2026-07-01) +- **S35 journal:** trimmed to that session's entries (were cleanly separable — all same-day entries were this session's). Real numbers retained: 405,336 fresh, duration 1h38m48s. +- **S33 & S34 journals:** metrics block replaced with an honest stub (per-session totals judged unreconstructable — global log's session_ids don't map, same-day cross-project entries can't be separated). Session-scoped Duration/Context retained. +- Logged in project artifacts as **CP-9** (coach-errors) and **CI-7** (coach-insights: "verify metrics-file date scope before approving patch-metrics"). + +### Reproduce +```bash +SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" +python3 "$SAGE_ROOT/tools/session_wrapup.py" "$SAGE_ROOT" "" "" +# inspect /tmp/session-metrics-.txt — timestamps will span multiple days/sessions +``` + +--- + +## `/tmp/.sage-plugin-root` is a single global slot shared by every Host + +**Status:** known, accepted. Escape hatch shipped in v1.2.0. + +`${CLAUDE_PLUGIN_ROOT}` is a hook-config substitution and is **not** present in the +Bash tool's environment, so `/tmp/.sage-plugin-root` is the only bridge from hook +space into the prose bash blocks. Every Host's `SessionStart` writes that same path. + +Run Sage on two Hosts on one machine and the last session started wins: + +- Claude Code writes its plugin root (e.g. a local dev checkout) +- Codex writes its version-pinned cache path (`~/.codex/plugins/cache/sage/sage//`) + +Both are valid Sage trees, so nothing crashes. It bites as **silent version skew**: +the protocol you are reading comes from one tree while the tools you are running come +from another. Most likely during development, when a working tree and a released +install are both active. + +**Escape hatch:** every bootstrap line now prefers an exported variable — + +```bash +SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" +``` + +Export `SAGE_ROOT` per shell (or per Host profile) and the shared file stops mattering. +`scripts/link-skills.sh` points both Hosts at the same working tree, which removes the +ambiguity for the development case entirely. + +**Not fixed** because session-scoping the filename needs a session id available inside +prose bash. Claude Code exports `CLAUDE_CODE_SESSION_ID`; no equivalent is confirmed on +Codex. That is a mechanism to build and validate for a failure mode requiring two Hosts, +two versions, and interleaved sessions. + +`/tmp/.sage-learning-root` has the same structure but is benign — the Learning Root is +Host-independent. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 1892090..5ab4b24 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -7,15 +7,15 @@ change has a version identity and a stated reason. ## The rules 1. **`plugin.json` is authoritative.** The `version` in - `.claude-plugin/plugin.json` is the single source of truth. The copy in - `.claude-plugin/marketplace.json` is a mirror and must always be equal. - CI fails the build if they diverge. + `.claude-plugin/plugin.json` is the single source of truth. The copies in + `.claude-plugin/marketplace.json` and `.codex-plugin/plugin.json` are + mirrors and must always be equal. CI fails the build if they diverge. 2. **Every shipping change bumps the version.** A *shipping change* is any - change under `SKILL.md`, `agents/`, `hooks/`, `references/`, `tools/`, or - `.claude-plugin/` — the things a user actually installs and runs. Bump the - version in the same PR. Changes confined to repo docs, tests, or CI must - *not* bump the version. CI enforces both directions of this on PRs. + change under `skills/`, `agents/`, `hooks/`, `tools/`, `.claude-plugin/`, + or `.codex-plugin/` — the things a user actually installs and runs. Bump + the version in the same PR. Changes confined to repo docs, tests, or CI + must *not* bump the version. CI enforces both directions of this on PRs. 3. **Every version bump gets a changelog entry.** Add a section to `CHANGELOG.md` in the same PR. CI fails a PR that bumps the version diff --git a/docs/adr/0001-configurable-learning-root.md b/docs/adr/0001-configurable-learning-root.md new file mode 100644 index 0000000..c2c38a6 --- /dev/null +++ b/docs/adr/0001-configurable-learning-root.md @@ -0,0 +1,15 @@ +# Configurable learning root via config file + +The plugin needs to know where learning topic directories live (the "learning root"). This path was previously hardcoded to the author's machine, breaking the plugin for all other users. + +We chose a config file at `~/.config/sage/config.json` with env var override, over three alternatives: + +- **Hardcoded path** (status quo) — only works on one machine. +- **Env var only** — invisible to new users who won't know to set it. +- **Auto-detect from CWD** — unreliable since users may run sessions from different directories. + +The config file is created on first run via a prompt in the skill. Resolution order: `SAGE_LEARNING_ROOT` env var > config file > first-run prompt. The env var override follows standard Unix convention and supports temporary overrides without editing config. + +The config uses a `version` field (`{"learning_root": "...", "version": 1}`) so the format can be migrated later as the plugin grows. + +See: https://github.com/0-BSCode/ultralearn/issues/1 diff --git a/docs/adr/0002-mandatory-command-verbs.md b/docs/adr/0002-mandatory-command-verbs.md new file mode 100644 index 0000000..042b3a6 --- /dev/null +++ b/docs/adr/0002-mandatory-command-verbs.md @@ -0,0 +1,15 @@ +# Mandatory command verbs for the `/sage` entry point + +Adding an archive capability required a way to tell "start a learning session" apart from "archive a topic" at the single `/sage` entry point. We chose to make an explicit leading verb **mandatory** — the grammar is now `/sage ` with exactly two verbs, `learn` and `archive` — accepting a breaking change to the previous free-form `/sage ` grammar. + +Previously the router inferred intent: any argument was treated as a topic to learn, except a small set of bare resume keywords (`continue`, `resume`, `pick`, `list`). Bolting `archive` onto that scheme reintroduces an ambiguity — a topic literally named "archive" is indistinguishable from the archive verb. We considered three options: + +- **Argument dispatch with `learn` as an implicit default** (backward-compatible) — `/sage react hooks` still learns, `/sage archive x` archives. Rejected: it only shrinks the ambiguity rather than removing it. A topic named after a reserved verb still can't be expressed without an escape hatch. +- **A separate skill/command** (`/sage-archive`) — rejected: adds a second plugin surface and duplicates the config + project-discovery boilerplate that already funnels through `session_router.py`. +- **Mandatory verb** (chosen) — `/sage learn ` and `/sage archive `. A topic named "archive" is unambiguous because `learn` is always present: `/sage learn archive`. + +Consequences: + +- **Breaking change.** Every prior invocation form (`/sage react hooks`, `/sage continue`) is now invalid. The router emits an "unknown verb" error that maps the old forms to the new ones (`/sage continue` → "Did you mean `/sage learn`?"). `argument-hint`, the README, and SKILL.md examples all change. +- The old resume keywords (`continue`/`resume`/`pick`/`list`) are **dropped**, not aliased. `learn` subsumes them: `learn ` starts-or-resumes (the router already branches on journal existence), and bare `learn` opens the project picker. Keeping aliases would make the grammar half-mandatory and undercut the collision guarantee. +- `learn` and `archive` share the router's config resolution and `list_projects` discovery, so the new surface adds one dispatch branch rather than a parallel entry point. diff --git a/docs/adr/0003-archive-by-move-recoverable.md b/docs/adr/0003-archive-by-move-recoverable.md new file mode 100644 index 0000000..64b5986 --- /dev/null +++ b/docs/adr/0003-archive-by-move-recoverable.md @@ -0,0 +1,38 @@ +# Archive a Project by moving it, one-way-but-recoverable + +`/sage archive ` retires a **Project** (its on-disk container). We chose to **move** the project directory to `/.archive//` rather than deleting it or flagging it in place, and to ship the operation **one-way** (no `unarchive` verb) while keeping it **losslessly recoverable** by hand. A dedicated, unit-tested `tools/archive_project.py` performs the move + cross-refs surgery; the router only dispatches to it via a new `mode: "archive"`. + +## Why move, not a marker or registry + +Discovery is `list_projects()` scanning `//learning/journal/index.md`. "Archived" fundamentally means "no longer appears in the `learn` picker, without being destroyed." Moving the directory under a hidden `.archive/` achieves that with almost no change to discovery, is self-evident to a human running `ls`, physically declutters the picker (the actual felt problem), and reverses with a single `mv`. A marker file or a central registry keeps clutter in place and adds hidden state that can desync from the filesystem. + +## Cross-refs handling (the hard part) + +The learning root's `cross-refs/` registry has two tiers. `INDEX.md` (`| Project | Overlaps With |`) is the **load-driver**: the coach loads every file named in a project's Overlaps-With cell. Per-project shards (`.md`, `| Concept | Also Covered In | ... |`) carry human-readable notes only. + +Archiving a project therefore must scrub **both** its own INDEX row **and** every inbound Overlaps-With cell that names it — otherwise a future session loads a shard that has moved away (file-not-found). This is functionally required, not cosmetic. We do **not** scrub the archived name from sibling shards' `Also Covered In` columns: those never drive loading, so a stale mention is harmless, and chasing it would rewrite ~every shard in the registry (write-risk) and have to be undone on recovery. The archived project's own shard is **moved** (co-located at `.archive//cross-refs.md`), not edited. + +## One-way by design — no `unarchive` + +There is **no `unarchive` verb, and none is planned**. This is a decision, not a deferral. Archiving is an explicitly confirmed act; the confirmation states the irreversibility at the moment it matters. Consenting to archive means consenting to give up the tracking state (knowledge map, cards, SRS schedule) and keep only the artifacts as a readable record. Returning to a topic means starting a fresh Project. + +The rejection is deliberate because a *correct* `unarchive` is far more expensive than it looks, and we designed it far enough to know: + +- **Stashed INDEX rows are snapshots that go stale, and restoring them verbatim is order-dependent and wrong.** Observed in real data: `ai-empirical-evals` (archived 09:12) stashed `| rag-retrieval-metrics | rag-triad, ai-empirical-evals, product-analytics, observability |`; `observability` (archived 09:33) stashed the same project's row as `| rag-retrieval-metrics | rag-triad, product-analytics |`. They disagree. Pasting the first back would resurrect `observability` while it is still archived — recreating exactly the dangling-load bug archive exists to prevent. A correct restore must re-insert the single slug token into the *current* cell and validate every overlap against what is currently active — a merge, not a restore. +- **Generations are ambiguous.** Numeric-suffixed archives (`alpha`, `alpha-2`) share one `original_slug`, so a typed slug cannot identify a target; it needs a picker keyed on `archive-meta.json`. +- **Restore collides on three surfaces at once** — the project directory, the cross-ref shard, and the INDEX row — because the slug *is* the identity. If a new Project has taken the name (which `/sage learn ` does freely once the old one is archived), restoring requires the learner to rename, which then invalidates historical references to the old slug. + +That is a large, well-tested machine for an operation that fires approximately never. Not building it is the cheaper correct answer. + +## Recovering by hand + +Nothing is deleted, so recovery stays possible without a command. The removed INDEX fragments (own row + stripped inbound cells) are stashed in `/archive-meta.json` alongside provenance (original slug, archived date passed in by the skill for determinism, inbound-ref count). The stash keeps its place precisely because it makes the rare manual restore tractable; the recipe is documented in the README. + +**The trap to avoid when restoring by hand:** do not paste `index.inbound_rows[].row` back verbatim — those are snapshots, per the order-dependence above. Re-add only the archived slug to each inbound project's *current* Overlaps-With cell, and drop any `own_overlaps` entry whose project is no longer active. + +## Consequences + +- **Collision:** if `.archive//` exists (archive → recreate same topic → archive again), the tool uses the lowest free numeric suffix (`.archive/-2/`). Never overwrites (honors "nothing is deleted"); deterministic, so testable. +- **Quiescent-only invariant:** `archive_project.py` is stateless and assumes at-rest data. If the target is the session's currently-active project, the **skill** runs the end-of-session checklist (journal + savepoint + cross-refs update) to fully persist *before* dispatching archive. The tool never reasons about live session state. Checkpoint updates cross-refs first, then archive moves/scrubs them — sequential, no conflict with the `enforce-cross-refs` Stop hook. +- **Confirmation is mandatory** and names the inbound-reference count, so archiving a heavily-linked hub project gives the learner pause. +- Bare `/sage archive` opens the `list_projects` picker (safer than typing a slug); `/sage archive ` resolves a slug and errors — creating nothing — if no project matches. diff --git a/docs/adr/0004-resolve-transcripts-by-session-id.md b/docs/adr/0004-resolve-transcripts-by-session-id.md new file mode 100644 index 0000000..dcfccd8 --- /dev/null +++ b/docs/adr/0004-resolve-transcripts-by-session-id.md @@ -0,0 +1,44 @@ +# Resolve transcripts by session id, and fail rather than guess + +`tools/session_duration.py` derives a **Session**'s `Duration` from the Claude Code transcript. It now finds that transcript by **session id**, globbing `~/.claude/projects/*/.jsonl`, taking the id from `CLAUDE_CODE_SESSION_ID` when none is passed explicitly. When an id is known but resolves to nothing, the tool **fails** — it does not fall back to guessing. The path-derived lookup survives only for manual invocation outside Claude Code, and warns when it guesses. + +## Why not the working directory + +The original implementation slugified `os.getcwd()` to locate the transcript directory. This is a faithful transcription of Claude Code's storage layout — transcripts really do live at `~/.claude/projects//.jsonl` — but it reads a *storage layout* as an *addressing scheme*, and those differ in one decisive way: **the slug encodes the directory Claude Code was launched in, not the directory the process is currently in.** Claude Code's Bash tool persists `cd` across tool calls, so any earlier `cd` into a subdirectory — verifying a file, running a scoped grep — permanently repoints the lookup at a directory that has never existed. `glob()` returns empty, the tool exits non-zero, and the coach falls through to asking the learner for a stopwatch reading while a correct 768 KB transcript sits untouched on disk. + +Supplying a session id did not rescue this, because the id was only used to pick a file *within* the cwd-derived directory. Same id, two working directories, two different answers. + +The tempting fix — pass the learning root in as an argument — is the same defect wearing a new hat. The slug tracks the launch directory, which only coincidentally equals the learning root; a session started from a subdirectory or from `$HOME` reproduces the bug exactly. **Any fix that derives the path from some other path is still deriving the path from a path.** The session id is the only identifier that is actually invariant, and it is unique across every transcript on disk (verified against 160). + +## Why the environment variable, not plumbing + +An earlier design had the id travel from a `SessionStart` hook, through `/tmp/.sage-session-id`, into the end-of-session checklist, into `session_wrapup.py`, into argv. That machinery is unnecessary: **`CLAUDE_CODE_SESSION_ID` is already in the tool's own environment**, inherited by subprocesses, and equal to the transcript's basename. Nothing needs to be plumbed and — importantly — the coach is never asked to supply an id it would have to reconstruct. An id the model guesses wrong is worse than no id at all, because a global glob on a wrong id finds nothing. + +This takes a deliberate dependency on an **undocumented** Claude Code variable. The bet is already implicit in this repo: the `~/.claude/projects//` layout the tool reads is equally undocumented. If the variable is renamed, the failure is loud (a `null` duration and a message naming the variable), not silent. + +An explicit id argument still overrides the environment. It is the test seam, and the escape hatch. + +## Why failing beats falling back + +Given a known id that resolves to nothing, the tool could fall back to "newest `.jsonl` by mtime in the cwd directory." It does not. + +That fallback returns *a* duration — one belonging to a different session — with exit 0 and no indication anything went wrong. It flows into `patch-metrics`, into the journal, and into coach pacing flags, where a wrong number is indistinguishable from a right one. A `null` announces itself, and the checklist already handles it: step 7 of `docs/ref-session-end.md` asks the learner for the wall time. + +This path was already live before the fix and undiagnosed: `session_duration.py ` from a valid directory returned a duration and exit 0, silently discarding the id. Making the id authoritative for *lookup* while leaving it advisory for *failure* would have kept that hole open. **If you trust a signal enough to search on it, you must trust it enough to fail on it** — overriding an authoritative signal with a guess is precisely how the deleted token-metrics collector came to aggregate across every session on the machine. + +The cost is real and accepted: the tool now fails *more often* in environments where transcripts are not on local disk. We prefer a visible gap to an invisible fabrication. + +## The assumption that makes a Sitting's wall time a Session's Duration + +`Duration` is the wall time of the **current Sitting** — the span since the last quiet gap longer than 30 minutes — not the full transcript span. This is necessary because compact/resume keeps the same session id and appends to the same file; one observed transcript spanned 9 days across 3 sittings, so a naive first-to-last would report days. + +That substitution is only valid under a stated assumption: **one Session is exactly one Sitting** (now defined in `CONTEXT.md`). A learner who breaks for 50 minutes mid-Session and returns has their Session recorded as the post-break remainder only. We accept this rather than trying to distinguish "a long break inside one Session" from "a new Session on the same transcript" — the 30-minute gap cannot tell them apart, and no other signal in the transcript can either. + +This is the part of the decision that is genuinely hard to reverse. The code is trivially changeable; the *meaning of `Duration`* in every journal entry already written is not. Revisiting it means either reinterpreting history or forking the definition at a date. + +## Consequences + +- **Three failure modes are now distinguishable** rather than collapsing into "No transcript or timestamps found" — a message that said *no transcript* when it meant *no transcript directory for this cwd*, which is what made the failure read as "the data is gone" instead of "you are in the wrong directory." +- **The no-id branch warns on success.** It is the only remaining path that can be confidently wrong, and it is reachable only by a human running the tool from a terminal. +- **`session_wrapup.py --session-id` is now vestigial.** It still forwards correctly; nothing needs to use it. `docs/ref-session-end.md` step 6 is unchanged. +- **Sessions are unaffected by where the coach has `cd`-ed**, which was the point. diff --git a/docs/adr/0005-stay-on-1x-despite-beta.md b/docs/adr/0005-stay-on-1x-despite-beta.md new file mode 100644 index 0000000..aa3a61f --- /dev/null +++ b/docs/adr/0005-stay-on-1x-despite-beta.md @@ -0,0 +1,8 @@ +# Stay on the 1.x version line despite beta maturity + +The plugin shipped as 1.0.x from its first release, but the product is still beta — semver convention would put it at 0.x, where anything may change. We decided to stay on 1.x anyway: `1.0.2` is already installed in the wild, and retreating to 0.x would make every future version sort *before* the installed one in any tooling that compares versions. Instead, the 1.x line is treated as young — patch bumps are used liberally, and 1.x does not yet carry the full stability promise semver implies. The compatibility rules that make this workable (what counts as breaking, what's internal) are in the sage repo's `docs/RELEASING.md`. + +We considered two alternatives: + +- **Go 0.x** — the honest semver signal. Rejected: version ordering breaks for existing installs. +- **Declare 1.x fully stable** — rejected: it would force major-bump ceremony on a product still changing shape. diff --git a/docs/adr/0006-hooks-are-advisory-invariants-live-in-tools.md b/docs/adr/0006-hooks-are-advisory-invariants-live-in-tools.md new file mode 100644 index 0000000..266b4a8 --- /dev/null +++ b/docs/adr/0006-hooks-are-advisory-invariants-live-in-tools.md @@ -0,0 +1,40 @@ +# Hooks are advisory; the one real invariant moves into the tools + +Porting Sage to a second **Host** raised the question of what breaks when a host has no +hook system. `AGENT-AGNOSTIC.md` had claimed correctness depended on Claude Code firing +hooks, and that enforcement must therefore be migrated into `tools/` before any port. +Reading the four scripts showed that was true of one hook, not four: + +- `checkpoint-guard.sh` — self-described "soft guard (warning, not block)" +- `verification-counter.sh` — emits a `systemMessage` +- `reset-verification.sh` — writes a flag file, renders no verdict +- `enforce-cross-refs.sh` — the only `"decision": "block"` in the repo + +So three hooks are ergonomics and one is an invariant. The invariant is also the cheapest +to relocate: it compares mtimes of files under the **Learning Root** and needs no +transcript, session id, or host API — host-neutral logic that happens to live in a +Claude-only file. + +The decision: the check is extracted to `tools/cross_refs_check.py`, which owns the rule and +is called from two places — `session_wrapup.py` (returning `cross_refs_stale` in its result, +which the end-of-session checklist acts on) and `enforce-cross-refs.sh`, now a thin trigger +that resolves the Learning Root, gates on cwd, and shells to the tool. The guarantee holds +wherever the wrapup runs; the hook only makes it automatic and blocking on Hosts that have +one. The other three hooks stay advisory ergonomics and are allowed to be absent elsewhere — +a Host without them loses nudges, not guarantees. `AGENT-AGNOSTIC.md` is retired. + +Two things follow from putting the rule in Python. The check no longer depends on `find`, +`stat -c`, or GNU-flavored `grep`, so it is portable to any Host that can run the engine at +all. And `session_wrapup.py`'s existing tests plus `tests/test_enforce_cross_refs.py` (which +drives the hook end to end, and therefore the tool through it) cover one implementation +instead of two. + +We considered two alternatives: + +- **Full move-2-first** (migrate all four hooks' enforcement into `tools/`, add a + `build-adapters.js` generator and a drift guard, as `AGENT-AGNOSTIC.md` proposed) — + rejected: three advisory nudges do not justify a code generator, and the doc's premise + was measurably wrong. +- **Port the hooks as-is and change nothing** (as `multi-host-support.md` proposed) — + rejected: it leaves the sole hard invariant depending on Codex's hook trust gate, which + the same doc lists as an unknown. Relocating ~15 lines removes the question entirely. diff --git a/docs/adr/0007-ponytail-layout-one-skill-under-skills-dir.md b/docs/adr/0007-ponytail-layout-one-skill-under-skills-dir.md new file mode 100644 index 0000000..d491f92 --- /dev/null +++ b/docs/adr/0007-ponytail-layout-one-skill-under-skills-dir.md @@ -0,0 +1,49 @@ +# Move the skill under `skills/sage/` and ship a plugin per Host + +Sage's Claude manifest declared `"skills": ["./"]` — the repo root *was* the skill. Codex's +plugin manifest takes `skills` as a single path string pointing at a *container* directory +holding skill subdirectories (verified: ponytail ships `skills/ponytail/SKILL.md`, +`skills/ponytail-audit/SKILL.md`, …). With `SKILL.md` at the repo root, Codex would scan for +skill subdirectories and find none. Supporting Codex therefore required a `skills/sage/` +directory. + +**Only `SKILL.md` and `references/` moved.** An earlier draft of this ADR said `agents/` and +`tools/` moved too; implementing it showed both must stay at the plugin root: + +- `agents/` is how **Claude Code registers subagents** — a plugin's Clerks are discovered at + the plugin root by convention, with no `agents` key in the manifest (verified against + `oh-my-claudecode`, which ships `"skills": "./skills/"` *and* a root `agents/`). Moving it + would have silently unregistered all six Clerks. +- `tools/` is addressed as `$SAGE_ROOT/tools/…`, and `$SAGE_ROOT` is the plugin root. + `references/` moves *because* it is addressed relative to `SKILL.md`. + +So `$SAGE_ROOT` keeps its existing meaning, `SessionStart` still writes +`${CLAUDE_PLUGIN_ROOT}` unchanged, the thirty prose sites that read it are untouched, and no +test path moved. The restructure is a two-directory `git mv` plus one manifest line. + +We now ship `.claude-plugin/plugin.json` and `.codex-plugin/plugin.json` side by side, both +pointing at one `hooks/claude-codex-hooks.json`. Install is one command per Host. +`${CLAUDE_PLUGIN_ROOT}` expands on both, and Codex normalizes event names +(`SubagentStart` → `subagent_start`), so no per-Host hook config and no event remap. +Future cleanup: replace that compatibility placeholder when the Hosts share a neutral +hook-root placeholder; Sage's internal name remains `$SAGE_ROOT`. + +The split does put two different `agents/` directories in the tree: `agents/` at the root +(the six Clerk specs, read by Claude) and `skills/sage/agents/` (holding only `openai.yaml`, +Codex's skill-adjacent metadata, following mattpocock/skills' convention). Confusing enough +to note; not confusing enough to fight either Host over. +`tests/test_plugin_manifests.py` fails if any of this drifts. + +mattpocock/skills looks like a counterexample — it restructured to `skills///` +but shipped no Codex plugin, and its ADR 0002 cites the single-path `skills` field. That +constraint does not apply here. Its Claude manifest is a hand-curated 22-entry array that +deliberately excludes `deprecated/`, `in-progress/`, and `personal/`; a single path string +cannot express that curation. Sage ships exactly one skill, so `"skills": "./skills/"` over +a directory containing only `sage/` ships precisely what is intended. + +We considered one alternative: + +- **Stay flat; install by symlink to `~/.agents/skills/sage` or `npx skills add`** — no + moves, but no plugin manifest and therefore no hooks on Codex. Rejected: it trades a + four-line change for permanent install friction, and the `git mv` becomes unavoidable the + first time a second Sage skill ships. diff --git a/docs/adr/0008-router-stays-the-sole-parser-of-the-invocation.md b/docs/adr/0008-router-stays-the-sole-parser-of-the-invocation.md new file mode 100644 index 0000000..865fad7 --- /dev/null +++ b/docs/adr/0008-router-stays-the-sole-parser-of-the-invocation.md @@ -0,0 +1,27 @@ +# The router stays the sole parser of the invocation + +`SKILL.md` passed `$ARGUMENTS` to `session_router.py`. Claude Code substitutes that from the +slash command; Codex has no argument substitution, so the port needed another way for the +invocation to reach the router. + +The obvious fix — have the coach extract the verb and topic from the learner's message and +pass them as a quoted string — would have undone [ADR 0002](0002-mandatory-command-verbs.md). +That ADR accepted a breaking change to make the verb mandatory, on the grounds that a Topic +named "archive" is unambiguous only because `learn` is always present. That holds while the +*learner* types the verb. If the coach infers it, "archive my react notes" is ambiguous +again — precisely the collision that got `learn`-as-implicit-default rejected. + +It would also have disabled the fallback that was cited as making it safe. The +`unknown_verb` branch fires on an *unrecognized* verb; a coach doing extraction emits `learn` +or `archive`, always recognized. Making the coach the parser removes the parser's own +guard against a bad parse. + +So the coach passes the learner's request through **verbatim** and `parse_invocation` remains +the only parser on every Host. On Claude that is still `$ARGUMENTS`; elsewhere it is the +learner's message text. A request with no leading verb reaches `unknown_verb` and gets the +grammar message, which is the designed behavior rather than a failure. + +Consequence: the `unknown_verb` message becomes the primary way the grammar is taught on a +Host with no slash commands, so the router's hardcoded `/sage` prefixes +(`session_router.py:115,145`) are dropped in favor of bare `learn ` / +`archive `. diff --git a/hooks/README.md b/hooks/README.md index 675d775..ae5d611 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -12,13 +12,43 @@ echo '{"session_id":"test","cwd":"'"$PWD"'","stop_hook_active":false}' \ ## Hook Reference +One config, `claude-codex-hooks.json`, serves both Claude Code and Codex — both +support these events and the same stdin/stdout JSON contract, and Codex normalizes +the event names itself (`SubagentStart` → `subagent_start`). + | Hook | Event | Script | Purpose | |------|-------|--------|---------| | Verification counter | Stop | `scripts/verification-counter.sh` | Counts coach messages since last verification-gate call. Warns at 5+. | -| 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. | +| Reset verification | SubagentStop | `scripts/reset-verification.sh` | Resets counter when a verification-gate Clerk is called. Creates counter file on first call. | +| Checkpoint guard | SubagentStart | `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. | +## Design notes + +**Only one hook enforces anything.** `enforce-cross-refs.sh` is the sole +`"decision": "block"`; the other three are advisory. That check therefore lives in +`tools/cross_refs_check.py` and is called by `session_wrapup.py` too, so it holds on +Hosts with no hook system — the hook is only the automatic trigger. See +`docs/adr/0006-hooks-are-advisory-invariants-live-in-tools.md`. + +**Clerk identification is Host-neutral.** Sage registers no Codex agents, so +`agent_type` is generic there. Both agent-keyed scripts match on the registered type +*or* the spec pointer the prose delegation carries in the prompt: + +```bash +IDENT=$(jq -r '.agent_type // .tool_input.subagent_type // empty') +PROMPT=$(jq -r '.prompt // .tool_input.prompt // empty') +case "$IDENT$PROMPT" in *verification-gate*) ;; *) exit 0 ;; esac +``` + +Unverified on Codex: whether its `SubagentStart` payload carries a prompt. If not, +these two hooks no-op there — advisory only, so the cost is lost nudges. + +**All scripts fail open** — unparseable stdin, a jq error, or an empty payload exits 0. +A hook must never wedge a session on an untested Host. Each is also capped by a 5s +`timeout` in the config. + ## Known Issues -- ~~`reset-verification.sh` matches `subagent_type == "verification-gate"` but namespaced invocations use `"sage:verification-gate"`. Same for `"artifact-clerk"` vs `"sage:artifact-clerk"`. The counter file never gets created, so the verification overdue warning never fires.~~ **Fixed** — now uses glob suffix match (`*"verification-gate"`, `*"artifact-clerk"`). +- ~~`reset-verification.sh` matches `subagent_type == "verification-gate"` but namespaced invocations use `"sage:verification-gate"`. Same for `"artifact-clerk"` vs `"sage:artifact-clerk"`. The counter file never gets created, so the verification overdue warning never fires.~~ **Fixed** — the identity-or-prompt match above matches a substring, so both bare and namespaced forms hit, and the spec-path fallback sidesteps namespacing entirely. +- State files are `/tmp/sage-*` (renamed from `/tmp/claude-*` in v1.2.0 — they are Sage's own state, and the old name read as wrong on every non-Claude Host). diff --git a/hooks/hooks.json b/hooks/claude-codex-hooks.json similarity index 53% rename from hooks/hooks.json rename to hooks/claude-codex-hooks.json index dd7b41c..dac5a4f 100644 --- a/hooks/hooks.json +++ b/hooks/claude-codex-hooks.json @@ -5,44 +5,47 @@ "hooks": [ { "type": "command", - "command": "echo \"${CLAUDE_PLUGIN_ROOT}\" > /tmp/.sage-plugin-root && python3 \"${CLAUDE_PLUGIN_ROOT}/tools/config.py\" > /tmp/.sage-learning-root 2>/dev/null || true" + "command": "echo \"${CLAUDE_PLUGIN_ROOT}\" > /tmp/.sage-plugin-root && python3 \"${CLAUDE_PLUGIN_ROOT}/tools/config.py\" > /tmp/.sage-learning-root 2>/dev/null || true", + "timeout": 5 } ] } ], - "PreToolUse": [ + "SubagentStart": [ { - "matcher": "Agent", "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/checkpoint-guard.sh" + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/checkpoint-guard.sh\"", + "timeout": 5 } ] } ], - "Stop": [ + "SubagentStop": [ { - "matcher": "", "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/enforce-cross-refs.sh" - }, - { - "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/verification-counter.sh" + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/reset-verification.sh\"", + "timeout": 5 } ] } ], - "PostToolUse": [ + "Stop": [ { - "matcher": "Agent", + "matcher": "", "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/reset-verification.sh" + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/enforce-cross-refs.sh\"", + "timeout": 5 + }, + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/verification-counter.sh\"", + "timeout": 5 } ] } diff --git a/hooks/scripts/checkpoint-guard.sh b/hooks/scripts/checkpoint-guard.sh index 0d403bb..89b6ef0 100755 --- a/hooks/scripts/checkpoint-guard.sh +++ b/hooks/scripts/checkpoint-guard.sh @@ -1,21 +1,31 @@ #!/usr/bin/env bash -# PreToolUse hook on Agent: warns if artifact-clerk checkpoint is -# called but new cards haven't been verified this session. +# SubagentStart hook: warns if an artifact-clerk checkpoint is called +# but new cards haven't been verified this session. # # This is a soft guard (warning, not block) — the coach may # legitimately checkpoint without new cards. +# +# Host-neutral: the Clerk is identified by the registered agent type +# (Claude: .tool_input.subagent_type, Codex: .agent_type) OR by the spec +# pointer the prose delegation carries in the prompt. Codex spawns +# unregistered subagents, so the prompt is the only signal there. +# +# Fails open: unparseable stdin or a missing jq exits 0 rather than +# blocking a session on an untested Host. -set -euo pipefail +set -uo pipefail -INPUT=$(cat) +INPUT=$(cat 2>/dev/null) || exit 0 +[ -n "$INPUT" ] || exit 0 -SUBAGENT_TYPE=$(echo "$INPUT" | jq -r '.tool_input.subagent_type // empty') -PROMPT=$(echo "$INPUT" | jq -r '.tool_input.prompt // empty') +IDENT=$(echo "$INPUT" | jq -r '.agent_type // .tool_input.subagent_type // empty' 2>/dev/null) || exit 0 +PROMPT=$(echo "$INPUT" | jq -r '.prompt // .tool_input.prompt // empty' 2>/dev/null) || exit 0 # Only care about artifact-clerk checkpoint calls -if [ "$SUBAGENT_TYPE" != "artifact-clerk" ]; then - exit 0 -fi +case "$IDENT$PROMPT" in + *artifact-clerk*) ;; + *) exit 0 ;; +esac if ! echo "$PROMPT" | grep -qi "checkpoint"; then exit 0 @@ -28,15 +38,15 @@ if ! echo "$PROMPT" | grep -qi "card"; then fi # Cards are mentioned — check if they were verified -SESSION_ID=$(echo "$INPUT" | jq -r '.session_id') -CARDS_FLAG="/tmp/claude-cards-verified-${SESSION_ID}" +SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null) || exit 0 +CARDS_FLAG="/tmp/sage-cards-verified-${SESSION_ID}" if [ ! -f "$CARDS_FLAG" ]; then # Cards mentioned but not verified — warn cat </dev/null) || exit 0 +[ -n "$INPUT" ] || exit 0 -CROSS_REFS_DIR="${SAGE_DIR}/cross-refs" +CWD=$(echo "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || exit 0 +STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // empty' 2>/dev/null) || exit 0 -NOW=$(date +%s) - -# Check if any knowledge-map.md was modified within threshold -# AND has concepts at developing or higher (not just created with all not_started) -KM_MODIFIED=false -KM_HAS_PROMOTED=false -while IFS= read -r km; do - KM_MTIME=$(stat -c %Y "$km" 2>/dev/null || echo 0) - KM_AGE=$((NOW - KM_MTIME)) - if [ "$KM_AGE" -lt "$THRESHOLD" ]; then - KM_MODIFIED=true - if grep -qE '\| (developing|solid|mastered) \|' "$km" 2>/dev/null; then - KM_HAS_PROMOTED=true - fi - break - fi -done < <(find "$SAGE_DIR" -name "knowledge-map.md" 2>/dev/null) - -if [ "$KM_MODIFIED" != "true" ]; then +if [ "$STOP_HOOK_ACTIVE" = "true" ]; then exit 0 fi -if [ "$KM_HAS_PROMOTED" != "true" ]; then +# Only fire under the Learning Root (or a subdirectory) +if [[ "$CWD" != "$SAGE_DIR"* ]]; then exit 0 fi -# Knowledge map modified — check if cross-refs were updated too -CR_UPDATED=false - -# Check sharded cross-refs/ directory -if [ -d "$CROSS_REFS_DIR" ]; then - while IFS= read -r cr; do - CR_MTIME=$(stat -c %Y "$cr" 2>/dev/null || echo 0) - CR_AGE=$((NOW - CR_MTIME)) - if [ "$CR_AGE" -lt "$THRESHOLD" ]; then - CR_UPDATED=true - break - fi - done < <(find "$CROSS_REFS_DIR" -name "*.md" 2>/dev/null) -fi +REASON=$(python3 "$SAGE_ROOT/tools/cross_refs_check.py" "$SAGE_DIR" 2>/dev/null) || exit 0 -# Knowledge map modified but cross-references weren't — block -if [ "$CR_UPDATED" != "true" ]; then - 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 +if [ -n "$REASON" ]; then + jq -n --arg reason "$REASON" '{decision: "block", reason: $reason}' fi exit 0 diff --git a/hooks/scripts/reset-verification.sh b/hooks/scripts/reset-verification.sh index 33ffd8a..ec0dca7 100755 --- a/hooks/scripts/reset-verification.sh +++ b/hooks/scripts/reset-verification.sh @@ -1,47 +1,58 @@ #!/usr/bin/env bash -# PostToolUse hook on Agent: resets the verification counter when -# a verification-gate agent is called. Also marks card verification +# SubagentStop hook: resets the verification counter when a +# verification-gate Clerk is called. Also marks card verification # for the checkpoint guard. # # Activates the counter on first verification-gate call in a session. +# +# Host-neutral: the Clerk is identified by the registered agent type +# (Claude: .tool_input.subagent_type, Codex: .agent_type) OR by the spec +# pointer the prose delegation carries in the prompt. Matching the spec +# path also sidesteps the plugin-namespace prefix problem that forced +# glob-suffix matching here (see hooks/README.md). +# +# Fails open: unparseable stdin or a missing jq exits 0 rather than +# blocking a session on an untested Host. -set -euo pipefail +set -uo pipefail -INPUT=$(cat) +INPUT=$(cat 2>/dev/null) || exit 0 +[ -n "$INPUT" ] || exit 0 -SESSION_ID=$(echo "$INPUT" | jq -r '.session_id') +SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null) || exit 0 +IDENT=$(echo "$INPUT" | jq -r '.agent_type // .tool_input.subagent_type // empty' 2>/dev/null) || exit 0 +PROMPT=$(echo "$INPUT" | jq -r '.prompt // .tool_input.prompt // empty' 2>/dev/null) || exit 0 -SUBAGENT_TYPE=$(echo "$INPUT" | jq -r '.tool_input.subagent_type // empty') -PROMPT=$(echo "$INPUT" | jq -r '.tool_input.prompt // empty') +COUNTER_FILE="/tmp/sage-verif-counter-${SESSION_ID}" +WARNED_FILE="/tmp/sage-verif-warned-${SESSION_ID}" # Reset on artifact-clerk checkpoint (end of teaching phase) -if [[ "$SUBAGENT_TYPE" == *"artifact-clerk" ]]; then - if echo "$PROMPT" | grep -qi "checkpoint"; then - COUNTER_FILE="/tmp/claude-verif-counter-${SESSION_ID}" - if [ -f "$COUNTER_FILE" ]; then - echo "0" > "$COUNTER_FILE" +case "$IDENT$PROMPT" in + *artifact-clerk*) + if echo "$PROMPT" | grep -qi "checkpoint"; then + if [ -f "$COUNTER_FILE" ]; then + echo "0" > "$COUNTER_FILE" + fi + rm -f "$WARNED_FILE" fi - WARNED_FILE="/tmp/claude-verif-warned-${SESSION_ID}" - rm -f "$WARNED_FILE" - fi - exit 0 -fi + exit 0 + ;; +esac -if [[ "$SUBAGENT_TYPE" != *"verification-gate" ]]; then - exit 0 -fi +case "$IDENT$PROMPT" in + *verification-gate*) ;; + *) exit 0 ;; +esac # Reset the message counter (creates it if first call) -COUNTER_FILE="/tmp/claude-verif-counter-${SESSION_ID}" echo "0" > "$COUNTER_FILE" # Clear warned flag -WARNED_FILE="/tmp/claude-verif-warned-${SESSION_ID}" rm -f "$WARNED_FILE" # If this was a verify-cards operation, mark it for the checkpoint guard if echo "$PROMPT" | grep -qi "verify-cards"; then - CARDS_FLAG="/tmp/claude-cards-verified-${SESSION_ID}" + CARDS_FLAG="/tmp/sage-cards-verified-${SESSION_ID}" echo "1" > "$CARDS_FLAG" fi diff --git a/hooks/scripts/verification-counter.sh b/hooks/scripts/verification-counter.sh index c1fd069..f82aad5 100755 --- a/hooks/scripts/verification-counter.sh +++ b/hooks/scripts/verification-counter.sh @@ -2,18 +2,22 @@ # Stop hook: counts coach messages since last verification-gate call. # Warns when 5+ messages have passed without verification. # -# State file: /tmp/claude-verif-counter- +# State file: /tmp/sage-verif-counter- # The counter file is created by reset-verification.sh on the first # verification-gate call. If it doesn't exist, this hook is a no-op # (we're not in a session that uses verification). +# +# Fails open: unparseable stdin or a missing jq exits 0 rather than +# blocking a session on an untested Host. -set -euo pipefail +set -uo pipefail -INPUT=$(cat) +INPUT=$(cat 2>/dev/null) || exit 0 +[ -n "$INPUT" ] || exit 0 -SESSION_ID=$(echo "$INPUT" | jq -r '.session_id') -STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active') -COUNTER_FILE="/tmp/claude-verif-counter-${SESSION_ID}" +SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null) || exit 0 +STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // empty' 2>/dev/null) || exit 0 +COUNTER_FILE="/tmp/sage-verif-counter-${SESSION_ID}" # Prevent infinite loops if [ "$STOP_HOOK_ACTIVE" = "true" ]; then @@ -32,7 +36,7 @@ echo "$COUNT" > "$COUNTER_FILE" # Warn once at 5+ if [ "$COUNT" -ge 5 ]; then - WARNED_FILE="/tmp/claude-verif-warned-${SESSION_ID}" + WARNED_FILE="/tmp/sage-verif-warned-${SESSION_ID}" if [ ! -f "$WARNED_FILE" ]; then echo "1" > "$WARNED_FILE" cat <&2 + exit 1 +fi + +# Agent Skills standard location first — Codex reads ~/.codex/skills, which is +# itself commonly a symlink into ~/.agents/skills. +TARGETS=( + "$HOME/.agents/skills" + "$HOME/.claude/skills" + "$HOME/.codex/skills" +) + +link_one() { + local src="$1" dest="$2" name="$3" + + if [ -e "$dest" ] && [ ! -L "$dest" ]; then + echo " skip $name → $dest exists and is not a symlink" + return + fi + + if [ "$DRY_RUN" = true ]; then + echo " would link $name → $dest" + return + fi + + ln -sfn "$src" "$dest" + echo " linked $name → $dest" +} + +for target in "${TARGETS[@]}"; do + if [ ! -d "$(dirname "$target")" ]; then + echo "$target — parent missing, Host not installed, skipping" + continue + fi + + mkdir -p "$target" + echo "$target" + + for skill in "$SKILLS_DIR"/*/; do + [ -f "$skill/SKILL.md" ] || continue + name="$(basename "$skill")" + link_one "${skill%/}" "$target/$name" "$name" + done +done + +echo +echo "Export SAGE_ROOT to pin the plugin root for this shell:" +echo " export SAGE_ROOT=\"$REPO_ROOT\"" diff --git a/SKILL.md b/skills/sage/SKILL.md similarity index 85% rename from SKILL.md rename to skills/sage/SKILL.md index 5641640..bff9396 100644 --- a/SKILL.md +++ b/skills/sage/SKILL.md @@ -4,38 +4,56 @@ description: | Evidence-based learning session with spaced repetition, retrieval practice, and mastery tracking. argument-hint: "learn | archive " +disable-model-invocation: true --- -You are running a Sage session. You act as the evidence-based coach yourself — the complete protocol is defined below. You delegate only to the operational subagents listed in `references/ref-subagents.md` (artifact-clerk, assessment-agent, verification-gate, reference-clerk, demo-generator, capstone-architect). Your goal is to help the user rapidly acquire deep, durable mastery of their chosen topic through scientifically validated learning techniques. +You are running a Sage session. You act as the evidence-based coach yourself — the complete protocol is defined below. You delegate only to the Clerks listed in `references/ref-subagents.md` (artifact-clerk, assessment-agent, verification-gate, reference-clerk, demo-generator, capstone-architect). Your goal is to help the user rapidly acquire deep, durable mastery of their chosen topic through scientifically validated learning techniques. + +**Paths in this file.** Every `references/…` path is relative to **this file's +directory**, `$SAGE_ROOT/skills/sage/`. `$SAGE_ROOT` is the *plugin root*, one +level above — it is the prefix for `tools/…` and `agents/…` only. Resolving +`references/…` against `$SAGE_ROOT` yields a path that does not exist. ## The Topic/Skill to Master -$ARGUMENTS +Whatever the learner asked for when they invoked Sage. Do not restate or +reinterpret it — the router resolves it in Step 0. ## Step 0: Session Setup -The command grammar is `/sage ` with exactly two verbs — `learn` -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): +The command grammar is ` ` with exactly two verbs — `learn` and +`archive`. The verb is mandatory; there is no verb-less form. + +**Pass the learner's request through verbatim. Do not parse it yourself.** The +router is the only parser: it extracts the leading verb and fails safe when +there isn't one. Extracting the verb yourself reintroduces the ambiguity the +mandatory-verb grammar exists to remove (a topic named "archive" becomes +indistinguishable from the archive verb) and bypasses the `unknown_verb` branch +that catches a bad parse. + ```bash -SAGE_ROOT=$(cat /tmp/.sage-plugin-root) -python3 "$SAGE_ROOT/tools/session_router.py" "$SAGE_ROOT" "$ARGUMENTS" +SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" +python3 "$SAGE_ROOT/tools/session_router.py" "$SAGE_ROOT" "" ``` -- If `mode` is `unknown_verb`: the learner used the old verb-less grammar (e.g. - `/sage react hooks`) or a dropped keyword (`continue`). Show the router's - `message` field verbatim — it maps the old form to the new one — and stop. Do - not guess a topic or start a session. +- If `mode` is `unknown_verb`: the learner's request had no leading verb (e.g. + `react hooks`, or a conversational phrasing) or used a dropped keyword + (`continue`). Show the router's `message` field verbatim — it teaches the + grammar — and stop. Do not guess a topic or start a session. - If `mode` is `needs_config`: ask the learner where to store projects, then: 1. **Preview** the resolved path so typos and `~` expansion are visible before anything is written: + ```bash python3 "$SAGE_ROOT/tools/config.py" --normalize "" ``` + Show the resolved absolute path and ask the learner to confirm it's correct (this catches typos like `lerning`). 2. On confirmation, **save** it. `save_config()` expands `~`, makes the path absolute, and creates `/cross-refs/` *before* writing the config — no separate `mkdir` is needed: + ```bash python3 -c "import sys; sys.path.insert(0, '$SAGE_ROOT/tools'); from config import save_config; print(save_config(''))" ``` + 3. If `save_config` raises (e.g. permission denied, or the path sits under an existing file), report the error and ask for a different location — nothing is persisted on failure, so the learner can safely retry. Then re-run the router. @@ -67,9 +85,11 @@ confirmation. 2. **Get the plan.** Never describe the archive from your own reading of `INDEX.md` — the tool computes every fact. Run it in dry-run mode, which touches nothing (not even `.archive/`): + ```bash python3 "$SAGE_ROOT/tools/archive_project.py" "" "" --dry-run ``` + It returns `status: "dry_run"` plus `archived_dir` (the real destination, including any numeric suffix), `shard_archived`, `index_own_row_removed`, `inbound_refs_scrubbed`, and `inbound_ref_count`. @@ -78,6 +98,7 @@ confirmation. every path and number below comes from that output, never from your own inspection. Require an explicit yes. The inbound count is what makes a heavily-linked hub project give pause, so state it plainly: + ``` Archive ""? • moves @@ -89,15 +110,18 @@ confirmation. a fresh project. The artifacts stay readable under .archive/. Nothing is deleted. Proceed? (yes/no) ``` + If `archived_dir` carries a numeric suffix, say so — it means a previous archive of this slug already exists. If the learner declines, stop — change nothing (the dry-run has already left the filesystem untouched). 4. **Run the tool for real**, with today's date (passed in so the tool stays deterministic): + ```bash python3 "$SAGE_ROOT/tools/archive_project.py" "" "" --date "$(date +%Y-%m-%d)" ``` + It recomputes the plan from scratch rather than trusting the dry-run, then executes it. @@ -107,6 +131,7 @@ confirmation. ### Eager-Load References Before any teaching begins (both resume and fresh start paths), read these files: + - `references/ref-subagents.md` — subagent call patterns and integration rules - `references/ref-verification.md` — verification protocol, verdict handling, fallback chain @@ -117,24 +142,38 @@ These stay in context for the entire session. When resuming a learning journey in progress, follow this protocol exactly: 1. **Request a brief from the Artifact Clerk:** + + ``` + Delegate to `artifact-clerk`: + + ``` + + Read $SAGE_ROOT/agents/artifact-clerk.md in full and follow it exactly — that + file is your complete specification. Do not act before reading it. + + Operation: brief + Path: /learning/ + Project: + ``` - Task(subagent_type="artifact-clerk", prompt="Operation: brief\nPath: /learning/\nProject: ") ``` + Include the `Project:` field with the project's folder name (the directory name used in `cross-refs/` if it exists). This lets the clerk reliably match against the cross-project registry. If you don't know the project folder name, omit the field — the clerk will fall back to searching by topic slug. The clerk reads all artifacts, the SRS engine state, and the cross-project registry, returning a compact summary with: current plan position, last savepoint, due reviews, active misconceptions, knowledge map snapshot, plateau status, and cross-project overlaps. 2a. **Read coach insights** (if `coach-insights.md` exists in the learning directory): - - Load all CI-# entries with status `active` or `validated` from the brief's "Coach Insights" section - - These are behavioral rules the coach has learned from past errors - - Apply them as constraints for this session (e.g., "CI-1: verify API signatures before presenting them") - - If you notice yourself about to violate a rule, stop and correct course + +- Load all CI-# entries with status `active` or `validated` from the brief's "Coach Insights" section +- These are behavioral rules the coach has learned from past errors +- Apply them as constraints for this session (e.g., "CI-1: verify API signatures before presenting them") +- If you notice yourself about to violate a rule, stop and correct course 2b. **Verify coach-insights independently:** Do NOT rely solely on the brief's "Coach Insights" section. Always read `/learning/coach-insights.md` directly yourself. If the brief reported "None" but the file exists, use the file contents and note the discrepancy for the session checkpoint. -2. **Pre-verify upcoming session claims:** Read the plan to identify what concepts, APIs, or technical facts the next session segment will cover. Batch-verify them per `references/ref-verification.md` trigger condition #1. Skip if resuming from the same savepoint with no plan advancement. Exclude claims already covered by existing cards in `cards.md`. +1. **Pre-verify upcoming session claims:** Read the plan to identify what concepts, APIs, or technical facts the next session segment will cover. Batch-verify them per `references/ref-verification.md` trigger condition #1. Skip if resuming from the same savepoint with no plan advancement. Exclude claims already covered by existing cards in `cards.md`. -3. **Reconstruct context** from the brief: +2. **Reconstruct context** from the brief: - What phase/milestone was the learner on? - What was the immediate next step? - Are any spaced reviews overdue? @@ -142,7 +181,8 @@ When resuming a learning journey in progress, follow this protocol exactly: - **What concepts are marked `prior (from [project])`?** Skip re-teaching these and reference existing knowledge: "You covered [concept] in [project]. Let's build on that." - Are there any coach metrics flags? (e.g., "time-to-solid increasing" → adjust teaching approach this session) -4. **Greet with a contextual summary** — show the learner you know exactly where they left off: +3. **Greet with a contextual summary** — show the learner you know exactly where they left off: + ``` Welcome back! Last time (Session N on [date]), we were working on [topic]. You had just [what they were doing]. Your next step was [from savepoint]. @@ -150,17 +190,32 @@ When resuming a learning journey in progress, follow this protocol exactly: Before we continue, let's do a quick retrieval check on what we covered last time... ``` -5. **Handle overdue reviews FIRST.** If any spaced reviews are overdue, address them before new material. Forgetting compounds — catch it early. +4. **Handle overdue reviews FIRST.** If any spaced reviews are overdue, address them before new material. Forgetting compounds — catch it early. + +5. **Request assessment questions for retrieval warm-up:** + + ``` + Delegate to `assessment-agent`: + + ``` + + Read $SAGE_ROOT/agents/assessment-agent.md in full and follow it exactly — that + file is your complete specification. Do not act before reading it. + + Operation: select-and-prepare + Path: /learning/ + + Session context: [topics from savepoint] + Count: 2-3 -6. **Request assessment questions for retrieval warm-up:** ``` - Task(subagent_type="assessment-agent", prompt="Operation: select-and-prepare\nPath: /learning/\n\nSession context: [topics from savepoint]\nCount: 2-3") ``` + **Exemption:** If overdue SRS cards exceed 20, skip the assessment warm-up — SRS triage replaces it. The overdue card reviews serve as retrieval practice. Note the substitution in session notes. -7. **Start with retrieval practice on previous material** — this is both a learning technique AND a diagnostic. How much they retained tells you whether to review or advance. +6. **Start with retrieval practice on previous material** — this is both a learning technique AND a diagnostic. How much they retained tells you whether to review or advance. -8. **Pick up from the savepoint** — continue the plan from exactly where they stopped. +7. **Pick up from the savepoint** — continue the plan from exactly where they stopped. ## Phase 1: Metalearning & Planning @@ -236,6 +291,7 @@ Sessions are designed to be **interruptible at any time**. The learner can leave ### During a Session Monitor for: + - Signs of passive learning (just reading/listening) → shift to generation - Frustration with difficulty → normalize it, break it down - False confidence → challenge with harder retrieval or edge cases @@ -315,6 +371,7 @@ Watch for: illusion of competence, passive consumption, blocked practice, insuff You MUST generate learning journey artifacts throughout the session. For the full artifact table, entry classification rules (WS vs CE/CP), weak spot categories, coach error protocol, and card type taxonomy, read `references/ref-artifacts.md` when logging entries. Core rules: + - Create `plan.md` before starting execution — it grounds the journey - Never end a session without a journal entry (clerk writes `journal/session-NN.md`) - Only promote a concept's status in the knowledge map based on demonstrated retrieval, not mere exposure @@ -322,7 +379,7 @@ Core rules: ## SRS Engine -SM-2 spaced repetition scheduler. Resolve path with `SAGE_ROOT=$(cat /tmp/.sage-plugin-root)`. You grade cards directly during reviews; the clerk handles init/sync/forecast. Before your first SRS review in a session, read `references/ref-srs.md` for commands, quality scale, and grading protocol. +SM-2 spaced repetition scheduler. Resolve path with `SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}"`. You grade cards directly during reviews; the clerk handles init/sync/forecast. Before your first SRS review in a session, read `references/ref-srs.md` for commands, quality scale, and grading protocol. ## Plateau Detector @@ -331,6 +388,7 @@ The clerk runs the plateau detector during the brief and includes results in the ## Capstone Build Guidance When the learner is building a capstone project: + - Write all capstone build artifacts under `capstone//`, a sibling to `learning/`. Only move artifacts to their production location (e.g., `.claude/skills/`) when the learner marks them ready. - The capstone spec lives at `capstone/capstone.md` (written by the capstone-architect agent). - Proposals live at `capstone/capstone-proposals.md`. diff --git a/skills/sage/agents/openai.yaml b/skills/sage/agents/openai.yaml new file mode 100644 index 0000000..fa4ff26 --- /dev/null +++ b/skills/sage/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Sage" + short_description: "Evidence-based learning coach with spaced repetition" +policy: + allow_implicit_invocation: false diff --git a/references/ref-artifacts.md b/skills/sage/references/ref-artifacts.md similarity index 100% rename from references/ref-artifacts.md rename to skills/sage/references/ref-artifacts.md diff --git a/references/ref-plateau.md b/skills/sage/references/ref-plateau.md similarity index 100% rename from references/ref-plateau.md rename to skills/sage/references/ref-plateau.md diff --git a/references/ref-session-end.md b/skills/sage/references/ref-session-end.md similarity index 62% rename from references/ref-session-end.md rename to skills/sage/references/ref-session-end.md index a3b61f6..a26f3ee 100644 --- a/references/ref-session-end.md +++ b/skills/sage/references/ref-session-end.md @@ -12,28 +12,46 @@ Follow this checklist in order when the session ends or the learner signals they ## 3. Verify Flashcards -Before persisting any new flashcards, send them through the verification gate: +Before persisting any new flashcards, delegate to `verification-gate`: ``` -Task(subagent_type="verification-gate", prompt="Operation: verify-cards\nTopic: [topic]\n\nCards:\n### Card 1\n**Q:** [question]\n**A:** [answer]\n**Tags:** [tags]\n...") +Read $SAGE_ROOT/agents/verification-gate.md in full and follow it exactly — that +file is your complete specification. Do not act before reading it. + +Operation: verify-cards +Topic: [topic] + +Cards: +### Card 1 +**Q:** [question] +**A:** [answer] +**Tags:** [tags] +... ``` Apply corrections from `corrected` verdicts. For `flagged` cards, either fix them yourself or drop them — never persist an unverified flashcard. Wrong flashcards are actively harmful because spaced repetition will cement the error. ## 4. Checkpoint via Artifact Clerk -Compile your session notes (what was covered, retrieval scores, assessment performance, new cards, misconceptions, knowledge map changes, savepoint data) and send to the clerk: +Compile your session notes (what was covered, retrieval scores, assessment performance, new cards, misconceptions, knowledge map changes, savepoint data) and delegate to `artifact-clerk`: ``` -Task(subagent_type="artifact-clerk", prompt="Operation: checkpoint\nPath: /learning/\n\n[session notes]") +Read $SAGE_ROOT/agents/artifact-clerk.md in full and follow it exactly — that +file is your complete specification. Do not act before reading it. + +Operation: checkpoint +Path: /learning/ + +[session notes] ``` The clerk updates all artifacts, runs SRS sync/forecast, and validates cross-artifact consistency. Include an "Assessment Performance" section in session notes with question IDs, scores, and quality ratings from any assessment agent evaluations. - If any CE-# or CP-# entries were created, updated, or resolved this session, include a flag in the checkpoint data: `Coach Reflect: yes` - After the checkpoint completes, make a separate call to trigger reflection: - `Task(subagent_type="artifact-clerk", prompt="Operation: coach-reflect\nPath: /learning/")` + After the checkpoint completes, delegate to `artifact-clerk` again with the + spec pointer followed by `Operation: coach-reflect` and + `Path: /learning/`. Review the returned candidates and approve or reject each one. The clerk writes approved rules to `coach-insights.md`. - If CE/CP entries are logged **after** the checkpoint completes (e.g., through learner feedback or late self-discovery), trigger `coach-reflect` immediately — do not defer to the next session. Full session context is available now; it won't be later. @@ -46,24 +64,31 @@ Check for consistency warnings and address any flagged issues. After all post-checkpoint work is complete, run the wrapup script: ```bash -SAGE_ROOT=$(cat /tmp/.sage-plugin-root) +SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}" python3 "$SAGE_ROOT/tools/session_wrapup.py" "$SAGE_ROOT" "" ``` 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 `cross_refs_stale` is non-null, a knowledge map was promoted this session but +`cross-refs/` was not updated. Upsert the affected concepts before finishing — this +is the one hard invariant, and it is enforced here so it holds on every Host, not +only where a Stop hook happens to fire. If any `errors`, note them but don't block — these are non-critical. ## 7. Patch Duration into Journal -``` -Task(subagent_type="artifact-clerk", prompt="Operation: patch-metrics\nPath: /learning/\nDuration: ") -``` +Delegate to `artifact-clerk` with the spec pointer followed by +`Operation: patch-metrics`, `Path: /learning/`, and +`Duration: `. The clerk patches the session duration into the latest journal entry. -**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. +**Fallback:** `duration` is null whenever no session transcript can be trusted — +either the lookup failed, or the Host does not expose a session id at all (only +Claude Code does). In both cases ask the learner for the session wall time and +pass that as the `Duration` value instead. Never pass a guessed number. ## 8. Confirm to Learner diff --git a/references/ref-srs.md b/skills/sage/references/ref-srs.md similarity index 96% rename from references/ref-srs.md rename to skills/sage/references/ref-srs.md index 6d7cc26..5823059 100644 --- a/references/ref-srs.md +++ b/skills/sage/references/ref-srs.md @@ -12,7 +12,7 @@ You have access to a spaced repetition scheduling engine that implements the SM- | `python3 "$SAGE_ROOT/tools/srs/srs_engine.py" grade ` | Grade a card (0-5), update schedule | **You run this directly** — after assessing each card during review | | `python3 "$SAGE_ROOT/tools/srs/srs_engine.py" forecast --days 14` | Show what's due each day | Session end (Artifact Clerk handles this) | -All commands accept `--json` for machine-readable output. `` is the `/learning/` directory. Always resolve `SAGE_ROOT` first: `SAGE_ROOT=$(cat /tmp/.sage-plugin-root)`. +All commands accept `--json` for machine-readable output. `` is the `/learning/` directory. Always resolve `SAGE_ROOT` first: `SAGE_ROOT="${SAGE_ROOT:-$(cat /tmp/.sage-plugin-root 2>/dev/null)}"`. ## Quality Scale @@ -30,7 +30,7 @@ All commands accept `--json` for machine-readable output. `` is the ` ``` This is live pedagogical work, not bookkeeping. The engine is the source of truth for review history. diff --git a/references/ref-subagents.md b/skills/sage/references/ref-subagents.md similarity index 78% rename from references/ref-subagents.md rename to skills/sage/references/ref-subagents.md index b74b2e3..b3483c7 100644 --- a/references/ref-subagents.md +++ b/skills/sage/references/ref-subagents.md @@ -1,23 +1,48 @@ # Subagent Reference -You delegate to several subagents via the Task tool. Each agent has its own spec defining its behavior and boundaries — you only need to know when and how to call them. +You delegate to several **Clerks**. Each has its own spec defining its behavior and boundaries — you only need to know when and how to call them. How a subagent gets spawned is the Host's business; name the Clerk and let the Host bind it. + +**Every delegation begins with the Clerk's spec pointer, then the operation payload:** + +``` +Read $SAGE_ROOT/agents/.md in full and follow it exactly — that file +is your complete specification. Do not act before reading it. + +Operation: +... +``` + +The pointer is mandatory. On Hosts that pre-load a registered agent's spec it is +harmless redundancy; on Hosts that spawn a generic subagent it is the only thing +that tells the Clerk what it is. The `Call Pattern` column below documents the +payload that follows the pointer. + +**Always lead with the Clerk name, never the operation** — two different Clerks +define an `audit` operation. + +**If your Host has no subagent facility:** read `$SAGE_ROOT/agents/.md` and +perform the operation inline. Be aware this costs context, and that running +`verification-gate` inline makes it self-verification rather than an independent check. **What you still own:** All pedagogical decisions, live SRS card grading during reviews, deciding artifact content (you provide session notes, agents handle formatting/writing), and reading artifact files mid-session when needed. **Path resolution:** Always pass absolute paths to subagents. Use `topic_path` from the session router output — it resolves to the project's `learning/` directory. For agents that need the project root (reference-clerk), drop the trailing `learning/` segment. Never construct paths from the slug — the cwd may already be inside the project, causing path doubling (e.g., `writing-testable-code/writing-testable-code/learning/`). -**SRS engine path:** `$SAGE_ROOT/tools/srs/srs_engine.py` — used for live grading during reviews. See `references/ref-srs.md` for full command reference. +**SRS engine path:** `$SAGE_ROOT/tools/srs/srs_engine.py` — used for live grading during reviews. See `ref-srs.md` (this directory, `$SAGE_ROOT/skills/sage/references/`) for full command reference. | Agent | Operation | When | Call Pattern | | ------------------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | artifact-clerk | `brief` | Session start (resume) | `Operation: brief\nPath: /\nProject: ` | | artifact-clerk | `checkpoint` | Session end | `Operation: checkpoint\nPath: /\nProject: \n\n[session notes]` | +| artifact-clerk | `coach-reflect` | After checkpoint, when CE-#/CP-# entries were created, updated, or resolved this session (see `ref-session-end.md`) | `Operation: coach-reflect\nPath: /` | +| artifact-clerk | `patch-metrics` | Session end, after the wrapup script returns a duration | `Operation: patch-metrics\nPath: /\nDuration: ` | | assessment-agent | `select-and-prepare` | Session start (warm-up), post-material checks | `Operation: select-and-prepare\nPath: /\n\nSession context: [...]\nCount: 3\nMin mastery: developing` | | assessment-agent | `generate` | After covering new material | `Operation: generate\nPath: /\n\nTarget:\n- Concept: [...]\n- Difficulty: [1-5]\n- Question type: [free_recall \| conceptual \| application \| analysis \| transfer \| reverse]` | | assessment-agent | `evaluate` | After learner answers assessment | `Operation: evaluate\nPath: /\n\nQuestion ID: q-N\nQuestion text: [...]\nExpected answer: [...]\nLearner response: [...]\nSession: [N]` | | verification-gate | `verify-claims` | Session start (batch) + topic-section gate at each topic transition + message-counter fallback (5+ messages without a gate) + ad-hoc fallback for unplanned claims | `Operation: verify-claims\nTopic: [...]\n\nClaims:\n1. [...]` | | verification-gate | `verify-code` | Before presenting code examples | `Operation: verify-code\nLanguage: [...]\nExpected behavior: [...]\n\nCode:\n[...]` | | verification-gate | `verify-cards` | Before checkpoint (new cards only) | `Operation: verify-cards\nTopic: [...]\n\nCards:\n[card definitions]` | +| verification-gate | `verify-demo` | After demo-generator produces a demo, before showing it to the learner (see `ref-plateau.md`) | `Operation: verify-demo\nConcept: \nMisconception: M[N] — [desc]\n\nDemo:\n[demo html or path]` | | reference-clerk | `generate` | Learner requests, concept deeply explored, or after misconception | `Operation: generate\nPath: /\nConcept: \nContext: [...]\n\nSource material:\n[...]` | | reference-clerk | `update` | Corrections or additions to existing ref doc | `Operation: update\nPath: /\nConcept: \nUpdates:\n- [...]` | | reference-clerk | `audit` | Check coverage gaps | `Operation: audit\nPath: /` | diff --git a/references/ref-verification.md b/skills/sage/references/ref-verification.md similarity index 88% rename from references/ref-verification.md rename to skills/sage/references/ref-verification.md index 818302d..9f8dcbd 100644 --- a/references/ref-verification.md +++ b/skills/sage/references/ref-verification.md @@ -16,14 +16,30 @@ Teaching wrong information is worse than teaching nothing. Factual accuracy is a Before starting each new topic section (e.g., moving from "testing pyramid overview" to "dependency injection"), list ALL factual claims you plan to make during that section and batch-verify them: +Delegate to `verification-gate`: + ``` -Task(subagent_type="verification-gate", prompt="Operation: verify-claims\nTopic: [topic]\n\nClaims:\n1. [claim you plan to teach or guide the learner toward]\n2. [API behavior / syntax / definition]\n...") +Read $SAGE_ROOT/agents/verification-gate.md in full and follow it exactly — that +file is your complete specification. Do not act before reading it. + +Operation: verify-claims +Topic: [topic] + +Claims: +1. [claim you plan to teach or guide the learner toward] +2. [API behavior / syntax / definition] +... ``` -If you plan to show a code example, verify it too: +If you plan to show a code example, verify it too — same spec pointer, then: ``` -Task(subagent_type="verification-gate", prompt="Operation: verify-code\nLanguage: [lang]\nExpected behavior: [what it should do]\n\nCode:\n```[lang]\n[code]\n```") +Operation: verify-code +Language: [lang] +Expected behavior: [what it should do] + +Code: +[code] ``` **What counts as a "topic section":** Any shift to a new concept, sub-topic, or exercise that wasn't covered in the previous verification batch. Consult `plan.md` — each concept listed in the current milestone is a topic section boundary. When in doubt, verify. The cost of an extra gate call is far lower than teaching wrong information. A pre-session or pre-plan verification batch does NOT exempt you from topic-section gates — each concept transition gets its own gate call. @@ -40,10 +56,7 @@ Verification applies at these points (all use the same protocol above): 2. **Pre-plan batch (fresh start):** Before finalizing the plan, extract every factual claim from the metalearning map and skill tree and verify them. Do NOT present an unverified plan. 3. **Per-concept gate (during teaching):** Each time you advance to a new concept, run a new verification batch for that concept's claims. A pre-session or pre-plan batch does not exempt you. 4. **Message-counter fallback:** When you see `[VERIFICATION OVERDUE]`, stop and verify. -5. **Flashcard verification (session end):** Before persisting new flashcards, verify them: - ``` - Task(subagent_type="verification-gate", prompt="Operation: verify-cards\nTopic: [topic]\n\nCards:\n### Card 1\n**Q:** [question]\n**A:** [answer]\n**Tags:** [tags]\n...") - ``` +5. **Flashcard verification (session end):** Before persisting new flashcards, delegate to `verification-gate` with the spec pointer, then `Operation: verify-cards`, `Topic: [topic]`, and the card definitions. Apply corrections from `corrected` verdicts. For `flagged` cards, fix or drop — never persist an unverified flashcard. Wrong flashcards are actively harmful because spaced repetition will cement the error. 6. **Ad-hoc claims:** Any claim not covered by the above batches that arises mid-session gets its own gate call before presenting to the learner. 7. **Capstone artifact gate:** Before writing any capstone artifact that contains detection rules, operational instructions, or factual claims, run the verification gate on those claims. Translating principles into detection heuristics creates new claims — even if the underlying principle was already verified in a reference doc. diff --git a/tests/test_checkpoint_guard.py b/tests/test_checkpoint_guard.py index c4d9159..b546672 100644 --- a/tests/test_checkpoint_guard.py +++ b/tests/test_checkpoint_guard.py @@ -19,7 +19,7 @@ class TestCheckpointGuard(unittest.TestCase): def setUp(self): self.session_id = f"test-{uuid.uuid4().hex[:8]}" - self.cards_flag = f"/tmp/claude-cards-verified-{self.session_id}" + self.cards_flag = f"/tmp/sage-cards-verified-{self.session_id}" def tearDown(self): for f in [self.cards_flag]: diff --git a/tests/test_enforce_cross_refs.py b/tests/test_enforce_cross_refs.py index 9ceb245..2ac5ea9 100644 --- a/tests/test_enforce_cross_refs.py +++ b/tests/test_enforce_cross_refs.py @@ -10,12 +10,8 @@ import uuid from pathlib import Path -SCRIPT = str( - Path(__file__).resolve().parent.parent - / "hooks" - / "scripts" - / "enforce-cross-refs.sh" -) +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT = str(REPO_ROOT / "hooks" / "scripts" / "enforce-cross-refs.sh") class TestEnforceCrossRefs(unittest.TestCase): @@ -36,6 +32,9 @@ def tearDown(self): def _run(self, input_json: dict) -> subprocess.CompletedProcess: env = os.environ.copy() env["SAGE_DIR"] = self.tmpdir + # Pin the plugin root too — without it the script falls back to + # /tmp/.sage-plugin-root, which exists only on a dev machine. + env["SAGE_ROOT"] = str(REPO_ROOT) return subprocess.run( ["bash", SCRIPT], input=json.dumps(input_json), diff --git a/tests/test_plugin_manifests.py b/tests/test_plugin_manifests.py new file mode 100644 index 0000000..4bfcf96 --- /dev/null +++ b/tests/test_plugin_manifests.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Coherence tests for the per-Host plugin manifests and the skill layout. + +`skills/sage/` is referenced by four independent things — the Claude manifest, +the Codex manifest, the SessionStart hook, and (via /tmp/.sage-plugin-root) the +prose bash blocks. Moving or renaming it breaks all four *silently*: the plugin +still installs, the hook still writes a path, and the first symptom is a Clerk +that cannot find tools/. + +Borrowed from ponytail, which ships one adapter test per Host. +""" + +import json +import re +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +CLAUDE_MANIFEST = REPO_ROOT / ".claude-plugin" / "plugin.json" +CODEX_MANIFEST = REPO_ROOT / ".codex-plugin" / "plugin.json" +MARKETPLACE = REPO_ROOT / ".claude-plugin" / "marketplace.json" +HOOKS_CONFIG = REPO_ROOT / "hooks" / "claude-codex-hooks.json" +SKILL_DIR = REPO_ROOT / "skills" / "sage" +AGENTS_DIR = REPO_ROOT / "agents" + +CLERKS = [ + "artifact-clerk", + "assessment-agent", + "verification-gate", + "reference-clerk", + "demo-generator", + "capstone-architect", +] + + +def load(path): + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +class TestSkillPaths(unittest.TestCase): + def test_claude_skill_paths_resolve_and_hold_a_skill(self): + for rel in load(CLAUDE_MANIFEST)["skills"]: + resolved = (REPO_ROOT / rel).resolve() + self.assertTrue(resolved.is_dir(), f"{rel} is not a directory") + self.assertTrue( + (resolved / "SKILL.md").is_file(), f"{rel} has no SKILL.md" + ) + + def test_codex_skills_field_is_a_container_of_skill_dirs(self): + # Codex takes a single path string pointing at a directory of skill + # subdirectories — not at a skill itself. + rel = load(CODEX_MANIFEST)["skills"] + self.assertIsInstance(rel, str, "Codex 'skills' must be a single path string") + container = (REPO_ROOT / rel).resolve() + self.assertTrue(container.is_dir()) + + found = [d for d in container.iterdir() if (d / "SKILL.md").is_file()] + self.assertTrue(found, f"{rel} contains no skill directories") + + def test_both_manifests_ship_the_same_skills(self): + claude = { + (REPO_ROOT / rel).resolve() for rel in load(CLAUDE_MANIFEST)["skills"] + } + container = (REPO_ROOT / load(CODEX_MANIFEST)["skills"]).resolve() + codex = {d.resolve() for d in container.iterdir() if (d / "SKILL.md").is_file()} + self.assertEqual(claude, codex) + + +class TestHooks(unittest.TestCase): + def test_both_manifests_point_at_the_one_hooks_file(self): + self.assertEqual(load(CLAUDE_MANIFEST)["hooks"], "./hooks/claude-codex-hooks.json") + self.assertEqual(load(CODEX_MANIFEST)["hooks"], "./hooks/claude-codex-hooks.json") + self.assertTrue(HOOKS_CONFIG.is_file()) + + def test_every_referenced_hook_script_exists(self): + raw = HOOKS_CONFIG.read_text(encoding="utf-8") + for script in re.findall(r"hooks/scripts/([\w-]+\.sh)", raw): + self.assertTrue( + (REPO_ROOT / "hooks" / "scripts" / script).is_file(), + f"{script} referenced by hooks config but missing", + ) + + def test_session_start_writes_the_plugin_root(self): + raw = HOOKS_CONFIG.read_text(encoding="utf-8") + self.assertIn("/tmp/.sage-plugin-root", raw) + self.assertIn("${CLAUDE_PLUGIN_ROOT}", raw) + + def test_subagent_events_are_used_not_tool_matchers(self): + hooks = load(HOOKS_CONFIG)["hooks"] + self.assertIn("SubagentStart", hooks) + self.assertIn("SubagentStop", hooks) + # PreToolUse/PostToolUse matcher "Agent" is Claude-only vocabulary. + self.assertNotIn("PreToolUse", hooks) + self.assertNotIn("PostToolUse", hooks) + + +class TestVersionMirror(unittest.TestCase): + def test_all_three_manifests_agree_on_version(self): + version = load(CLAUDE_MANIFEST)["version"] + self.assertEqual(load(CODEX_MANIFEST)["version"], version) + self.assertEqual(load(MARKETPLACE)["plugins"][0]["version"], version) + + +class TestInvocationPolicy(unittest.TestCase): + def test_user_invoked_is_declared_in_both_harnesses(self): + skill = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8") + self.assertIn("disable-model-invocation: true", skill) + + openai = (SKILL_DIR / "agents" / "openai.yaml").read_text(encoding="utf-8") + self.assertIn("allow_implicit_invocation: false", openai) + + +class TestHostNeutralProse(unittest.TestCase): + """The prompt layer must not name any one Host's API.""" + + def _prose_files(self): + return list(SKILL_DIR.rglob("*.md")) + list(AGENTS_DIR.glob("*.md")) + + def test_no_task_tool_vocabulary(self): + for path in self._prose_files(): + text = path.read_text(encoding="utf-8") + self.assertNotIn("subagent_type", text, f"{path.name} names subagent_type") + self.assertNotIn("Task tool", text, f"{path.name} names the Task tool") + + def test_every_clerk_spec_exists(self): + for clerk in CLERKS: + self.assertTrue((AGENTS_DIR / f"{clerk}.md").is_file()) + + def test_delegation_pointers_name_a_real_spec(self): + pattern = re.compile(r"\$SAGE_ROOT/agents/([\w-]+)\.md") + seen = set() + for path in self._prose_files(): + for name in pattern.findall(path.read_text(encoding="utf-8")): + seen.add(name) + self.assertIn(name, CLERKS, f"{path.name} points at unknown Clerk {name}") + self.assertTrue(seen, "no delegation spec pointers found in the prompt layer") + + def test_bootstrap_line_prefers_an_exported_root(self): + # The bare `SAGE_ROOT=$(cat ...)` form has no escape hatch for the + # shared-/tmp-slot clobber. See docs/KNOWN-ISSUES.md. + for path in self._prose_files(): + text = path.read_text(encoding="utf-8") + self.assertNotIn( + "SAGE_ROOT=$(cat /tmp/.sage-plugin-root)", + text, + f"{path.name} uses the bare bootstrap form", + ) + + +class TestRouterMessagesAreHostNeutral(unittest.TestCase): + """unknown_verb is the primary way the grammar is taught on a Host with no + slash commands, so its messages must not name one. See docs/adr/0008.""" + + def _messages(self): + import session_router # noqa: E402 — path wired by conftest + + return [ + session_router._unknown_verb("react", "hooks", "/sage-root")["message"], + session_router._unknown_verb("continue", "", "/sage-root")["message"], + session_router.route("/sage-root", "")["message"], + ] + + def test_grammar_messages_carry_no_slash_command(self): + for message in self._messages(): + self.assertNotIn("/sage", message, f"host syntax leaked into: {message}") + + def test_grammar_messages_still_name_both_verbs(self): + joined = " ".join(self._messages()) + self.assertIn("learn", joined) + self.assertIn("archive", joined) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_reset_verification.py b/tests/test_reset_verification.py index f1d1739..d21c586 100644 --- a/tests/test_reset_verification.py +++ b/tests/test_reset_verification.py @@ -21,9 +21,9 @@ class TestResetVerification(unittest.TestCase): def setUp(self): self.session_id = f"test-{uuid.uuid4().hex[:8]}" - self.counter_file = f"/tmp/claude-verif-counter-{self.session_id}" - self.warned_file = f"/tmp/claude-verif-warned-{self.session_id}" - self.cards_flag = f"/tmp/claude-cards-verified-{self.session_id}" + self.counter_file = f"/tmp/sage-verif-counter-{self.session_id}" + self.warned_file = f"/tmp/sage-verif-warned-{self.session_id}" + self.cards_flag = f"/tmp/sage-cards-verified-{self.session_id}" def tearDown(self): for f in [self.counter_file, self.warned_file, self.cards_flag]: diff --git a/tests/test_verification_counter.py b/tests/test_verification_counter.py index 3e54848..78d9358 100644 --- a/tests/test_verification_counter.py +++ b/tests/test_verification_counter.py @@ -21,8 +21,8 @@ class TestVerificationCounter(unittest.TestCase): def setUp(self): self.session_id = f"test-{uuid.uuid4().hex[:8]}" - self.counter_file = f"/tmp/claude-verif-counter-{self.session_id}" - self.warned_file = f"/tmp/claude-verif-warned-{self.session_id}" + self.counter_file = f"/tmp/sage-verif-counter-{self.session_id}" + self.warned_file = f"/tmp/sage-verif-warned-{self.session_id}" def tearDown(self): for f in [self.counter_file, self.warned_file]: diff --git a/tools/cross_refs_check.py b/tools/cross_refs_check.py new file mode 100644 index 0000000..7b8cdf7 --- /dev/null +++ b/tools/cross_refs_check.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Cross-reference staleness check — the one Sage invariant that blocks. + +If a knowledge map was modified this sitting and holds any concept at +Developing or higher, the cross-refs registry must have been updated too. + +This lives in a tool rather than a hook because it is the only enforcing +check Sage has, and a Host without hooks would otherwise lose it silently. +The Claude/Codex `Stop` hook calls this as its automatic trigger; the +session-ending tool calls it directly so the guarantee holds everywhere. +See docs/adr/0006-hooks-are-advisory-invariants-live-in-tools.md. + +Inputs are mtimes under the Learning Root — no transcript, no session id, +no Host API. + +Usage: + python3 cross_refs_check.py + +Exit 0 and print nothing when the invariant holds; exit 0 and print the +reason when it does not (the caller decides whether to block). +""" + +import os +import sys +import time + +# ponytail: mtime window, not a change journal. A session longer than this +# with no kmap write looks unmodified. Swap for a manifest if that bites. +SITTING_THRESHOLD_SECONDS = 1800 + +REASON = ( + "Knowledge map(s) were modified this session but cross-refs/ was not " + "updated. Per the Cross-Reference Protocol: upsert any concept that " + "reached Developing or higher into cross-refs/.md before " + "ending the session." +) + +PROMOTED_MARKERS = ("| developing |", "| solid |", "| mastered |") + + +def _recently_modified(path, now): + try: + return (now - os.path.getmtime(path)) < SITTING_THRESHOLD_SECONDS + except OSError: + return False + + +def _has_promoted_concept(path): + try: + with open(path, encoding="utf-8") as fh: + lowered = fh.read().lower() + except OSError: + return False + return any(marker in lowered for marker in PROMOTED_MARKERS) + + +def check(learning_root, now=None): + """Return a reason string when cross-refs are stale, else None.""" + if not learning_root or not os.path.isdir(learning_root): + return None + + now = time.time() if now is None else now + + touched = False + for dirpath, _dirnames, filenames in os.walk(learning_root): + if "knowledge-map.md" not in filenames: + continue + kmap = os.path.join(dirpath, "knowledge-map.md") + if _recently_modified(kmap, now) and _has_promoted_concept(kmap): + touched = True + break + + if not touched: + return None + + cross_refs = os.path.join(learning_root, "cross-refs") + if os.path.isdir(cross_refs): + for dirpath, _dirnames, filenames in os.walk(cross_refs): + for name in filenames: + if name.endswith(".md") and _recently_modified( + os.path.join(dirpath, name), now + ): + return None + + return REASON + + +def main(argv): + learning_root = argv[1] if len(argv) > 1 else "" + reason = check(learning_root) + if reason: + print(reason) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tools/session_router.py b/tools/session_router.py index 5a30bd9..d005eec 100644 --- a/tools/session_router.py +++ b/tools/session_router.py @@ -108,19 +108,22 @@ def suggest_slug(slug, learning_root): def _unknown_verb(verb, topic, sage_root): """Build a helpful error for an unrecognized leading verb.""" + # Messages carry no host syntax (no `/sage` prefix): on a Host without + # slash commands this branch is the primary way the grammar is taught, + # and it must not name a command that does not exist there. See docs/adr/0008. if verb in LEGACY_RESUME_KEYWORDS: # Without this branch the generic message below reads - # "`/sage learn continue` to learn it" — it would interpolate the + # "`learn continue` to learn it" — it would interpolate the # keyword as if it were a topic name. - hint = f"/sage learn {topic}".strip() + hint = f"learn {topic}".strip() message = f"'{verb}' is no longer a command. Did you mean `{hint}`?" suggestion = "learn" else: - # Most likely a legacy bare-topic invocation like `/sage react hooks`. + # Most likely a legacy bare-topic invocation like `react hooks`. full = f"{verb} {topic}".strip() message = ( f"Unknown verb '{verb}'. Commands now require a verb: " - f"`/sage learn {full}` to learn it, or `/sage archive ` to archive it." + f"`learn {full}` to learn it, or `archive ` to archive it." ) suggestion = None return { @@ -142,7 +145,7 @@ def route(sage_root, raw_args): "mode": "unknown_verb", "verb": "", "suggestion": None, - "message": "Usage: `/sage learn ` or `/sage archive `.", + "message": "Usage: `learn ` or `archive `.", "sage_root": sage_root, } diff --git a/tools/session_wrapup.py b/tools/session_wrapup.py index 07f509f..c2c0583 100644 --- a/tools/session_wrapup.py +++ b/tools/session_wrapup.py @@ -74,20 +74,43 @@ def run(sage_root, topic_path, session_id=""): insights_ok = True # 3. Session duration (current-sitting wall time from the transcript) - duration_cmd = [ - "python3", os.path.join(sage_root, "tools", "session_duration.py"), - ] - if session_id: - duration_cmd.append(session_id) - - duration_ok, duration_out = run_script(duration_cmd, "session_duration") - duration = duration_out if duration_ok and duration_out else None - if not duration_ok: - # Non-blocking: duration is best-effort. The clerk falls back to asking the learner. - errors.append(f"session_duration: {duration_out}") + # + # Only attempt this when a session id identifies the transcript. With no + # id, session_duration.py falls back to "newest .jsonl under the cwd-derived + # directory" — which off-Claude returns an *unrelated* session's wall time + # with exit 0, indistinguishable from a correct answer once it reaches the + # journal. That guess path exists for manual terminal use, not for a Host + # that never sets CLAUDE_CODE_SESSION_ID. See docs/adr/0004. + resolved_id = session_id or os.environ.get("CLAUDE_CODE_SESSION_ID", "") + if resolved_id: + duration_cmd = [ + "python3", os.path.join(sage_root, "tools", "session_duration.py"), + resolved_id, + ] + duration_ok, duration_out = run_script(duration_cmd, "session_duration") + duration = duration_out if duration_ok and duration_out else None + if not duration_ok: + # Non-blocking: duration is best-effort. The clerk falls back to asking the learner. + errors.append(f"session_duration: {duration_out}") + else: + # No transcript we can trust — the clerk asks the learner for wall time. + duration = None + + # 4. Cross-refs invariant. Enforced here rather than only in the Stop hook + # so it holds on Hosts with no hook system. See docs/adr/0006. + cross_refs_stale = None + try: + sys.path.insert(0, os.path.join(sage_root, "tools")) + from config import get_learning_root + from cross_refs_check import check as check_cross_refs + + cross_refs_stale = check_cross_refs(get_learning_root()) + except Exception as e: + errors.append(f"cross_refs_check: {e}") return { "duration": duration, + "cross_refs_stale": cross_refs_stale, "coach_metrics_ok": coach_metrics_ok, "coach_metrics_flags": coach_metrics_flags, "insights_ok": insights_ok,