diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 5835592..2f7846a 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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", diff --git a/CHANGELOG.md b/CHANGELOG.md index 91fd348..268984e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b90fbe2..e419654 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/skills/sage-instructor/SKILL.md b/skills/sage-instructor/SKILL.md index 02c32ae..d2c2535 100644 --- a/skills/sage-instructor/SKILL.md +++ b/skills/sage-instructor/SKILL.md @@ -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/.md`. --- @@ -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.` entry but isn't active), **Not started** (no entry yet). `/switch TRACK` sets `active_track` to `TRACK`; if `tracks.` 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 | |---|---| @@ -297,7 +301,7 @@ 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 @@ -305,7 +309,7 @@ Axis levels are declared once at track creation and go stale. `low_hint_streak` - **`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. diff --git a/skills/sage-instructor/curricula/rust-cli.md b/skills/sage-instructor/curricula/rust-cli.md new file mode 100644 index 0000000..644f7c8 --- /dev/null +++ b/skills/sage-instructor/curricula/rust-cli.md @@ -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` / `Option` 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` 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, Error>`. +- [ ] `P1-custom-error` — Define an `Error` enum (`Io`, `Pattern`, ...) and wire `?` to convert from `std::io::Error` via `impl From 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, 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. diff --git a/tests/README.md b/tests/README.md index 556ac72..2349dbb 100644 --- a/tests/README.md +++ b/tests/README.md @@ -90,7 +90,7 @@ per-commit CI check. ### Pre-release checklist Before bumping the plugin version (`.claude-plugin/plugin.json` + -`CHANGELOG.md`), run **all five** scenarios in `scenarios/` — not just the +`CHANGELOG.md`), run **all eight** scenarios in `scenarios/` — not just the ones nearest whatever you changed. Record the result (pass/fail per assertion) in the version's CHANGELOG entry the way 1.2.0 and the harness's own rollout did, so "we ran the harness" is checkable later instead of diff --git a/tests/scenarios/06-custom-track-creation.md b/tests/scenarios/06-custom-track-creation.md new file mode 100644 index 0000000..67cf93c --- /dev/null +++ b/tests/scenarios/06-custom-track-creation.md @@ -0,0 +1,71 @@ +# Scenario 06 — Custom-track creation (Track Setup Rounds 1-4) + +## Regression target + +Every prior scenario (01-05) and the 1.2.1 real-install smoke test picked the +**existing** bundled track at Track Setup Round 0. The custom-track path — +Round 0's "Build a custom track" branch, the four-round interview, curriculum +generation from `TEMPLATE.md`, confirm-before-save, and the resulting +curriculum actually landing in `curricula/.md` — is fully specified in +`SKILL.md` (Track Setup, Rounds 1-4) but has never been exercised end-to-end. +This is the last major untested path from a fresh install. + +## Setup + +Empty scratch directory. No `.sage-profile.md`, no `.sage-progress.json`. +`skills/sage-instructor/curricula/` contains `TEMPLATE.md` and +`python-basics.md` exactly as shipped — don't modify them for this scenario. + +## Script + +1. Learner triggers Sage via natural language (not a slash command): "teach + me Rust, let's build something." +2. Profile Setup — answer each round: + - Identity: "Marco, mobile developer" + - Bridge languages: Java, Kotlin + - Experience: "Junior (1-3yr)" + - Learning style: "Hands-on first" + - Tone: "Encouraging and patient" + Confirm the generated profile looks right. +3. Track Setup Round 0 fires (curricula/ has `python-basics.md` beyond just + `TEMPLATE.md`, no `active_track` yet). Learner explicitly picks **"Build a + custom track"** — not "Python Foundations." +4. Round 1 ("What do you want to learn?"): "Rust." +5. Round 2 ("Is there a project this feeds into?"): Yes — "A small CLI tool + that recursively greps through a directory, like a mini ripgrep clone." +6. Round 3 ("How much do you know already?"): "From scratch." +7. Round 4 ("What's the priority?"): "Build while learning (Balanced)." +8. Sage generates the curriculum and presents it for confirmation before + saving. +9. Learner confirms. +10. Learner runs `/sage-lesson`. + +## Assertions + +- `[mechanical]` A new file exists at `curricula/.md` (some + Rust-related track slug) with a YAML header containing every field + `TEMPLATE.md` marks required: `track`, `title`, `destination`, `mastery`, + `consequence`, `intent`, `bridge_from`, `teaches`, `verify`. +- `[mechanical]` `verify` is a real, non-placeholder command appropriate for + Rust (e.g. involving `rustc` or `cargo`) — not `TEMPLATE.md`'s literal + C++/g++ example copied verbatim, and not empty/missing. +- `[mechanical]` `.sage-progress.json`'s `active_track` matches the new + curriculum's `track` slug, and `python3 tests/check_progress_schema.py + ` exits 0. +- `[behavioral]` `mastery: low` (from Round 3's "From scratch") and + `intent: balanced` (from Round 4's "Build while learning (Balanced)") — + quote the generated YAML header and confirm both map correctly from the + interview answers, not defaulted or guessed independently of them. +- `[behavioral]` `destination` reflects Round 2's actual stated project (the + CLI grep tool), not `"general"` or a generic placeholder — Round 2 was + answered "Yes" with a specific project named. +- `[behavioral]` `bridge_from` reflects the learner's real profile languages + (Java and/or Kotlin from Profile Setup) — not invented languages the + learner never stated. +- `[behavioral]` Sage displayed the generated curriculum (at least its + structure/header) and explicitly asked the learner to confirm **before** + saving it — quote the confirmation prompt. The file must not exist on disk + before that confirmation turn (check between steps 8 and 9). +- `[mechanical]` Round 0's options included "Build a custom track." alongside + "Python Foundations" (same mechanism scenario 01 already proved works for + the other branch — this exercises picking custom instead). diff --git a/tests/scenarios/07-axis-recalibration-accept.md b/tests/scenarios/07-axis-recalibration-accept.md new file mode 100644 index 0000000..7c98c8a --- /dev/null +++ b/tests/scenarios/07-axis-recalibration-accept.md @@ -0,0 +1,95 @@ +# Scenario 07 — Axis re-calibration accept path + +## Regression target +- The recalibration offer's **accept** branch has never been live-tested. + Scenario 04 only exercises decline. This scenario triggers `low_hint_streak + >= 3` (the "Mastery is probably too low" signal) and has the learner + **accept** the bump, checking that: `axis_overrides` gets the new level + (never the curriculum file itself), a confirmation is given, and — per the + fix in `SKILL.md` Axis Re-Calibration (the "if the learner accepts" bullet) + — the triggering streak resets to 0 so it doesn't immediately re-fire + against the level Sage just applied. +- It also checks that a second, later streak crossing the same threshold is + measured against the *new* `axis_overrides` level (medium, not low) — a + genuinely fresh signal earning a genuinely fresh offer, worded for the new + baseline. + +## Setup +Scratch directory with `.sage-progress.json`: +```json +{ + "active_track": "python-basics", + "tracks": { + "python-basics": { + "phase": 0, + "completed_exercises": ["P0-fizzbuzz", "P0-temp-converter"], + "current_topic": "Control flow", + "current_exercise": "P0-word-counter", + "next_up": "P1-list-ops", + "observations": "", + "topic_confidence": {}, + "review_due": [], + "axis_overrides": {}, + "low_hint_streak": 2, + "high_hint_streak": 0, + "last_session": "", + "hint_count": 0 + } + } +} +``` +(`low_hint_streak: 2` simulates `P0-fizzbuzz` and `P0-temp-converter` both +already completed with zero hints. Curriculum declares `mastery: low`.) +Valid `.sage-profile.md`. + +## Script +1. Learner works `P0-word-counter` (Phase 0's last exercise) needing **zero** + hints, submits a correct solution. Verify passes. `low_hint_streak` should + become 3 — this IS the end of Phase 0, so the phase-transition + AskUserQuestion fires. +2. At the phase-transition question, the recalibration option should be + present (per the streak state) — check its exact wording ("the last few + exercises... bump Mastery to medium?" or clearly equivalent). +3. Learner picks the **recalibration option**, i.e. accepts the bump. +4. Learner starts Phase 1, completes `P1-list-ops` with zero hints. +5. Learner completes `P1-comprehension-refactor` (Phase 1's second of three + exercises) with zero hints. `low_hint_streak` reaches 2 here, but this is + **not** a phase-transition point. +6. Learner completes `P1-json-roundtrip` (Phase 1's actual last exercise) + with zero hints again, so the streak isn't broken by a hint-needed pass. + This IS the end of Phase 1 → the phase-transition AskUserQuestion fires. +7. Learner accepts this second recalibration offer too. + +## Assertions + +- `[behavioral]` The recalibration option's wording at step 2 says "the last + few exercises" (or clearly equivalent phrasing that doesn't scope to a + single phase) and names bumping Mastery upward — quote it directly. +- `[mechanical]` After step 3 (accept), `axis_overrides.mastery == "medium"` + in the progress file — diff before/after. `curricula/python-basics.md` on + disk is unchanged (`mastery: low` still declared there; the override layers + on top, it never overwrites the source file). +- `[behavioral]` At step 3, Sage confirms what changed in one sentence (per + SKILL.md: "Confirm what changed in one sentence") — quote it. +- `[mechanical]` After step 3, `low_hint_streak` is reset to `0` in the + progress file — the accept branch resets the triggering streak same as + decline does, so it doesn't immediately resurface against the level just + applied. +- `[behavioral]` After step 4 (first Phase 1 zero-hint exercise post-accept), + no recalibration offer fires yet — the streak was reset in step 3, so this + is only the first exercise of a new streak (`low_hint_streak == 1`), below + threshold. +- `[behavioral]` After step 5, `low_hint_streak == 2` (still below the `>= 3` + threshold) and, independent of that, `P1-comprehension-refactor` is not + Phase 1's last exercise, so no phase-transition question fires at all here. + Sage should show only the standard 4-option "Exercise complete. What + next?" menu, with no 5th option. +- `[behavioral]` After step 6 (Phase 1's actual last exercise, phase + transition fires), `low_hint_streak == 3` and a **fresh** recalibration + offer is presented — confirm it's worded against the *new* baseline (e.g. + "bump Mastery to high?", not a repeat of "bump Mastery to medium?" since + `axis_overrides.mastery` is already `medium` at this point). +- `[mechanical]` After step 7 (second accept), `axis_overrides.mastery == + "high"` and `low_hint_streak` is reset to `0` again. +- `[mechanical]` `python3 tests/check_progress_schema.py ` exits + 0 at every checkpoint. diff --git a/tests/scenarios/08-multi-track-switching.md b/tests/scenarios/08-multi-track-switching.md new file mode 100644 index 0000000..872b3ec --- /dev/null +++ b/tests/scenarios/08-multi-track-switching.md @@ -0,0 +1,102 @@ +# Scenario 08 — /sage-tracks and /sage-switch across two real tracks + +## Regression target +- `/sage-tracks` and `/sage-switch` have only ever existed alongside a single + real bundled curriculum (`python-basics`) — never exercised with a second + one actually present, per issue #2's "out of scope for this pass" list. + `SKILL.md`'s spec for `/switch` is a single line ("Save current, load + new.") — thin enough that per-track isolation is only implicit in the + progress file's `tracks: {}` shape (see Progress File Format), not spelled + out as behavior. +- This scenario adds `rust-cli` (declared `mastery: medium, consequence: + high, intent: output` — sharply different from `python-basics`'s `low, + low, growth`) and checks three things `/switch` could plausibly get wrong: + progress isolation (one track's state corrupting or leaking into the + other's), axis calibration isolation (teaching posture bleeding from one + track's declared axes into the other), and `/tracks` status accuracy + across multiple real entries. + +## Setup +Requires both `curricula/python-basics.md` and `curricula/rust-cli.md` to +exist. Scratch directory with `.sage-progress.json`: +```json +{ + "active_track": "python-basics", + "tracks": { + "python-basics": { + "phase": 0, + "completed_exercises": ["P0-fizzbuzz"], + "current_topic": "Control flow", + "current_exercise": "P0-temp-converter", + "next_up": "P0-word-counter", + "observations": "", + "topic_confidence": {"variables-dynamic-typing-truthiness": "solid"}, + "review_due": [], + "axis_overrides": {}, + "low_hint_streak": 1, + "high_hint_streak": 0, + "last_session": "", + "hint_count": 0 + } + } +} +``` +(No `rust-cli` entry yet — that track has never been started.) Valid +`.sage-profile.md`. + +## Script +1. Learner runs `/sage-tracks`. Check the listing before touching anything. +2. Learner works `P0-temp-converter` (python-basics) needing zero hints, + submits a correct solution. Verify passes. `low_hint_streak` becomes 2, + `current_exercise` advances to `P0-word-counter`. This is deliberately + *not* a phase transition (one exercise remains in Phase 0) — just + building distinguishable state to check for corruption later. +3. Learner runs `/sage-switch rust-cli`. +4. Learner works `P0-word-count` (rust-cli's first exercise — note the + different destination, `Ferrogrep`, and topics from Phase 0 of + `curricula/rust-cli.md`) needing 3+ hints, submits a correct solution. + Verify passes. +5. Learner runs `/sage-switch python-basics`. +6. Learner runs `/sage-tracks` again. + +## Assertions + +- `[behavioral]` At step 1, `/sage-tracks` lists both `python-basics` + (status: started/active — it's `active_track` and has completed + exercises) and `rust-cli` (status: not started — no entry in `tracks` + yet), each showing the title and destination from its own YAML header + (`Python Foundations`/Taskwright vs. `Rust CLI Tools`/Ferrogrep). +- `[mechanical]` After step 3 (switch), `.sage-progress.json` has + `active_track == "rust-cli"`, a new `tracks.rust-cli` entry initialized at + phase 0 with empty `completed_exercises`/`axis_overrides` and zeroed + streaks, AND `tracks.python-basics` unchanged from its step-2 state + (`current_exercise: "P0-word-counter"`, `low_hint_streak: 2`, + `completed_exercises: ["P0-fizzbuzz", "P0-temp-converter"]`) — diff + before/after the switch to confirm nothing in the other track's entry was + touched. +- `[behavioral]` During step 4's lesson delivery, Sage's teaching posture + reflects `rust-cli`'s own declared axes (`mastery: medium` → compressed + concept+bridge per `references/philosophy.md`; `consequence: high` → + explicit verification framing, no black-box treatment of the borrow + checker's rejection; `intent: output` → efficient, compressed Steps 1-4) — + not `python-basics`'s `low`/`low`/`growth` posture. Quote the specific + transcript moment and cite the philosophy.md row it matches. +- `[mechanical]` After step 4, `tracks.rust-cli.high_hint_streak == 1` and, + critically, `tracks.python-basics.high_hint_streak` is still `0` (or + whatever it was before step 3) — a hint-heavy exercise on one track must + not touch the other track's streaks. +- `[mechanical]` After step 5 (switch back), `active_track == + "python-basics"` and `tracks.python-basics` resumes exactly where step 2 + left it (`current_exercise: "P0-word-counter"`, `low_hint_streak: 2`, + `completed_exercises` unchanged) — not reset, not re-onboarded from Phase + 0. +- `[behavioral]` At step 6, `/sage-tracks` now shows both tracks as started, + with `python-basics` correctly marked active again (not `rust-cli`, which + was only switched away from, not abandoned). +- `[mechanical]` `axis_overrides` is `{}` for both tracks at every + checkpoint — this scenario never triggers a recalibration offer, so a + non-empty override anywhere would mean something wrote to the wrong place. +- `[mechanical]` `python3 tests/check_progress_schema.py ` + exits 0 at every checkpoint, confirming the schema checker validates a + multi-track file correctly (it loops over every key under `tracks`, not + just `active_track`).