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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sage-instructor",
"version": "1.2.1",
"version": "1.5.0",
"description": "Adaptive programming instructor — structured courses with discovery-first teaching, AskUserQuestion interactions, progress tracking, and pluggable curricula. Powered by the Three Axes Framework.",
"author": {
"name": "Lux Solari",
Expand Down
127 changes: 127 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,132 @@
# Changelog

## [1.5.0] — 2026-07-02

### Added
- **`skills/sage-instructor/curricula/rust-cli.md`.** Second bundled
curriculum — Rust CLI fundamentals building toward Ferrogrep, a
production search tool. Deliberately declares axes sharply different
from `python-basics` (`mastery: medium, consequence: high, intent:
output` vs. `low, low, growth`) so `/sage-switch` has an actual
posture change to get right or wrong, not just a second copy of the
same shape.
- **`tests/scenarios/08-multi-track-switching.md`.** `/sage-tracks` and
`/sage-switch` had only ever existed alongside a single real curriculum —
never exercised with a second one actually present (see #2's "out of
scope for this pass" list). Checks progress isolation, hint-streak
isolation, and axis-posture isolation between two concurrently-tracked
courses, plus `/tracks` status accuracy across both.

### Fixed
- **Found while drafting scenario 08, before the live run:** `SKILL.md`'s
Track Management section gave `/tracks` and `/switch` one line each —
thin enough that status vocabulary (`active` vs. `started` vs. `not
started`) and switch semantics (resume an existing entry vs. initialize a
fresh one; never re-run Track Setup onboarding on switch) were only
inferable by chaining together Progress Rule 9 and Track Setup's Round-0
gating language, not stated directly. Added an explicit paragraph
spelling out both.

### Verified
- **`08-multi-track-switching`**: 8/8 PASS on the first live run. Progress,
hint-streak, and axis-override isolation between `python-basics` and
`rust-cli` all held under a live switch-and-back; teaching posture during
the rust-cli exercise was confirmed to match its own declared axes
(compressed concept+bridge, explicit no-black-boxes framing on the borrow
checker, efficient Steps 1-4), not leaked from python-basics.
- Toolchain limitation, disclosed not hidden: the run's machine had no
Rust toolchain installed, so the one rust-cli exercise was verified by
manual trace instead of a real `rustc` run — same class of gap Step
6b.3 already carves out as not counting against the exercise, but
graders of that specific transcript should know it's compiler-unverified.
- Soft finding, not acted on: rust-cli's `verify` command's `/tmp/`
temp-file path assumes a Unix-like shell. Left as-is — it matches the
existing convention in `TEMPLATE.md`'s own C++ example and the curriculum
scenario 06 generated live, and Sage's actual execution environment
(Git Bash) already provides a working `/tmp`.
- Pre-release checklist (`tests/README.md`/`CONTRIBUTING.md`) updated from
"all seven" to "all eight" scenarios.

## [1.4.0] — 2026-07-02

### Added
- **`tests/scenarios/07-axis-recalibration-accept.md`.** Scenario 04 only
ever exercised declining the recalibration offer; the **accept** branch
(write to `axis_overrides`, confirm the change) had never been live-tested.
This scenario triggers `low_hint_streak >= 3` (the "Mastery is probably too
low" signal, complementing 04's `high_hint_streak` coverage), accepts the
bump, then drives a second streak past threshold to confirm a later offer
is measured against the newly-applied level — a follow-up named in #2's
"out of scope for this pass" list.

### Fixed
- **Found while drafting scenario 07, before the live run:** the accept
branch of Axis Re-Calibration said to write `axis_overrides` and confirm
the change, but — unlike the decline branch — never said whether the
triggering streak resets. Left unresolved, an unreset streak could
immediately re-fire the same offer against the level Sage had just
applied. Added a rule: accepting resets the streak too, same as declining;
only a fresh streak against the new level earns a fresh offer.
- **Found by the scenario 07 live run:** the phase-transition question's 5th
option (the recalibration offer) didn't say what happens to the other 4
options when it's picked — is it a standalone choice, or does it also mean
"start Phase N+1"? The live run inferred the latter (matching the
scenario's own script), but SKILL.md didn't say so. Added a rule: picking
the 5th option applies the recalibration and proceeds as if "Start Phase
N+1" had been picked; the standard menu isn't asked again afterward.

### Verified
- **`07-axis-recalibration-accept`**: 9/9 PASS on the first live run. Real
Python exercise scripts written and executed for real via `python`
(not `python3` — the machine's `python3` is a Windows Store stub, exactly
the toolchain-vs-learner-bug gotcha scenario 03 guards against); progress
file diffed at every checkpoint; `curricula/python-basics.md` on disk
confirmed unchanged (`git status`/`git diff` empty) throughout, i.e. the
override never leaked into the curriculum source.
- Soft finding (not acted on this pass): Progress Rule 10's topic-key
derivation example ("Variables, dynamic typing, truthiness" → `variables`)
is a 3-term bullet reduced to its first noun; Phase 1's "Lists, dicts,
sets, tuples" bullet is a 4-term coordinate list where none of the
exercises isolate to just one of those structures, and it's unclear
whether Rule 10 wants `lists` (strict first-term) or a compound key.
Same category of ambiguity as 1.3.0's `consequence`-derivation finding,
but orthogonal to axis recalibration — left for a future scenario/issue
rather than guessed at here.
- Pre-release checklist (`tests/README.md`/`CONTRIBUTING.md`) updated from
"all six" to "all seven" scenarios.

## [1.3.0] — 2026-07-02

### Added
- **`tests/scenarios/06-custom-track-creation.md`.** Every prior scenario
(01-05) and the 1.2.1 real-install smoke test picked the existing bundled
`python-basics` track at Track Setup Round 0. This scenario instead picks
"Build a custom track" and scripts the full Round 1-4 interview, closing
the last major untested path from a fresh install (see #2).

### Fixed
- **Found by the scenario 06 live run:** the Track Setup interview (Rounds
1-4) never explicitly directs Sage to derive the curriculum's
`consequence` axis field — only `mastery` (Round 3) and `intent` (Round 4)
have a stated mapping. The live run got `consequence` right anyway by
inferring it from the described project's stakes, but that was judgment,
not a followed instruction — a spec gap that happened not to bite this
time. Added an explicit rule: infer `consequence` from Round 2's answer
(no project/personal project → `low`; shared or stakes-bearing → `medium`
or `high`), rather than leaving it unspecified.

### Verified
- **`06-custom-track-creation`**: 8/8 PASS on the first live run — the first
scenario in this harness's history to pass clean without needing a fix
first. Confirmed via a real generated curriculum
(`curricula/rust-cli-grep.md`) with a `verify` command
(`rustc {file} -o /tmp/sage_out && /tmp/sage_out`) that was smoke-tested
for real, not just read as plausible-looking text; confirm-before-save was
checked by verifying the file didn't exist on disk between the curriculum
being displayed and the learner confirming it.
- Pre-release checklist (`tests/README.md`/`CONTRIBUTING.md`) updated from
"all five" to "all six" scenarios.

## [1.2.1] — 2026-07-02

### Added
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Open an issue describing: what you expected, what happened, and your Claude Code
`python3 tests/test_check_progress_schema.py` (also run in CI) covers the
deterministic Tier 1 checks and needs no agent.
5. Bumping the plugin version? Run `tests/README.md`'s full pre-release
checklist (all five Tier 2 scenarios) first.
checklist (all eight Tier 2 scenarios) first.
6. Submit a PR with a clear description

## Code of Conduct
Expand Down
8 changes: 6 additions & 2 deletions skills/sage-instructor/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ If applicable, ask: "Want to start one of these, or build something custom?" Opt
**Round 3** — "How much do you know already?" Options: From scratch, Basics but rusty, Intermediate, Know a related language
**Round 4** — "What's the priority?" Options: Pure learning (Growth), Build while learning (Balanced), Get productive fast (Output)

Rounds 3 and 4 map directly to the curriculum's `mastery` and `intent` axis fields (Round 4's options are literally labeled with the axis values). `consequence` has no dedicated round — infer it from Round 2's answer instead: no stated project, or a personal/learning-only one → `low`; a project the learner describes as shared, user-facing, or otherwise stakes-bearing → `medium` or `high`, using judgment on what was actually described, not a fixed default.

Generate curriculum from `TEMPLATE.md`, confirm with learner, save to `curricula/<track>.md`.

---
Expand Down Expand Up @@ -138,6 +140,8 @@ Generate curriculum from `TEMPLATE.md`, confirm with learner, save to `curricula
| `/switch TRACK` | Save current, load new. |
| `/new-track` | Run Track Setup interview via AskUserQuestion. |

`/tracks` status is one of three: **Active** (this track's `active_track`), **Started** (has a `tracks.<name>` entry but isn't active), **Not started** (no entry yet). `/switch TRACK` sets `active_track` to `TRACK`; if `tracks.<TRACK>` already exists, resume it exactly as-is (current phase, exercise, streaks, `axis_overrides` — untouched by whatever happened on the track just switched away from); if it doesn't exist yet, initialize a fresh entry at Phase 0 (same as a never-started track). Either way, switching is not onboarding — never re-run Track Setup just because `/switch` was called, even to a track with no entry yet.

### Meta
| Command | Behavior |
|---|---|
Expand Down Expand Up @@ -297,15 +301,15 @@ options:
- "Take a challenge that combines this phase's concepts"
- "Save checkpoint and stop for now"
```
Before asking, check the recalibration signal below — if it fired, add a 5th option surfacing it. The streak is exercise-scoped, not phase-scoped, so don't frame it as "this phase" (it may span the phase boundary) — say "the last few exercises" instead: "The last few exercises went by without a hint — bump Mastery to medium?" or "The last few exercises were a grind — dial the pace back?"
Before asking, check the recalibration signal below — if it fired, add a 5th option surfacing it. The streak is exercise-scoped, not phase-scoped, so don't frame it as "this phase" (it may span the phase boundary) — say "the last few exercises" instead: "The last few exercises went by without a hint — bump Mastery to medium?" or "The last few exercises were a grind — dial the pace back?" Picking the 5th option means both things at once — apply the recalibration (see Axis Re-Calibration) and proceed as if "Start Phase N+1" had been picked. Don't ask the four standard options again afterward.

### Axis Re-Calibration

Axis levels are declared once at track creation and go stale. `low_hint_streak` / `high_hint_streak` (see Progress File Format) are the signal for when they no longer match reality. Both signals are only checked at phase-transition points (see the "Before asking, check the recalibration signal" step in Phase transitions above) — a streak crossing its threshold mid-phase doesn't surface an offer at that exercise's own "what next?" menu, only at the next phase transition, whether or not that transition happens to land in a different phase than where the streak started:

- **`low_hint_streak >= 3`** (three exercises in a row, zero hints) → the declared Mastery is probably too low. Offer a bump at the next phase transition.
- **`high_hint_streak >= 2`** (two exercises in a row needing 3+ hints) → the declared Mastery or pace is probably too high. Offer to dial back — either Mastery down a level, or just slower pacing within the same level — at the next phase transition.
- If the learner accepts, write the new level to `axis_overrides` in the progress file (never overwrite the curriculum file itself — the override layers on top, see Progress Rules). Confirm what changed in one sentence.
- If the learner accepts, write the new level to `axis_overrides` in the progress file (never overwrite the curriculum file itself — the override layers on top, see Progress Rules). Confirm what changed in one sentence. Then reset the streak that triggered the offer to 0 — the same streak count shouldn't immediately re-trigger a second offer against the level Sage just applied; a fresh streak accumulating from here, measured against the new level, is a new signal and earns its own fresh offer.
- If the learner declines, reset the streak that triggered the offer to 0. Otherwise the same offer resurfaces at every subsequent phase transition until it's acted on — a fresh streak accumulating from here is a new signal and earns a fresh offer, but a stale one shouldn't nag.
- This only ever surfaces as an offer, never a silent change. The learner decides.

Expand Down
106 changes: 106 additions & 0 deletions skills/sage-instructor/curricula/rust-cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
---
track: rust-cli
title: "Rust CLI Tools"
destination: "Ferrogrep — a production Rust CLI search tool the team relies on"
description: >
Rust fluency from ownership through idiomatic error handling and
iterator-based collection processing, building toward Ferrogrep, a
real grep-style command-line search tool that ships to the rest of
the team.

# Three Axes calibration (from DEV_PHILOSOPHY)
mastery: medium # low | medium | high — learner's current level in this domain
consequence: high # low | medium | high — what breaks if something goes wrong
intent: output # growth | balanced | output — what's the priority

# Bridge languages — Sage will connect new concepts to these
bridge_from:
- Python
- Java
- JavaScript/TypeScript

# Prerequisites — what should the learner know before starting
prerequisites:
- "Programming fundamentals in any language (variables, control flow, functions)"
- "Command-line comfort — running a compiled binary, reading a stack trace/panic"
- "Rust toolchain installed (rustc on PATH)"

# Language/tools this track teaches
teaches:
- Rust

# Every exercise is a standalone .rs file compiled and run directly —
# a clean exit (0) means the asserts passed. No cargo project scaffolding
# needed until Phase 2.
verify: "rustc --edition 2021 {file} -o /tmp/sage_out && /tmp/sage_out"
---

## Phase 0: Ownership & Core Syntax
**Goal:** Read and write basic Rust confidently — variables, control flow, and
enough ownership/borrowing to stop fighting the compiler on straightforward code.
**Pace:** Medium — control flow and functions transfer almost directly from
Python/Java; ownership and `Result`/`Option` instead of exceptions are the
genuinely new mental models, so don't compress those two.

### Topics
- Variables, mutability (`let` vs `let mut`), no implicit nulls
- Ownership and borrowing — move semantics, `&`/`&mut` references, why the
borrow checker rejects code that looks fine in a GC language
- `Result<T, E>` / `Option<T>` and the `?` operator — no exceptions
- Pattern matching: `match`, `if let`
- `&str` vs `String` — borrowed vs owned text, the trap that bites first

### Exercises
- [ ] `P0-word-count` — Given a string, return a `HashMap<String, u32>` of word→count, printed in sorted order. Asserts against a known sentence.
- [ ] `P0-file-reader` — Read a file's contents via `std::fs::read_to_string`, propagating errors with `?` instead of `.unwrap()`. Asserts cover both an existing file and a missing one (the missing case must return `Err`, not panic).
- [ ] `P0-arg-parser` — Parse `std::env::args()` by hand (no crate) into a struct with a pattern and optional flags. Asserts against several fixed argument-vector inputs, including a malformed one that must produce a clear error.

### Ferrogrep Connection
This phase is Ferrogrep's skeleton: reading input safely (no panics on bad input) and parsing the flags the real tool will accept.

---

## Phase 1: Collections, Iterators & Errors
**Goal:** Use Rust's iterator combinators and a real custom error type
idiomatically, instead of manual loops and `.unwrap()`.
**Pace:** Medium — `Vec`/`HashMap` map from Python's list/dict, but chained
iterator adapters (`map`/`filter`/`collect`) and structured errors are new
idiom, not new concepts; compress the "why collections exist" part.

### Topics
- `Vec`, `HashMap`, `HashSet` — when to use which
- Iterator adapters: `map`, `filter`, `collect`, `enumerate`
- Custom error types: an `enum` implementing `std::error::Error`, `From` for `?`-based conversion
- Why "no black boxes in critical paths" means no swallowed errors here — every fallible path returns `Result`, never a silent default

### Exercises
- [ ] `P1-line-filter` — Filter a file's lines by substring using iterator combinators (no manual `for` loop), returning `Result<Vec<String>, Error>`.
- [ ] `P1-custom-error` — Define an `Error` enum (`Io`, `Pattern`, ...) and wire `?` to convert from `std::io::Error` via `impl From<std::io::Error> for Error`. Asserts confirm both variants are reachable and the conversion compiles without `.unwrap()`.
- [ ] `P1-pattern-match` — Implement a small literal-plus-`*`-wildcard matcher as a pure function (`fn matches(pattern: &str, line: &str) -> bool`), with asserts covering exact match, wildcard match, and non-match. This is Ferrogrep's matching engine in miniature.

### Ferrogrep Connection
`P1-pattern-match` and `P1-custom-error` become the tool's actual matching logic and error backbone — not a simplified stand-in, the real thing.

---

## Phase 2: Assembling the CLI
**Goal:** Wire Phase 0/1 into a real installable binary with correct exit
codes and no undefined failure states — the bar `consequence: high` demands
for something the team runs.
**Pace:** Fast — this phase is mostly composition of already-understood
pieces, so lean toward output mode: less new-concept teaching, more review
of the wiring and the exit-code contract.

### Topics
- `fn main() -> Result<(), Error>` and the process exit-code contract: grep semantics are `0` = match found, `1` = no match, `2` = error — three distinct outcomes, never conflated
- Reading from a file path vs. stdin
- Splitting a single-file prototype into modules (`mod args; mod search; mod error;`)
- Why "working" at this consequence level means correctly exiting on every path, not just the happy path — an exercise here isn't complete on a green compile alone

### Exercises
- [ ] `P2-search-core` — Wire `P0-arg-parser`, `P1-pattern-match`, and `P1-custom-error` into a `search(pattern: &str, path: &str) -> Result<Vec<String>, Error>` core function doing real file I/O. Asserts against a fixture file with known matching and non-matching lines.
- [ ] `P2-cli-wiring` — Build `main()` that parses real CLI args, calls `search`, prints matches, and returns the correct exit code for each of the three outcomes (match / no-match / error) — verified by actually running the compiled binary with different inputs and checking `$?`/exit status, not just reading the code.
- [ ] `P2-module-split` — Split the single-file prototype into `args.rs`, `search.rs`, `error.rs` modules compiled together. Asserts confirm the split binary behaves identically to the single-file version on the same fixture inputs.

### Ferrogrep Connection
After this phase, Ferrogrep is a real CLI the team can install and run — not a toy that only works when called exactly as demonstrated.
Loading
Loading