← BodhiKitCONTENT HISTORY

Update to BodhiKit

Snapshot Oct 29, 2026 · 18:37 UTC · version 1.25.1

WHAT CHANGED

Package or technical metadata updated

Package or technical metadata updated

Observed in package metadata. These changes alone do not establish a new customer-facing feature.

Save BodhiKit and check future changes in Following.

Up to 3 plugins free · no card required.

All fields and original values.

These are saved file metadata. Open a changed file to read its text edits.

Package file

Before

Sep 30, 2026 · 23:15 UTC

{"sha256":"1d03575fb38b6cc49a232b2de36098c622a225bad62c7beb6f6071573505f831","size":54}

After

Oct 29, 2026 · 18:37 UTC

Not present

Package file

Before

Sep 30, 2026 · 23:15 UTC

{"sha256":"86a71e096653ce6d87bca9c9dde2e433d1e4f85969b12295c06693cbfa7e43f6","size":707205}

After

Oct 29, 2026 · 18:37 UTC

Not present

Package file

Before

Sep 30, 2026 · 23:15 UTC

{"sha256":"86a71e096653ce6d87bca9c9dde2e433d1e4f85969b12295c06693cbfa7e43f6","size":707205}

After

Oct 29, 2026 · 18:37 UTC

Not present

Package file

Before

Sep 30, 2026 · 23:15 UTC

{"sha256":"86a71e096653ce6d87bca9c9dde2e433d1e4f85969b12295c06693cbfa7e43f6","size":707205}

After

Oct 29, 2026 · 18:37 UTC

Not present

Compare saved observations

Download comparison JSON

Changed files

Choose a file to see its text changes. Binary or large files show metadata only.

.bodhikit-openai-build.json →

.codex-plugin/assets/composer-icon.png →

.codex-plugin/assets/logo-dark.png →

.codex-plugin/assets/logo.png →

.codex-plugin/plugin.json →

hooks/hooks.json →

references/knowledge/ai-learning-safeguards.md →

references/knowledge/blooms-taxonomy.md →

references/knowledge/difficulty-calibration.md →

references/knowledge/evidence-kinds.md →

references/knowledge/feynman-technique.md →

references/knowledge/growth-mindset.md →

references/knowledge/metacognition.md →

references/knowledge/state-lifecycle.md →

references/knowledge/state-ops.md →

references/knowledge/state-schema.md →

references/knowledge/teaching-personality.md →

references/openai-runtime.md →

references/roles/resource-finder.md →

references/roles/skill-assessor.md →

rules/learning-project.md →

scripts/bodhi-session-context.py →

scripts/bodhi-state →

scripts/bodhi-stop-hook-core.py →

scripts/bodhi-stop-hook.py →

skills/assess/SKILL.md →

skills/continue/SKILL.md →

skills/learn/SKILL.md →

skills/learn/references/host-without-files.md →

skills/plan/SKILL.md →

skills/practice/SKILL.md →

skills/progress/SKILL.md →

skills/progress/references/module-row.md →

skills/quiz/SKILL.md →

skills/reflect/SKILL.md →

skills/reflect/references/revision-sheet.md →

skills/resources/SKILL.md →

skills/review/SKILL.md →

skills/teach/SKILL.md →

skills/teach/references/prerequisite-gate.md →

scripts/bodhi-state

− removed · + added

--- before
+++ after
@@ -46,6 +46,17 @@
 
 CONFIDENCE_VALUES = {"sure", "mostly", "guessing"}
 
+# Evidence kinds (state-schema KB): what shows the learner can use a concept.
+# A project or module with no kind recorded is `code`.
+EVIDENCE_KINDS = ("code", "problems", "writing", "performed")
+# How applied work was seen: `observed` (the tutor read it in the project; the
+# absent default, never written), `inspected` (a photo or transcript the
+# learner shared), `reported` (the learner's word).
+APPLIED_BASES = ("observed", "inspected", "reported")
+# The kinds whose applied work the tutor cannot see: the two unseen bases
+# count toward the gate and Solid there and nowhere else.
+UNSEEN_BASIS_KINDS = ("performed",)
+
 # Learner-facing rendering of a Bloom level (blooms-taxonomy KB, canonical).
 # The number is an instructor-facing instrument. A learner reads the OUTCOME
 # clause on its own; the LABEL is spoken only at a rung-crossing
@@ -57,11 +68,11 @@
 BLOOM_OUTCOMES = {
     0: "nothing observed yet",
     1: "you can recall the terms and what they refer to",
-    2: "you can explain what it does in your own words",
-    3: "you can apply it in working code with some guidance",
-    4: "you can reason about why it behaves as it does and debug it on your own",
-    5: "you can weigh approaches and defend a design choice",
-    6: "you can design something new with it and teach it",
+    2: "you can explain it in your own words",
+    3: "you can apply it to a real case with some guidance",
+    4: "you can work out why it works as it does and find where it breaks, on your own",
+    5: "you can weigh the alternatives and defend a choice between them",
+    6: "you can create something new with it and teach it",
 }
 
 
@@ -463,6 +474,11 @@
     for flag in ("applied", "retry", "selfReport"):
         if flag in h and not isinstance(h[flag], bool):
             errs.append((flag, h[flag], "true|false", _coerce_bool(h[flag])))
+    if "appliedBasis" in h and h["appliedBasis"] not in APPLIED_BASES:
+        errs.append(("appliedBasis", h["appliedBasis"], "|".join(APPLIED_BASES),
+                     _coerce_choice(h["appliedBasis"], APPLIED_BASES)))
+    if "evidence" in h and not isinstance(h["evidence"], str):
+        errs.append(("evidence", h["evidence"], "a project-relative path", None))
     return errs
 
 
@@ -644,7 +660,7 @@
 
 
 def apply_review(concept, result, tested_bloom, confidence=None, note=None,
-                 source=None, retry=False, applied=False):
+                 source=None, retry=False, applied=None, evidence=None):
     """Canonical per-concept update (spaced-repetition + state-schema KBs).
 
     correct   -> box up one (max 5), nextReview = today + new interval, when
@@ -659,11 +675,13 @@
                  tomorrow's review stand; no box/counter/bloom movement.
     bloomLevel only ever ratchets up (demotion is /forget's job, and even
     /forget demotes the box, not the Bloom classification).
-    `applied` marks the entry as demonstrated in working code the tutor read
-    (an exercise, a driven piece) rather than in an explanation or a quiz
-    answer. It is a separate axis from the level: the rubric still sets
+    `applied` marks the entry as demonstrated in work the tutor read (code
+    that ran, or a written application on a non-code topic; blooms-taxonomy
+    KB *Applied Evidence*) rather than in an explanation or a quiz answer. It is a separate axis from the level: the rubric still sets
     `--tested-bloom`, and the flag is what the gate and the mastery formula
-    read for "can the learner actually build with it" (1.20.0).
+    read for "can the learner actually build with it" (1.20.0). `applied`
+    is its basis (APPLIED_BASES); only an unseen one is written, as
+    appliedBasis, and `evidence` is the work's project-relative path.
     """
     t = today()
     old_box = concept.get("box", 1)
@@ -713,6 +731,10 @@
         entry["source"] = source
     if applied:
         entry["applied"] = True
+    if applied in ("inspected", "reported"):
+        entry["appliedBasis"] = applied
+    if evidence:
+        entry["evidence"] = evidence
     history = concept.setdefault("reviewHistory", [])
     history.append(entry)
     if len(history) > HISTORY_CAP:
@@ -733,11 +755,48 @@
             "crossedLevel": new_bloom > old_bloom,   # the one moment the label is spoken
             "consecutiveCorrectAtL4Plus": concept.get("consecutiveCorrectAtL4Plus", 0),
             "nextReview": concept["nextReview"]}
+    if applied in ("inspected", "reported"):
+        out["appliedBasis"] = applied
+    if evidence:
+        out["evidence"] = evidence
     if box_held:
         out["boxHeld"] = box_held
     return out
 
 
+# The exercise key the tutor saves before a non-code attempt: worked answers
+# for `problems`, the criteria for `writing` (and a performed rep's task).
+KEY_FILES = ("answers.md", "criteria.md")
+
+
+def evidence_path(project, raw, kind, basis):
+    """The checked, project-relative path of applied work (state-schema KB
+    reviewHistory[].evidence). Observed work off code is the learner's own
+    file beside the exercise's key, so neither the key, the brief nor a
+    tracking file can stand in for it."""
+    root = os.path.realpath(project)
+    full = os.path.realpath(raw if os.path.isabs(raw) else os.path.join(root, raw))
+    rel = os.path.relpath(full, root)
+    if rel == os.pardir or rel.startswith(os.pardir + os.sep):
+        die(f"--evidence {raw!r} is outside the project")
+    if not os.path.isfile(full):
+        die(f"--evidence {raw!r} does not exist in the project")
+    parts = rel.split(os.sep)
+    if parts[0] == ".bodhi":
+        die(f"--evidence {raw!r} is a tracking file, not the learner's work")
+    if kind != "code" and basis == "observed":
+        name = parts[-1].casefold()
+        if name in KEY_FILES:
+            die(f"--evidence {raw!r} is the exercise's key, not the learner's work")
+        if name == "readme.md":
+            die(f"--evidence {raw!r} is the exercise's README (the brief), not the "
+                f"learner's work")
+        if not any(os.path.isfile(os.path.join(os.path.dirname(full), k)) for k in KEY_FILES):
+            die(f"--evidence {raw!r} has no key beside it: save the exercise's "
+                f"{' or '.join(KEY_FILES)} in its folder before the attempt")
+    return "/".join(parts)
+
+
 # --- Subcommands ------------------------------------------------------------
 
 def cmd_add_concept(args):
@@ -779,10 +838,25 @@
         concept = new_concept(args.concept, args.module, args.question or "")
         data.setdefault("concepts", []).append(concept)
         created = True
+    kind = effective_kind(concept)
+    if args.evidence is not None and args.applied is None:
+        die("--evidence is the path of applied work; pass it with --applied")
+    if args.applied is not None and not admits(kind, args.applied):
+        die(f"concept {concept['name']!r} is a {kind} concept: --applied "
+            f"{args.applied} (the learner's word, or a photo the tutor examined) "
+            f"counts only on a performed concept — record it without --applied, "
+            f"or as --applied observed with the saved work")
+    evidence = None
+    if args.evidence is not None:
+        evidence = evidence_path(args.project, args.evidence, kind, args.applied)
+    elif args.applied == "observed" and kind != "code":
+        die(f"--applied observed on a {kind} concept needs --evidence <path>: the "
+            f"learner's work saved in the project, beside the exercise's key "
+            f"({' or '.join(KEY_FILES)})")
     change = apply_review(concept, args.result, args.tested_bloom,
                           confidence=args.confidence, note=args.note,
                           source=args.source, retry=args.retry,
-                          applied=args.applied)
+                          applied=args.applied, evidence=evidence)
     data["lastReviewCheck"] = now_iso()
     write_spaced_review(path, data)
     change["created"] = created
@@ -1010,6 +1084,63 @@
           "lastActivity": state.get("lastActivity")})
 
 
+def cmd_set_evidence_kind(args):
+    """Record the project's evidence kind, or one module's override
+    (state-schema KB). Writes state.json only: every review is kept, and a
+    kind changes how reviews are read, never what they say."""
+    if args.clear and args.kind is not None:
+        die("pass --kind to record a kind or --clear to remove one, not both")
+    if not args.clear and args.kind is None:
+        die(f"--kind is required: one of {'|'.join(EVIDENCE_KINDS)} (or --clear)")
+    if args.kind is not None and args.kind not in EVIDENCE_KINDS:
+        die(f"kind {args.kind!r} is not one of {'|'.join(EVIDENCE_KINDS)}")
+    sp, state = require_state(args.project)
+    kinds = state.get("moduleEvidenceKinds", {})
+    if not isinstance(kinds, dict):
+        die(f"{sp}: moduleEvidenceKinds is {type(kinds).__name__}, expected an "
+            f"object — fix it by hand; `bodhi-state verify` lists every problem")
+    key = None
+    if args.module is not None:
+        _, plan = read_plan_modules(args.project)
+        by_id = {m["id"]: norm_module_name(m["name"]) for m in plan}
+        key = by_id.get(norm_module_name(args.module), norm_module_name(args.module))
+        _, data = load_spaced_review(args.project, create=True)
+        known = set(by_id.values()) | {norm_module_name(c.get("module"))
+                                       for c in data.get("concepts", [])}
+        # Clearing must reach an orphan (a renamed module), so only a set
+        # is held to the typo guard.
+        if not args.clear and key not in known:
+            die(f"module {args.module!r} matches no plan module heading and no "
+                f"tracked concept's module — `syllabus` lists the plan's names")
+    if key is None and args.clear:
+        changed = state.pop("evidenceKind", None) is not None
+    elif key is None:
+        state["evidenceKind"] = args.kind
+        changed = True
+    else:
+        spellings = [k for k in kinds if norm_module_name(k) == key]
+        for k in spellings:
+            kinds.pop(k)
+        if not args.clear:
+            kinds[key] = args.kind
+        changed = bool(spellings) or not args.clear
+        if kinds:
+            state["moduleEvidenceKinds"] = kinds
+        else:
+            state.pop("moduleEvidenceKinds", None)
+    if not changed:
+        emit({"action": "noop", "reason": "no kind recorded there to clear",
+              "module": key})
+        return
+    write_json(sp, state)
+    install_kinds(args.project)
+    emit({"action": "evidence-kind-cleared" if args.clear else "evidence-kind-set",
+          "module": key, "kind": args.kind,
+          "projectKind": _KINDS["project"],
+          "projectKindRecorded": "evidenceKind" in state,
+          "moduleEvidenceKinds": state.get("moduleEvidenceKinds", {})})
+
+
 def cmd_bump_profile(args):
     if args.counter not in PROFILE_COUNTERS:
         die(f"counter must be one of {sorted(PROFILE_COUNTERS)}")
@@ -1223,6 +1354,7 @@
                         "exposure": exposure,
                         "bloomOutcome": bloom_render(c.get("bloomLevel", 0))["bloomOutcome"],
                         "question": c.get("question", ""),
+                        "evidenceKind": effective_kind(c),
                         "_sort": (c.get("box", 1), raw)})
     due.sort(key=lambda d: d.pop("_sort"))
     for i, d in enumerate(due, 1):
@@ -1244,7 +1376,7 @@
     """Canonical mastery formula (state-ops KB; field semantics in state-schema).
 
     Five conjuncts since 1.20.0: the fifth, one correct demonstrated in
-    working code since the last miss, is what keeps "Solid" from being
+    applied work since the last miss, is what keeps "Solid" from being
     earned entirely in conversation. Since 1.23.0 the explain-back must
     also postdate the last miss (feynman_current). Do not redefine inline.
     """
@@ -1299,7 +1431,7 @@
 def new_module_row():
     """Per-module tally shape shared by `mastery` and `snapshot`."""
     return {"concepts": 0, "mastered": 0, "classified": 0, "applied": 0,
-            "dueForCheck": 0,
+            "dueForCheck": 0, "masteredByReport": 0,
             "tiers": {"unclassified": 0, "introduced": 0,
                       "familiar": 0, "mastered": 0}}
 
@@ -1311,6 +1443,8 @@
         m["classified"] += 1
     if is_mastered(c):
         m["mastered"] += 1
+        if strongest_basis(c) == "reported":
+            m["masteredByReport"] += 1
     if applied_evidence(c) >= 1:
         m["applied"] += 1
     if due_for_check(c, t):
@@ -1364,7 +1498,7 @@
     rollup = {"strong": [], "building": [], "needs_review": []}
     due_today, due_week, parked, due_check = [], [], [], []
     boxes = {str(b): 0 for b in range(1, MAX_BOX + 1)}
-    overdue10 = unparseable = 0
+    overdue10 = unparseable = waiting = unscheduled = 0
     for c in concepts:
         tally_module(modules.setdefault(c.get("module", "(none)"),
                                         new_module_row()), c, t)
@@ -1380,17 +1514,29 @@
         if nr is None:
             if c.get("nextReview") is not None:
                 unparseable += 1
+            else:
+                unscheduled += 1
             continue
         if nr <= t:
             due_today.append(c["name"])
             if (t - nr).days >= 10:
                 overdue10 += 1
-        elif nr <= t + datetime.timedelta(days=7):
-            due_week.append(c["name"])
+        else:
+            # Scheduled later than today: nothing to do for it yet. Since
+            # 1.23.0 a correct before the due date holds the box, so a
+            # learner who only teaches sees no movement; this count is what
+            # /progress names so a flat dashboard reads as "waiting", not
+            # "stuck".
+            waiting += 1
+            if nr <= t + datetime.timedelta(days=7):
+                due_week.append(c["name"])
     finalize_module_rows(modules)
+    for name, row in modules.items():
+        row["evidenceKind"] = kind_of_module(name)
     return {"modules": modules, "rollup": rollup, "dueToday": due_today,
             "dueThisWeek": due_week, "parked": parked, "boxes": boxes,
-            "dueForCheck": due_check,
+            "dueForCheck": due_check, "waiting": waiting,
+            "unscheduled": unscheduled,
             "overdue10": overdue10, "unparseable": unparseable}
 
 
@@ -1405,6 +1551,7 @@
            "blockedOnFeynman": blocked_on_feynman(concepts),
            "blockedOnApplied": blocked_on_applied(concepts),
            "masteredDueForCheck": r["dueForCheck"]}
+    out["solidByReport"], out["solidBasis"] = solid_by_report(concepts)
     if r["parked"]:
         out["parked"] = r["parked"]
     emit(out)
@@ -1491,6 +1638,9 @@
     "1 review, 0% recall"). The gap for a concept's first review runs from
     its `introduced` date. `delayedSuccessRate` drops the same-day bucket,
     which is immediate post-instruction performance, not retention.
+    `byKind` and `byBasis` split the same counts by the concept's evidence
+    kind and by the entry's applied-work basis (`notApplied` without one).
+    A practice rep's entry is graded on its debrief, so it counts here.
     """
     def new_bucket():
         return {"correct": 0, "incorrect": 0, "partial": 0}
@@ -1503,7 +1653,16 @@
     self_reports = 0
     legacy_no_box = 0
     delayed = new_bucket()
+    by_kind, by_basis = {}, {}
+
+    def tally(groups, key, res, is_delayed):
+        g = groups.setdefault(key, {"all": new_bucket(), "delayed": new_bucket()})
+        g["all"][res] += 1
+        if is_delayed:
+            g["delayed"][res] += 1
+
     for c in data.get("concepts", []):
+        kind = effective_kind(c)
         # After a truncation the first retained gap runs from the last
         # archived review, not from `introduced` (review finding 6).
         prev = (parse_date(archived_summary(c).get("through"))
@@ -1523,14 +1682,18 @@
             if h.get("retry"):
                 relearning += 1
                 continue
+            is_delayed = False
             if prev is not None:
                 bucket = bucket_for_gap((d - prev).days)
                 if bucket:
                     by_gap[bucket][res] += 1
                     if bucket != "same-day":
                         delayed[res] += 1
+                        is_delayed = True
             prev = d
             totals[res] += 1
+            tally(by_kind, kind, res, is_delayed)
+            tally(by_basis, entry_basis(h) or "notApplied", res, is_delayed)
             bb = h.get("boxBefore")
             if isinstance(bb, int) and 1 <= bb <= MAX_BOX:
                 by_box[str(bb)][res] += 1
@@ -1542,6 +1705,12 @@
         out["reviews"] = n
         out["successRate"] = round(bucket["correct"] / n, 2) if n else None
         return out
+    def rates(g):
+        n, dn = sum(g["all"].values()), sum(g["delayed"].values())
+        return {"reviews": n,
+                "successRate": round(g["all"]["correct"] / n, 2) if n else None,
+                "delayedReviews": dn,
+                "delayedSuccessRate": round(g["delayed"]["correct"] / dn, 2) if dn else None}
     total_n = sum(totals.values())
     delayed_n = sum(delayed.values())
     return {
@@ -1556,6 +1725,9 @@
         "selfReportsExcluded": self_reports,
         "entriesWithoutBoxBefore": legacy_no_box,
         "entriesSkipped": skipped,
+        "byKind": {k: rates(by_kind[k]) for k in EVIDENCE_KINDS if k in by_kind},
+        "byBasis": {b: rates(by_basis[b]) for b in APPLIED_BASES + ("notApplied",)
+                    if b in by_basis},
     }
 
 
@@ -1577,7 +1749,23 @@
     feynman = 0
     mastered = 0
     applied = 0
+    # By kind and basis: counts only, keyed by vocabulary, never by a name.
+    by_kind, by_basis = {}, {}
+
+    def basis_row(basis):
+        return by_basis.setdefault(basis, {"appliedCorrects": 0, "solid": 0})
+
     for c in data.get("concepts", []):
+        row = by_kind.setdefault(effective_kind(c), {"concepts": 0, "applied": 0, "mastered": 0})
+        row["concepts"] += 1
+        row["applied"] += applied_evidence(c) >= 1
+        row["mastered"] += is_mastered(c)
+        for h in c.get("reviewHistory", []):
+            basis = applied_correct_basis(h) if isinstance(h, dict) else None
+            if basis:
+                basis_row(basis)["appliedCorrects"] += 1
+        if is_mastered(c):
+            basis_row(strongest_basis(c))["solid"] += 1
         b = c.get("box", 1)
         if isinstance(b, int) and 1 <= b <= MAX_BOX:
             boxes[str(b)] += 1
@@ -1609,6 +1797,8 @@
         "sessionTypeCounts": session_types,
         "retention": retention_summary(data),
         "calibration": calibration,
+        "byKind": {k: by_kind[k] for k in EVIDENCE_KINDS if k in by_kind},
+        "byBasis": {b: by_basis[b] for b in APPLIED_BASES if b in by_basis},
     }
     state = state_if_present(args.project)
     if state is not None:
@@ -1637,7 +1827,8 @@
         emit({"concept": args.concept, "tracked": False,
               "firstExposure": True, "pretestApplies": True,
               "isReteach": False, "dueForReview": False, "reviews": 0,
-              "currentModule": state.get("currentModule")})
+              "currentModule": state.get("currentModule"),
+              "evidenceKind": kind_of_module(state.get("currentModule"))})
         return
     history = [h for h in c.get("reviewHistory", []) if not h.get("deferred")]
     # First exposure: never classified by a v3 writer AND no real review has
@@ -1668,6 +1859,7 @@
         "dueForReview": nr is not None and nr <= t,
         "reviews": len(history) + (c.get("reviewHistoryArchived", 0) or 0),
         "currentModule": state.get("currentModule"),
+        "evidenceKind": effective_kind(c),
     })
 
 
@@ -1677,6 +1869,357 @@
     return out[:limit].rstrip("-") or "session"
 
 
+# --- syllabus (1.24.0) ------------------------------------------------------
+# The plan says the order; the review file says the evidence; state.json says
+# the position. No skill could join the three reliably in prose: /plan view
+# read every phase file and showed order with no performance, /progress
+# showed per-module tiers with no order (its modules come from the review
+# file), and "where am I and how did I do" needs both. One read-only call.
+
+PHASE_FILE_RE = re.compile(r"^phase-(\d+)\.md$")
+# `### Module 1.2 — name` is canonical; `### Module 7 — name` (one number,
+# counted across the course) is what the OpenAI build's /learn wrote in a
+# real session, and a plan the reader cannot see empties /plan, /progress and
+# /continue's position line. The id is the number(s) as written.
+MODULE_HEADING_RE = re.compile(r"^###\s+Module\s+(\d+)(?:\.(\d+))?\s*(?:[—–:-]+\s*)?(.*?)\s*$")
+PLAN_UNREADABLE = ("plan/ has phase files but no `### Module N.M — name` (or "
+                   "`### Module N — name`) headings; /plan, /progress and the "
+                   "position line read an empty plan")
+MODULE_LINE_RE = re.compile(
+    r"^\s*(?:[-*]\s+)?\*\*(Status|Target Bloom(?:'s)?(?: level)?|Concepts|Key terms?)\s*:?\*\*\s*:?\s*(.+?)\s*$",
+    re.IGNORECASE)
+MODULE_STATUS_WORDS = {"skipped": "skipped", "completed": "done", "done": "done",
+                       "complete": "done"}
+MODULE_STATUS_RE = re.compile(r"\b(skipped|completed|complete|done)\b", re.IGNORECASE)
+
+
+def norm_module_name(s):
+    """Join key for a module name: a `Module 1.2 — ` or `Module 2 — ` prefix
+    dropped, whitespace collapsed, case folded. Concept rows carry whatever
+    `--module` was passed, so the join tolerates the executor's spelling —
+    including a plan renumbered from `Module 2` to `Module 0.2` after rows
+    were written."""
+    if not isinstance(s, str):
+        return ""
+    stripped = re.sub(r"^\s*module\s+\d+(?:\.\d+)?\s*[—–:-]*\s*", "", s, flags=re.IGNORECASE)
+    if stripped.strip():
+        s = stripped
+    return re.sub(r"\s+", " ", s).strip().casefold()
+
+
+# --- Evidence-kind resolver -------------------------------------------------
+# The kind reaches the applied-work counters through this per-process table,
+# installed once by main() (one process serves one --project), not through a
+# parameter on every reader. Uninstalled, everything reads as code: the
+# strictest reading, since code admits only observed work.
+_KINDS = {"project": "code", "modules": {}}
+
+
+def read_kinds(project):
+    """(project kind, {normalized module name: kind}) from state.json. Never
+    fails a reader: a missing or unreadable file, or a kind outside
+    EVIDENCE_KINDS, reads as code (verify reports the bad value)."""
+    try:
+        with open(state_path(project), encoding="utf-8") as f:
+            state = json.load(f)
+    except (OSError, ValueError):
+        return "code", {}
+    if not isinstance(state, dict):
+        return "code", {}
+    kind = state.get("evidenceKind")
+    modules = state.get("moduleEvidenceKinds")
+    modules = modules if isinstance(modules, dict) else {}
+    return (kind if kind in EVIDENCE_KINDS else "code",
+            {norm_module_name(name): k if k in EVIDENCE_KINDS else "code"
+             for name, k in modules.items()})
+
+
+def install_kinds(project):
+    _KINDS["project"], _KINDS["modules"] = read_kinds(project)
+
+
+def kind_of_module(module):
+    return _KINDS["modules"].get(norm_module_name(module), _KINDS["project"])
+
+
+def effective_kind(c):
+    """A concept's evidence kind: its module's override, else the project's."""
+    return kind_of_module(c.get("module"))
+
+
+def applied_basis(h):
+    """How an applied entry's work was seen. Absent is `observed`; a value
+    outside APPLIED_BASES reads as `reported`, the strictest."""
+    b = h.get("appliedBasis")
+    if b is None:
+        return "observed"
+    return b if b in APPLIED_BASES else "reported"
+
+
+def admits(kind, basis):
+    """Whether applied work on `basis` counts toward the gate and Solid for a
+    concept of `kind`: observed work everywhere, unseen work only where the
+    tutor cannot see it."""
+    return basis == "observed" or kind in UNSEEN_BASIS_KINDS
+
+
+def _coerce_choice(x, choices):
+    """The lossless vocabulary repair: case and surrounding spaces."""
+    if isinstance(x, str) and x not in choices and x.strip().casefold() in choices:
+        return x.strip().casefold()
+    return None
+
+
+def parse_bloom_target(val):
+    """`3`, `3 (Apply)`, `Apply` → 3; anything else None."""
+    m = re.search(r"\b([1-6])\b", val)
+    if m:
+        return int(m.group(1))
+    low = val.casefold()
+    for lvl, label in BLOOM_LABELS.items():
+        if label and label.casefold() in low:
+            return lvl
+    return None
+
+
+def read_plan_modules(project):
+    """The plan's module order from plan/phase-*.md (state-schema KB *plan/*):
+    `### Module <N>.<M> — <name>` headings in phase-number order, with the
+    optional `**Status:**`, `**Target Bloom's:**`, `**Concepts:**` and
+    `**Key terms:**` lines of each section. Returns (phases, modules) or (None, []) with no plan/."""
+    pdir = os.path.join(project, ".bodhi", "plan")
+    if not os.path.isdir(pdir):
+        return None, []
+    files = []
+    try:
+        names = os.listdir(pdir)
+    except OSError:
+        names = []
+    for f in names:
+        m = PHASE_FILE_RE.match(f)
+        if m:
+            files.append((int(m.group(1)), f))
+    phases, modules = [], []
+    for n, f in sorted(files):
+        try:
+            # A plan is prose the learner pastes into; one Latin-1 byte must
+            # not take `snapshot` (and with it /continue) down. Replace, skip
+            # nothing readable.
+            with open(os.path.join(pdir, f), encoding="utf-8", errors="replace") as fh:
+                text = fh.read()
+        except OSError:
+            continue
+        title = next((ln[2:].strip() for ln in text.splitlines()
+                      if ln.startswith("# ")), f"Phase {n}")
+        cur, count, fenced = None, 0, False
+        for line in text.splitlines():
+            if line.lstrip().startswith("```"):
+                fenced = not fenced
+                continue
+            if fenced:
+                continue
+            m = MODULE_HEADING_RE.match(line)
+            if m:
+                mid = f"{m.group(1)}.{m.group(2)}" if m.group(2) else m.group(1)
+                cur = {"phase": n, "id": mid, "name": m.group(3) or f"Module {mid}",
+                       "file": f, "declaredStatus": None, "targetBloom": None,
+                       "plannedConcepts": [], "keyTerms": []}
+                modules.append(cur)
+                count += 1
+                continue
+            if line.startswith("## ") or line.startswith("# "):
+                cur = None
+            if cur is None:
+                continue
+            ml = MODULE_LINE_RE.match(line)
+            if not ml:
+                continue
+            key, val = ml.group(1).casefold(), ml.group(2)
+            if key == "status":
+                sw = MODULE_STATUS_RE.search(val)
+                cur["declaredStatus"] = MODULE_STATUS_WORDS.get(sw.group(1).casefold()) if sw else None
+            elif key.startswith("target bloom"):
+                cur["targetBloom"] = parse_bloom_target(val)
+            elif key == "concepts":
+                cur["plannedConcepts"] = [x.strip() for x in val.split(",") if x.strip()]
+            elif key.startswith("key term"):
+                cur["keyTerms"] = [x.strip(" `*") for x in val.split(",") if x.strip(" `*")]
+        phases.append({"phase": n, "title": title, "file": f, "modules": count})
+    return phases, modules
+
+
+def plan_position(state, plan):
+    """Index of the current module in the plan's order: state.json
+    currentModule by name or id, else currentModuleIndex, else None. The
+    second and third cases carry a warning. Shared by `syllabus` and
+    `snapshot` (whose `project.position` is what /continue's check-in
+    prints), so "Module 3 of 8" has one home."""
+    warnings = []
+    cur_name = norm_module_name(state.get("currentModule"))
+    cur_id = re.sub(r"(?i)^\s*module\s+", "", str(state.get("currentModule") or "")).strip()
+    cur_phase = str(state.get("currentPhase") or "").strip()
+    matches = [i for i, m in enumerate(plan)
+               if cur_name and (norm_module_name(m["name"]) == cur_name or m["id"] == cur_id)]
+    if matches:
+        # A spiral revisit may declare the same name in a later phase; the
+        # phase the state names breaks the tie, else the first occurrence.
+        in_phase = [i for i in matches if str(plan[i]["phase"]) == cur_phase]
+        return (in_phase or matches)[0], warnings
+    if plan:
+        idx = state.get("currentModuleIndex")
+        if isinstance(idx, str) and idx.strip().isdigit():
+            idx = int(idx)
+        if isinstance(idx, int) and not isinstance(idx, bool) and 0 <= idx < len(plan):
+            if cur_name:
+                warnings.append(f"state.json currentModule {state.get('currentModule')!r} "
+                                f"is not a plan module; position taken from "
+                                f"currentModuleIndex {idx}")
+            return idx, warnings
+        warnings.append("state.json names no plan module as current; every "
+                        "undeclared module reads as upcoming")
+    return None, warnings
+
+
+def position_row(plan, idx):
+    """The learner-facing position: 1-based index over all plan modules."""
+    if idx is None:
+        return None
+    return {"index": idx + 1, "of": len(plan),
+            "module": plan[idx]["name"], "phase": plan[idx]["phase"]}
+
+
+def summary_tier(tiers):
+    """The /progress rendering rule in code: a mixed module reads at its
+    lowest occupied tier above `unclassified`; all-unclassified is None."""
+    for key in ("introduced", "familiar", "mastered"):
+        if tiers.get(key, 0) > 0:
+            return key
+    return None
+
+
+def cmd_syllabus(args):
+    """Plan order × review evidence × state position, one read-only call.
+    Every module the plan declares, in order, with its status (declared
+    `skipped`/`done`, else `done`/`current`/`upcoming` from the position the
+    plugin already tracks), the review file's module row, the earliest next
+    review and today's due count. Modules on either side that the other side
+    does not know are reported, never dropped: `untracked` names plan modules
+    no concept was tracked under (not reached yet, or a spelling the review
+    file does not use); `unplanned` carries the rows of tracked modules the
+    plan does not declare, so no evidence disappears from a dashboard."""
+    state = state_if_present(args.project, {})
+    _, data = load_spaced_review(args.project, create=True)
+    t = today()
+    concepts = data.get("concepts", [])
+    warnings = []
+
+    phases, plan = read_plan_modules(args.project)
+    if phases is None:
+        warnings.append("no .bodhi/plan/ directory — a pre-1.7.0 monolithic "
+                        "plan.md is not read; /housekeep migrate converts it")
+        phases = []
+    elif not plan:
+        warnings.append(PLAN_UNREADABLE)
+
+    # Evidence per tracked module, by join key — the same tally `mastery`
+    # and `snapshot` use, re-keyed so "schema design" and "Schema Design"
+    # are one module here (the rollup keys by the raw spelling).
+    tracked = {}
+    for c in concepts:
+        raw = c.get("module", "(none)")
+        entry = tracked.setdefault(norm_module_name(raw),
+                                   {"module": raw, "aliases": [], "row": new_module_row(),
+                                    "nextDue": None, "dueToday": 0, "parked": 0})
+        if raw != entry["module"] and raw not in entry["aliases"]:
+            entry["aliases"].append(raw)
+        tally_module(entry["row"], c, t)
+        if c.get("parked") is True:
+            entry["parked"] += 1
+            continue
+        nr = parse_date(c.get("nextReview"))
+        if nr is None:
+            continue
+        if nr <= t:
+            entry["dueToday"] += 1
+        if entry["nextDue"] is None or nr < entry["nextDue"]:
+            entry["nextDue"] = nr
+    finalize_module_rows({k: e["row"] for k, e in tracked.items()})
+
+    def evidence(entry):
+        if entry is None:
+            return {"tracked": False, "concepts": 0, "classified": 0, "applied": 0,
+                    "mastered": 0, "masteredByReport": 0, "dueForCheck": 0, "masteryPct": None,
+                    "tiers": new_module_row()["tiers"], "summaryTier": None,
+                    "nextDue": None, "dueToday": 0, "parked": 0}
+        row = entry["row"]
+        out = {"tracked": True, "trackedAs": entry["module"]}
+        if entry["aliases"]:
+            out["trackedAlso"] = entry["aliases"]
+        out.update({k: row[k] for k in ("concepts", "classified", "applied", "mastered",
+                                        "masteredByReport", "dueForCheck", "masteryPct",
+                                        "tiers")})
+        out["summaryTier"] = summary_tier(row["tiers"])
+        out["nextDue"] = iso(entry["nextDue"]) if entry["nextDue"] else None
+        out["dueToday"] = entry["dueToday"]
+        out["parked"] = entry["parked"]
+        return out
+
+    cur_idx, pos_warnings = plan_position(state, plan)
+    warnings.extend(pos_warnings)
+
+    modules, matched_keys = [], set()
+    for i, m in enumerate(plan):
+        key = norm_module_name(m["name"])
+        entry = tracked.get(key) or tracked.get(m["id"])
+        if entry is not None:
+            matched_keys.add(norm_module_name(entry["module"]))
+        if m["declaredStatus"]:
+            status, source = m["declaredStatus"], "declared"
+        elif cur_idx is None:
+            status, source = "upcoming", "unknown-position"
+        elif i < cur_idx:
+            status, source = "done", "plan-order"
+        elif i == cur_idx:
+            status, source = "current", "plan-order"
+        else:
+            status, source = "upcoming", "plan-order"
+        row = {"index": i + 1, "phase": m["phase"], "id": m["id"], "name": m["name"],
+               "status": status, "statusSource": source,
+               "targetBloom": m["targetBloom"], "plannedConcepts": m["plannedConcepts"],
+               "keyTerms": m["keyTerms"], "file": m["file"]}
+        if m["targetBloom"] is not None:
+            tb = bloom_render(m["targetBloom"])
+            row["targetBloomLabel"] = tb["bloomLabel"]
+            row["targetBloomOutcome"] = tb["bloomOutcome"]
+        row["evidence"] = evidence(entry)
+        row["evidenceKind"] = kind_of_module(m["name"])
+        modules.append(row)
+
+    untracked = [m["name"] for m in modules if not m["evidence"]["tracked"]]
+    unplanned = [{"module": e["module"], **{k: v for k, v in evidence(e).items()
+                                            if k not in ("tracked", "trackedAs")},
+                  "evidenceKind": kind_of_module(e["module"])}
+                 for k, e in tracked.items() if k not in matched_keys]
+
+    emit({
+        "project": state.get("projectName"),
+        "evidenceKind": _KINDS["project"],
+        "plan": phases is not None and bool(plan),
+        "phases": phases,
+        "position": position_row(plan, cur_idx),
+        "currentPhase": state.get("currentPhase"),
+        "overallCompletion": state.get("overallCompletion", 0),
+        "counts": {"modules": len(plan),
+                   "done": sum(1 for m in modules if m["status"] == "done"),
+                   "skipped": sum(1 for m in modules if m["status"] == "skipped"),
+                   "upcoming": sum(1 for m in modules if m["status"] == "upcoming")},
+        "modules": modules,
+        "untracked": untracked,
+        "unplanned": unplanned,
+        "warnings": warnings,
+    })
+
+
 def cmd_revision_brief(args):
     """What today's revision sheet is about (1.18.0). Read-only.
 
@@ -1704,6 +2247,7 @@
             **bloom_render(c.get("bloomLevel", 0)),
             "question": c.get("question", ""),
             "feynmanPassed": c.get("feynmanPassed", False),
+            "evidenceKind": effective_kind(c),
         })
     # the sheet is named after the taught concept when there is one
     studied.sort(key=lambda d: (0 if "teach" in d["sourcesToday"] else 1,
@@ -1752,10 +2296,13 @@
     feynman = sum(1 for c in concepts if c.get("feynmanPassed") is True)
     classified = sum(1 for c in concepts if c.get("bloomLevel", 0) > 0)
     applied = sum(1 for c in concepts if applied_evidence(c) >= 1)
+    by_report, solid_basis = solid_by_report(concepts)
 
     calibration = calibration_summary(data)
     calibration.pop("overconfidentEvents", None)
     calibration.pop("underconfidentEvents", None)
+    _, plan = read_plan_modules(args.project)
+    plan_idx, _ = plan_position(state, plan)
 
     emit({
         "project": {
@@ -1765,10 +2312,14 @@
             "currentPhase": state.get("currentPhase"),
             "currentModule": state.get("currentModule"),
             "currentModuleIndex": state.get("currentModuleIndex"),
+            "position": position_row(plan, plan_idx),
             "lastActivity": state.get("lastActivity"),
             "overallCompletion": state.get("overallCompletion", 0),
             "initialBloomLevel": state.get("initialBloomLevel", {}),
             "currentBloomLevel": state.get("currentBloomLevel", {}),
+            "evidenceKind": _KINDS["project"],
+            # /continue proposes a kind once when none was ever recorded.
+            "evidenceKindRecorded": state.get("evidenceKind") in EVIDENCE_KINDS,
         },
         "cadence": {
             "totalSessions": state.get("totalSessions", 0),
@@ -1783,6 +2334,8 @@
             "dueToday": len(r["dueToday"]), "dueTodayConcepts": r["dueToday"],
             "dueThisWeek": len(r["dueThisWeek"]), "dueThisWeekConcepts": r["dueThisWeek"],
             "overdue10dPlus": r["overdue10"],
+            "waitingForReviewDay": r["waiting"],
+            "unscheduled": r["unscheduled"],
             "boxDistribution": r["boxes"],
             "retentionRollup": {k: len(v) for k, v in r["rollup"].items()},
             "retentionConcepts": r["rollup"],
@@ -1798,6 +2351,8 @@
             "blockedOnFeynman": blocked_on_feynman(concepts),
             "blockedOnApplied": blocked_on_applied(concepts),
             "masteredDueForCheck": r["dueForCheck"],
+            "solidByReport": by_report,
+            "solidBasis": solid_basis,
         },
         "calibration": calibration,
         "bloomScale": bloom_scale(),
@@ -1813,10 +2368,7 @@
     through any number of later misses. Deferred entries are scheduling
     notes, not observations, and are skipped; archived entries are carried
     by archivedSummary."""
-    seed = archived_summary(c)
-    return _evidence_counts(c.get("reviewHistory", []),
-                            seed.get("appliedEvidence", 0) or 0,
-                            seed.get("evidenceAt3Plus", 0) or 0)[1]
+    return _evidence_counts(c.get("reviewHistory", []), archived_summary(c))["at3plus"]
 
 
 EXPOSURE_RANK = {"seeded": 0, "quizzed-only": 1, "demonstrated": 2,
@@ -1828,22 +2380,49 @@
     return s if isinstance(s, dict) else {}
 
 
-def _evidence_counts(entries, applied=0, at3plus=0):
-    """(applied corrects, level-3+ corrects) since the most recent miss over
-    `entries`, continuing from the seeds — the one loop behind
-    applied_evidence, evidence_at_3_plus and archive_history."""
+def built_by_of(summary):
+    """The unseen bases archived work was built on (archivedSummary.builtBy)."""
+    b = summary.get("builtBy")
+    return [x for x in b if x in ("inspected", "reported")] if isinstance(b, list) else []
+
+
+# archivedSummary key that seeds each count (state-schema KB).
+ARCHIVE_SEEDS = {"observed": "appliedEvidence", "inspected": "appliedInspected",
+                 "reported": "appliedReported", "at3plus": "evidenceAt3Plus"}
+
+
+def entry_basis(h):
+    """The basis of an applied entry, else None: the one place the `applied`
+    flag is read for counting (t_single_applied_reader), so no reader can
+    count unseen work as observed."""
+    return applied_basis(h) if h.get("applied") is True else None
+
+
+def applied_correct_basis(h):
+    """The basis of an applied correct, else None."""
+    return entry_basis(h) if h.get("result") == "correct" else None
+
+
+def _evidence_counts(entries, seed):
+    """Applied corrects per basis and level-3+ corrects, since the most
+    recent miss over `entries`, continuing from an archivedSummary `seed` —
+    the one loop behind applied_evidence, evidence_at_3_plus and
+    archive_history. Raw counts: which bases a concept admits is the
+    reader's question, asked through admits()."""
+    counts = {k: seed.get(key, 0) or 0 for k, key in ARCHIVE_SEEDS.items()}
     for h in entries:
         if not isinstance(h, dict) or h.get("deferred"):
             continue
         result = h.get("result")
         if result == "incorrect":
-            applied = at3plus = 0
+            counts = dict.fromkeys(counts, 0)
         elif result == "correct":
-            if h.get("applied") is True:
-                applied += 1
+            basis = applied_correct_basis(h)
+            if basis:
+                counts[basis] += 1
             if (h.get("bloomLevel") or 0) >= 3:
-                at3plus += 1
-    return applied, at3plus
+                counts["at3plus"] += 1
+    return counts
 
 
 def archive_history(c, archived):
@@ -1853,9 +2432,8 @@
     zero applied evidence, and to measure the first retained gap from
     `introduced`). Accumulates across repeated truncations."""
     prev = archived_summary(c)
-    applied, at3plus = _evidence_counts(
-        archived, prev.get("appliedEvidence", 0) or 0, prev.get("evidenceAt3Plus", 0) or 0)
-    summary = {"appliedEvidence": applied, "evidenceAt3Plus": at3plus}
+    counts = _evidence_counts(archived, prev)
+    summary = {"appliedEvidence": counts["observed"], "evidenceAt3Plus": counts["at3plus"]}
     dates = [h.get("date") for h in archived
              if isinstance(h, dict) and parse_date(h.get("date"))]
     through = dates[-1] if dates else prev.get("through")
@@ -1870,30 +2448,54 @@
     # recomputed from the seeds, so only these two are worth carrying.
     graded = [h for h in archived if isinstance(h, dict) and not h.get("deferred")
               and h.get("result") in ("correct", "incorrect", "partial")]
+    # `built` is the floor observed work sets; work on an unseen basis is
+    # kept as builtBy, so a later kind change can still admit or refuse it.
     exposure = prev.get("exposure")
-    if any(h.get("applied") is True and h.get("result") == "correct" for h in graded):
+    bases = {applied_correct_basis(h) for h in graded}
+    if "observed" in bases:
         exposure = "built"
     elif exposure != "built" and any(h.get("source") in INSTRUCTIONAL_SOURCES for h in graded):
         exposure = "taught"
     if exposure in ("taught", "built"):
         summary["exposure"] = exposure
+    for basis in ("inspected", "reported"):
+        if counts[basis]:
+            summary[ARCHIVE_SEEDS[basis]] = counts[basis]
+    built_by = [b for b in ("inspected", "reported") if b in bases or b in built_by_of(prev)]
+    if built_by:
+        summary["builtBy"] = built_by
     c["reviewHistoryArchived"] = (c.get("reviewHistoryArchived", 0) or 0) + len(archived)
     c["archivedSummary"] = summary
 
 
 def applied_evidence(c):
-    """Correct results demonstrated in working code (`applied: true`) SINCE
-    THE LAST MISS, the same reset as evidence_at_3_plus. The level says how
-    well the learner can *talk about* a concept; this says whether they have
-    *built* with it. Every recorded level before 1.20.0 came from an
+    """Correct results demonstrated in applied work (`applied: true`: code
+    that ran, or a written application on a non-code topic) SINCE THE LAST
+    MISS, the same reset as evidence_at_3_plus. The level says how well the
+    learner can *talk about* a concept; this says whether they have *used* it. Every recorded level before 1.20.0 came from an
     explanation or a quiz answer, so the gate and the mastery formula could
     be satisfied without the learner ever writing a line: the learner
     finding that motivated the flag. Deferred entries are skipped; archived
     entries are carried by archivedSummary."""
-    seed = archived_summary(c)
-    return _evidence_counts(c.get("reviewHistory", []),
-                            seed.get("appliedEvidence", 0) or 0,
-                            seed.get("evidenceAt3Plus", 0) or 0)[0]
+    counts = _evidence_counts(c.get("reviewHistory", []), archived_summary(c))
+    kind = effective_kind(c)
+    return sum(counts[b] for b in APPLIED_BASES if admits(kind, b))
+
+
+def strongest_basis(c):
+    """The best-seen basis among the applied corrects this concept's kind
+    admits since the last miss — observed, then inspected, then reported —
+    or None. A Solid resting on `reported` is Solid by the learner's
+    account, and every surface that shows it says so."""
+    counts = _evidence_counts(c.get("reviewHistory", []), archived_summary(c))
+    kind = effective_kind(c)
+    return next((b for b in APPLIED_BASES if counts[b] and admits(kind, b)), None)
+
+
+def solid_by_report(concepts):
+    """(names Solid on the learner's word alone, {Solid name: strongest basis})."""
+    basis = {c["name"]: strongest_basis(c) for c in concepts if is_mastered(c)}
+    return [n for n, b in basis.items() if b == "reported"], basis
 
 
 def last_miss(c):
@@ -1953,11 +2555,13 @@
     graded = [h for h in c.get("reviewHistory", []) if isinstance(h, dict)
               and not h.get("deferred")
               and h.get("result") in ("correct", "incorrect", "partial")]
-    floor = archived_summary(c).get("exposure")
-    if floor == "built" or any(h.get("applied") is True and h.get("result") == "correct"
-                               for h in graded):
+    summary = archived_summary(c)
+    kind = effective_kind(c)
+    bases = [applied_correct_basis(h) for h in graded] + built_by_of(summary)
+    if summary.get("exposure") == "built" or any(b and admits(kind, b) for b in bases):
         return "built"
-    if floor == "taught" or any(h.get("source") in INSTRUCTIONAL_SOURCES for h in graded):
+    if summary.get("exposure") == "taught" or any(h.get("source") in INSTRUCTIONAL_SOURCES
+                                                  for h in graded):
         return "taught"
     if not graded and not c.get("reviewHistoryArchived"):
         return "seeded"
@@ -2035,7 +2639,7 @@
     # seeded module a continuation session before it was ever taught and
     # the gate never fired on the journey it exists for (review finding 2b).
     in_module = [c for c in concepts
-                 if c.get("module", "").strip().lower() == current.strip().lower()]
+                 if norm_module_name(c.get("module", "")) == norm_module_name(current)]
     started = [c for c in in_module if any(
         not h.get("deferred") and h.get("result") in ("correct", "incorrect", "partial")
         for h in c.get("reviewHistory", []) if isinstance(h, dict))]
@@ -2051,7 +2655,14 @@
         return
     seeded_only = len(in_module)
 
-    # First session on a new module: identify prerequisites.
+    # First session on a new module: identify prerequisites. A plan may
+    # declare `none` — a module that builds on nothing tracked. Read as a
+    # concept name it matched nothing and reported a vacuous "clear".
+    if args.prereqs and re.match(r"\s*none\b", args.prereqs, re.IGNORECASE):
+        emit({"fires": False,
+              "reason": "the plan declares no prerequisites for this module",
+              "currentModule": current, "prerequisiteSource": "declared"})
+        return
     if args.prereqs:
         names = [n.strip().lower() for n in args.prereqs.split(",") if n.strip()]
         prereqs = [c for c in concepts
@@ -2075,7 +2686,7 @@
                   "currentModule": current})
             return
         prereqs = [c for c in concepts
-                   if c.get("module", "").strip().lower() == str(prior).strip().lower()]
+                   if norm_module_name(c.get("module", "")) == norm_module_name(str(prior))]
         source = "prior-module"
 
     report = []
@@ -2086,6 +2697,7 @@
                        "box": c.get("box", 1), "lastReviewed": c.get("lastReviewed"),
                        "evidenceAt3Plus": evidence_at_3_plus(c),
                        "appliedEvidence": applied_evidence(c),
+                       "evidenceKind": effective_kind(c),
                        "status": status, "reason": reason})
     gaps = [r for r in report if r["status"] == "gap"]
     stale = [r for r in report if r["status"] == "stale-reconfirm"]
@@ -2226,6 +2838,14 @@
                 state[singular] = state.pop(plural)
                 changes.append(f"renamed {plural} -> {singular}")
                 state_changes += 1
+        kinds = state.get("moduleEvidenceKinds")
+        for holder, key in [(state, "evidenceKind")] + [
+                (kinds, name) for name in (kinds if isinstance(kinds, dict) else {})]:
+            fixed = _coerce_choice(holder.get(key), EVIDENCE_KINDS)
+            if fixed is not None:
+                changes.append(f"evidence kind {holder[key]!r} -> {fixed!r}")
+                holder[key] = fixed
+                state_changes += 1
 
     srp = sr_path(args.project)
     sr = load_json(srp) if os.path.exists(srp) else None
@@ -2286,6 +2906,7 @@
 def cmd_verify(args):
     """Schema sanity check. Used by dev/check.sh and the Stop hook."""
     errors, warnings = [], []
+    module_kinds, tracked_modules = {}, set()
     project = args.project
     bdir = os.path.join(project, ".bodhi")
     if not os.path.isdir(bdir):
@@ -2331,6 +2952,22 @@
             if isinstance(dates, list) and len(dates) != len(set(dates)):
                 warnings.append("state.json sessionDates has duplicates — "
                                 "run `bodhi-state normalize`")
+            # Evidence kinds: readers treat a bad value as code, so it is an
+            # error only here, where it can be seen.
+            kinds = state.get("moduleEvidenceKinds")
+            if kinds is not None and not isinstance(kinds, dict):
+                errors.append(f"state.json moduleEvidenceKinds is "
+                              f"{type(kinds).__name__}, expected an object of "
+                              f"module name -> kind")
+                kinds = None
+            module_kinds = kinds or {}
+            for label, k in [("evidenceKind", state.get("evidenceKind"))] + [
+                    (f"moduleEvidenceKinds {name!r}", k) for name, k in module_kinds.items()]:
+                if k is not None and k not in EVIDENCE_KINDS:
+                    fix = _coerce_choice(k, EVIDENCE_KINDS)
+                    errors.append(f"state.json {label} {k!r} not "
+                                  f"{'|'.join(EVIDENCE_KINDS)} — readers treat it as code"
+                                  + (" — run `bodhi-state normalize`" if fix else ""))
         except (json.JSONDecodeError, OSError) as e:
             errors.append(f"state.json unreadable: {e}")
         except Exception as e:  # drift the checks above did not anticipate
@@ -2392,6 +3029,8 @@
                     for k in ("bloomLevel", "feynmanPassed", "consecutiveCorrectAtL4Plus"):
                         if k not in c:
                             errors.append(f"concept {name!r} missing v3 field {k!r}")
+                if isinstance(c.get("module"), str):
+                    tracked_modules.add(c["module"])
                 rh = c.get("reviewHistory", [])
                 if not isinstance(rh, list):
                     rh = []
@@ -2402,6 +3041,11 @@
                         errors.append(shape_error_text(name, f"reviewHistory {field}",
                                                        actual, expected, repair)
                                       + ("" if repair is not None else " — fix it by hand"))
+                    if "appliedBasis" in h and h.get("applied") is not True:
+                        warnings.append(f"concept {name!r} reviewHistory "
+                                        f"{h.get('date')!r} has appliedBasis "
+                                        f"{h['appliedBasis']!r} without applied: "
+                                        f"true — the basis is ignored")
                     if h.get("date") is not None and parse_date(h.get("date")) is None:
                         warnings.append(f"concept {name!r} reviewHistory date "
                                         f"{h.get('date')!r} is not an ISO date — "
@@ -2440,6 +3084,30 @@
         warnings.append("spaced-review.json missing while state.json exists — "
                         "the review schedule may have been deleted")
 
+    # Phase files nothing can parse: syllabus would read an empty plan.
+    phases, plan = read_plan_modules(project)
+    if phases and not plan:
+        warnings.append(PLAN_UNREADABLE)
+
+    # A module override joins on the normalized module name. Neither case
+    # below can be repaired by a subcommand, so neither blocks the Stop hook.
+    overridden = {norm_module_name(name) for name in module_kinds}
+    known = {norm_module_name(m["name"]) for m in plan} | {
+        norm_module_name(raw) for raw in tracked_modules}
+    for name in module_kinds:
+        if norm_module_name(name) not in known:
+            warnings.append(f"state.json moduleEvidenceKinds {name!r} matches no plan "
+                            f"module and no tracked concept (a renamed module?) — its "
+                            f"kind reaches nothing; record it under the current name")
+    by_id = {m["id"]: m for m in plan}
+    for raw in sorted(tracked_modules):
+        m = by_id.get(norm_module_name(raw))
+        if m and norm_module_name(raw) not in overridden \
+                and norm_module_name(m["name"]) in overridden:
+            warnings.append(f"concepts filed under plan id {raw!r} do not get module "
+                            f"{m['name']!r}'s evidence kind — the kind joins on the "
+                            f"module name")
+
     pp = find_profile(project)
     if pp:
         try:
@@ -2503,7 +3171,8 @@
 
 READ_ONLY_COMMANDS = {cmd_due, cmd_mastery, cmd_calibration, cmd_retention,
                       cmd_export_anonymized, cmd_session_brief, cmd_snapshot,
-                      cmd_gate_check, cmd_verify, cmd_revision_brief}
+                      cmd_gate_check, cmd_verify, cmd_revision_brief,
+                      cmd_syllabus}
 
 def main():
     p = argparse.ArgumentParser(prog="bodhi-state", description=__doc__,
@@ -2532,10 +3201,17 @@
     s.add_argument("--question", default="")
     s.add_argument("--note", default=None, help="short lastResult note")
     s.add_argument("--source", default=None, help="which skill produced this review")
-    s.add_argument("--applied", action="store_true",
-                   help="the outcome was demonstrated in working code the tutor "
-                        "read (exercise, driven piece), not an explanation or "
-                        "quiz answer; feeds the gate and the mastery formula")
+    s.add_argument("--applied", nargs="?", const="observed", default=None,
+                   choices=APPLIED_BASES,
+                   help="the outcome was demonstrated in applied work, not an "
+                        "explanation or quiz answer. Bare = observed: work the "
+                        "tutor read (code that ran; off code, a saved file, with "
+                        "--evidence). inspected (a photo the tutor examined) and "
+                        "reported (the learner's word) only on a performed "
+                        "concept. Feeds the gate and the mastery formula")
+    s.add_argument("--evidence", default=None,
+                   help="project-relative path of the applied work; required for "
+                        "observed work off code, beside the exercise's key")
     s.add_argument("--retry", action="store_true",
                    help="successive-relearning retry: history entry only, no "
                         "box/counter/bloom movement (the original demotion stands)")
@@ -2596,6 +3272,17 @@
     s.add_argument("--completion", type=int, default=None)
     s.set_defaults(fn=cmd_touch_state)
 
+    s = sub.add_parser("set-evidence-kind",
+                       help="record the project's evidence kind, or one module's "
+                            "(state.json only; every review is kept)")
+    s.add_argument("--kind", default=None, help="|".join(EVIDENCE_KINDS))
+    s.add_argument("--module", default=None,
+                   help="a plan module's name or id, or a tracked module; omit for the project")
+    s.add_argument("--clear", action="store_true",
+                   help="remove the kind: the project reads as code, a module "
+                        "takes the project's")
+    s.set_defaults(fn=cmd_set_evidence_kind)
+
     s = sub.add_parser("profile-add-project",
                        help="append a schema-complete activeProjects entry "
                             "(creates .bodhi-profile.projects.json if missing)")
@@ -2669,6 +3356,10 @@
                        help="single-call dashboard rollup for /progress: position, cadence, due, mastery, calibration (read-only)")
     s.set_defaults(fn=cmd_snapshot)
 
+    s = sub.add_parser("syllabus",
+                       help="plan order × review evidence × position: every plan module in order with status and module evidence (read-only)")
+    s.set_defaults(fn=cmd_syllabus)
+
     s = sub.add_parser("revision-brief",
                        help="today's studied concepts + the revision sheet file to write (read-only)")
     s.set_defaults(fn=cmd_revision_brief)
@@ -2698,6 +3389,7 @@
     profile = find_profile(args.project)
     if profile:
         acquire_lock(os.path.dirname(profile), shared=shared)
+    install_kinds(args.project)
     args.fn(args)
 
 
Full technical diff · 69 changed fields

removed /files/.bodhikit-openai-build.json

BEFORE
{
  "sha256": "1d03575fb38b6cc49a232b2de36098c622a225bad62c7beb6f6071573505f831",
  "size": 54
}
AFTER
Field is absent

removed /files/.codex-plugin~1assets~1composer-icon.png

BEFORE
{
  "sha256": "86a71e096653ce6d87bca9c9dde2e433d1e4f85969b12295c06693cbfa7e43f6",
  "size": 707205
}
AFTER
Field is absent

removed /files/.codex-plugin~1assets~1logo-dark.png

BEFORE
{
  "sha256": "86a71e096653ce6d87bca9c9dde2e433d1e4f85969b12295c06693cbfa7e43f6",
  "size": 707205
}
AFTER
Field is absent

removed /files/.codex-plugin~1assets~1logo.png

BEFORE
{
  "sha256": "86a71e096653ce6d87bca9c9dde2e433d1e4f85969b12295c06693cbfa7e43f6",
  "size": 707205
}
AFTER
Field is absent

changed /files/.codex-plugin~1plugin.json/sha256

BEFORE
"7959ec5f20385f90ad1720f6a4f9083ccb59e15c49642e97d55819400e5cfdd5"
AFTER
"9776b01d162c5c0b103d15cfc3e9fc8145827aa2ab7392669cd6f24acb329136"

changed /files/.codex-plugin~1plugin.json/size

BEFORE
1671
AFTER
1908

removed /files/hooks~1hooks.json

BEFORE
{
  "sha256": "11b4389d7c8dc89b00a6999e10826679d2b37d9b5efb2e741ec4f354b689555a",
  "size": 889
}
AFTER
Field is absent

changed /files/references~1knowledge~1ai-learning-safeguards.md/sha256

BEFORE
"40c973d8ffd3569b32d9217c1debda5df3c12ed985ad2e7a5bc1039a2d3d8d66"
AFTER
"46a09877f8c0fa3446445fbb2295a06da3e900625bb91731b6cf81148e33f057"

changed /files/references~1knowledge~1ai-learning-safeguards.md/size

BEFORE
1196
AFTER
1301

changed /files/references~1knowledge~1blooms-taxonomy.md/sha256

BEFORE
"b89da24b39d282696e37903711751cd27277b362891346e13c76ecc100c107b9"
AFTER
"0b454bdeff7d48a2f46d215ddb1a03d1f7839db1f827240261acce18fcfdf6e0"

changed /files/references~1knowledge~1blooms-taxonomy.md/size

BEFORE
8639
AFTER
10995

changed /files/references~1knowledge~1difficulty-calibration.md/sha256

BEFORE
"d9ac4f84c33c9208364f970c27acb4335a8a04254a7a6756c95a9e4f08281628"
AFTER
"80309abfcff9f59de2ad0158d5eecf6c89a2ef928379a97055c5ab2a0059f625"

changed /files/references~1knowledge~1difficulty-calibration.md/size

BEFORE
7770
AFTER
10162

added /files/references~1knowledge~1evidence-kinds.md

BEFORE
Field was absent
AFTER
{
  "sha256": "202a49b10cbf7d48407460d27c19c320fd40550ac24bea0daecc18aab19ff0bc",
  "size": 12193
}

changed /files/references~1knowledge~1feynman-technique.md/sha256

BEFORE
"ea1a13191ebe3b0ca71b4cbcf777643f4e72ba4bb793ce41c4cdfacd4c9016bc"
AFTER
"dd86925a066e0f7de3035281b13919e4352235c77cd919f06df777f8c008125a"

changed /files/references~1knowledge~1feynman-technique.md/size

BEFORE
11666
AFTER
11805

changed /files/references~1knowledge~1growth-mindset.md/sha256

BEFORE
"41427b526924b874c120a55334adb6b4f3235d3a4c5ecb3bfb66115b5fb71f8b"
AFTER
"c81ca91e7ff8308e6ed13bbe1448a160733c5fa67b88103348c320f7fe284b7c"

changed /files/references~1knowledge~1growth-mindset.md/size

BEFORE
1265
AFTER
1288

changed /files/references~1knowledge~1metacognition.md/sha256

BEFORE
"f56593c730a8864b3ba50bb1be09b0d2e4591d8c34b84b5cbaab9f0ea94e416a"
AFTER
"d7f5e8644584475b420b9c502331d2d30dd5a457fa176892e58e481535e55370"

changed /files/references~1knowledge~1metacognition.md/size

BEFORE
2652
AFTER
2669

changed /files/references~1knowledge~1state-lifecycle.md/sha256

BEFORE
"a49f5723b1288396e53824587013895a0bc4a7bb9fb258ed1920211ad5215c8e"
AFTER
"443bdfe1fecc2c680532ccc038e40c2ff92aeeca84899a513ceacae26081346a"

changed /files/references~1knowledge~1state-lifecycle.md/size

BEFORE
3887
AFTER
4440

changed /files/references~1knowledge~1state-ops.md/sha256

BEFORE
"37c0f073d26c2a7d0dad4892dd43e3a32405652cfea6b59d8b716421c239558e"
AFTER
"a77f098709d55a2187e5df9af2597e930efa87aff910c381aa21df293bf0ecfa"

changed /files/references~1knowledge~1state-ops.md/size

BEFORE
18095
AFTER
18538

changed /files/references~1knowledge~1state-schema.md/sha256

BEFORE
"e33a2207fd74dc8abd454630180b45cdef37d8f0d849c43b20e21c16ad834559"
AFTER
"a16852c8ef939038ebf52c044281e6d84bf2615b7803d3f3fc4e6867381c649e"

changed /files/references~1knowledge~1state-schema.md/size

BEFORE
18478
AFTER
18340

changed /files/references~1knowledge~1teaching-personality.md/sha256

BEFORE
"02238d3459cc7043fa730fada21ab8b187dc007f59fbedf5b7b798217daa12c1"
AFTER
"36df03d962712b5ae516c5aa2c0d9c04a6ec5061e3df108dee2fd3d13af08282"

changed /files/references~1knowledge~1teaching-personality.md/size

BEFORE
7879
AFTER
8289

changed /files/references~1openai-runtime.md/sha256

BEFORE
"930178414b69fc46abdd2da1b3ddd66714c7ac8b506235ddbc0dc0ef2f4a1d0f"
AFTER
"7548e204b9649b95ce3592cf6598ab8ea68fb6cb01ac6b865ae877bc5754200a"

changed /files/references~1openai-runtime.md/size

BEFORE
3172
AFTER
5791

changed /files/references~1roles~1resource-finder.md/sha256

BEFORE
"9a3a69e4f6bae00fca0eacc1aca03b516df888ec1ee5bfeb656e457a522d0922"
AFTER
"926bfc0af602b20b5676aec91bb49e2d4545d99fc6be08f5ea137147aeaffcaf"

changed /files/references~1roles~1resource-finder.md/size

BEFORE
2540
AFTER
3391

changed /files/references~1roles~1skill-assessor.md/sha256

BEFORE
"7458796e080cef18e3d280d8b044031c029af0f4febb0d42c6d61c745b8e601d"
AFTER
"9255b876bc292a56f3bdf7f26a7a60933717f1f524339b80bb0e4413f0128856"

changed /files/references~1roles~1skill-assessor.md/size

BEFORE
4575
AFTER
5044

changed /files/rules~1learning-project.md/sha256

BEFORE
"b5c0f0a6b812bf283d05f290a7fc37aa3e5da39698fd4f1b59f8929ba4cab5ca"
AFTER
"750e4231e54a74092eb318de07df7f01aa9eca5fec5d8859a00fbb7b5edfea18"

changed /files/rules~1learning-project.md/size

BEFORE
2274
AFTER
2990

removed /files/scripts~1bodhi-session-context.py

BEFORE
{
  "sha256": "d866d4defd9522badf7c9c50d23b3ba89b08b4a8b3cdaa958e69ac4a9f62d506",
  "size": 1886
}
AFTER
Field is absent

changed /files/scripts~1bodhi-state/sha256

BEFORE
"576cc4ef92a0b936750b8a6aed55bfa332a07debd08c79f1fa635a3f4d5f3fa7"
AFTER
"7578378453a67cb2af5314c11ed8bce9d6db0e0807bcea667b08b19fa6670d15"

changed /files/scripts~1bodhi-state/size

BEFORE
122992
AFTER
156837

removed /files/scripts~1bodhi-stop-hook-core.py

BEFORE
{
  "sha256": "84b2626cfa6f0ad5a8f91477dbfb3718b1554be21275590ce84eae47f1bd615d",
  "size": 10762
}
AFTER
Field is absent

removed /files/scripts~1bodhi-stop-hook.py

BEFORE
{
  "sha256": "06c22911aac5c4962ec0d434d77cfba42cc3c47e0b47baec5605697bd394a319",
  "size": 6597
}
AFTER
Field is absent

changed /files/skills~1assess~1SKILL.md/sha256

BEFORE
"ce0d80b010f7753462336212e486084e6b0c89a758eec9265c61b0e507a21cf5"
AFTER
"39dba44d9b53dc968b029806fc96fcf9e6e4eab9b64c444508e5e15e6ee97dda"

changed /files/skills~1assess~1SKILL.md/size

BEFORE
4960
AFTER
5238

changed /files/skills~1continue~1SKILL.md/sha256

BEFORE
"41e702398f6b386425c3642992dce47a9e5296e9f6892b79c7865474c3c911a5"
AFTER
"53c556696123c7dbfeaf27bd901737798fddce6fce6a0c3b830b32acbfb604fd"

changed /files/skills~1continue~1SKILL.md/size

BEFORE
12579
AFTER
13946

changed /files/skills~1learn~1SKILL.md/sha256

BEFORE
"e264313bd7d21da17b25b322a3ded61537499aa9eb02139106bba65c6087dc6a"
AFTER
"024120add5310120e8de56e525539e62d2ebcd4fb1467847b47996873fd1d299"

changed /files/skills~1learn~1SKILL.md/size

BEFORE
16378
AFTER
18650

added /files/skills~1learn~1references~1host-without-files.md

BEFORE
Field was absent
AFTER
{
  "sha256": "16638a6b467f2a50e10f5efd021c90fbde9041bc9c9fb0bc8917af2f22103167",
  "size": 3212
}

changed /files/skills~1plan~1SKILL.md/sha256

BEFORE
"649dc8184dfbbcbfff11475c9d477e98b5ec3eb1bfd0c8dc0f8f08c6ad252242"
AFTER
"394343a9b57f70888689903bf1c2fb31f8cd7fe2b251c894b2d5d225324dbde8"

changed /files/skills~1plan~1SKILL.md/size

BEFORE
7906
AFTER
9495

changed /files/skills~1practice~1SKILL.md/sha256

BEFORE
"a06c910e2a65faddcedfe9fca03b9f7b38f1c8e0488021c23ce10daa32afe401"
AFTER
"b7efcb8d6fa3ace4fa97e1bb6a57776603d5683b7b08c660ec6faaf1dec7b1c8"

changed /files/skills~1practice~1SKILL.md/size

BEFORE
14638
AFTER
15974

changed /files/skills~1progress~1SKILL.md/sha256

BEFORE
"dd89df465be4bda1ad8761f84f25337a66a30109c06d6a5688c03a03df260095"
AFTER
"d56fb4517b5ec518215562fa58971308c3c80de9c7f3b3aed749154c84a52d79"

changed /files/skills~1progress~1SKILL.md/size

BEFORE
15300
AFTER
15871

added /files/skills~1progress~1references~1module-row.md

BEFORE
Field was absent
AFTER
{
  "sha256": "f42ee8834c66c00e07fecb3673aec92dc912a351d008f5d0cbe928963fbb377e",
  "size": 3457
}

changed /files/skills~1quiz~1SKILL.md/sha256

BEFORE
"0a67eed819797201d6439e11f69a3700f5c294a6a200cb575ac7a59458b19bb5"
AFTER
"467265ee8d719aa13cc7b32ca74928a05adc6d492fc613b69a0a011e6a1b1d78"

changed /files/skills~1quiz~1SKILL.md/size

BEFORE
12383
AFTER
13178

changed /files/skills~1reflect~1SKILL.md/sha256

BEFORE
"679eec247211145739d2457049ecaa9225e8d0695b64613c43c2d8f1cecc3b13"
AFTER
"0f9fe2b497677d900c03bb45ea772dc49b7b2e5315153b09d7de2c28771a3547"

changed /files/skills~1reflect~1SKILL.md/size

BEFORE
13368
AFTER
13690

changed /files/skills~1reflect~1references~1revision-sheet.md/sha256

BEFORE
"b1d82c3a4dd5cf049eae9ebeb10ec1806604eed9734a31acfbef99f1df270943"
AFTER
"40b9a5a76f36d33fb5a9c45283a51715958938e12c6561df62b6ad7e9e915db8"

changed /files/skills~1reflect~1references~1revision-sheet.md/size

BEFORE
3648
AFTER
4299

changed /files/skills~1resources~1SKILL.md/sha256

BEFORE
"22004fad8974cfb0bf2c6c0fdadac3b24153384b50b404ebf90cb40ef02d58d5"
AFTER
"3550f8bbaa6920bbfb0fb0957d8a51d1a30fce034cf163c63352b32b5d62de79"

changed /files/skills~1resources~1SKILL.md/size

BEFORE
5517
AFTER
5773

changed /files/skills~1review~1SKILL.md/sha256

BEFORE
"f873e695ef76a565521295911a98607166fb23a56c414f69ad7aaf1e873a7673"
AFTER
"f2130a6ce3514ea078cf863e5e028fe32277cfe8be7d2ff0288d499cad7c9ea6"

changed /files/skills~1review~1SKILL.md/size

BEFORE
5075
AFTER
5731

changed /files/skills~1teach~1SKILL.md/sha256

BEFORE
"8a5ab774f1c1f3737d5d55ed3ca97447f19f3588dd832b88669668f32f264fe0"
AFTER
"1cc10e630e6eb7044c67826296c1c51ee17affa40ea9c68257595069ad697e91"

changed /files/skills~1teach~1SKILL.md/size

BEFORE
18056
AFTER
18792

changed /files/skills~1teach~1references~1prerequisite-gate.md/sha256

BEFORE
"78fb9e5cdb60d7169cb796a91fb5286b97852ea59b296ccc58cd3fa4b85658b6"
AFTER
"0c9213e3a711c6df19c1631b1ce608aa31539d6f76e81835590abbec1ffa2ce5"

changed /files/skills~1teach~1references~1prerequisite-gate.md/size

BEFORE
3910
AFTER
4591
Full snapshot data
{
  "files": {
    ".codex-plugin/plugin.json": {
      "sha256": "9776b01d162c5c0b103d15cfc3e9fc8145827aa2ab7392669cd6f24acb329136",
      "size": 1908
    },
    "assets/bodhikit-icon.png": {
      "sha256": "86a71e096653ce6d87bca9c9dde2e433d1e4f85969b12295c06693cbfa7e43f6",
      "size": 707205
    },
    "references/knowledge/ai-learning-safeguards.md": {
      "sha256": "46a09877f8c0fa3446445fbb2295a06da3e900625bb91731b6cf81148e33f057",
      "size": 1301
    },
    "references/knowledge/assessment-framework.md": {
      "sha256": "94236e74f3b6986d118e8ed18c3a9c205f8ae7b04259c11ebdca22d52a7a6cb6",
      "size": 8740
    },
    "references/knowledge/blooms-taxonomy.md": {
      "sha256": "0b454bdeff7d48a2f46d215ddb1a03d1f7839db1f827240261acce18fcfdf6e0",
      "size": 10995
    },
    "references/knowledge/constructivism.md": {
      "sha256": "f68b61167d15eff336edc64eb4bacc2ac0b8c38928e671c31870c7fc278e6dbe",
      "size": 1399
    },
    "references/knowledge/deliberate-practice.md": {
      "sha256": "624b614001793674918ee2a143c8eb6678d9f59b05d1427a043838b439b3cfbc",
      "size": 1418
    },
    "references/knowledge/difficulty-calibration.md": {
      "sha256": "80309abfcff9f59de2ad0158d5eecf6c89a2ef928379a97055c5ab2a0059f625",
      "size": 10162
    },
    "references/knowledge/evidence-kinds.md": {
      "sha256": "202a49b10cbf7d48407460d27c19c320fd40550ac24bea0daecc18aab19ff0bc",
      "size": 12193
    },
    "references/knowledge/feynman-technique.md": {
      "sha256": "dd86925a066e0f7de3035281b13919e4352235c77cd919f06df777f8c008125a",
      "size": 11805
    },
    "references/knowledge/growth-mindset.md": {
      "sha256": "c81ca91e7ff8308e6ed13bbe1448a160733c5fa67b88103348c320f7fe284b7c",
      "size": 1288
    },
    "references/knowledge/mentoring-theory.md": {
      "sha256": "30600120e7c5f7deefc0d0b8d3d59de32e25d0661402bfb693d6569dd2a0d030",
      "size": 1278
    },
    "references/knowledge/metacognition.md": {
      "sha256": "d7f5e8644584475b420b9c502331d2d30dd5a457fa176892e58e481535e55370",
      "size": 2669
    },
    "references/knowledge/pair-programming.md": {
      "sha256": "b3d7dc3fd7383205d93528223240718001f962b4fb138c79104984bf19060b5c",
      "size": 1382
    },
    "references/knowledge/scientific-debugging.md": {
      "sha256": "b92ad48403876021820918eee418e533223aa4af90560f991decf4e2349c74db",
      "size": 2008
    },
    "references/knowledge/spaced-repetition.md": {
      "sha256": "95529863ce3dd22bcaa1edec564666f7c794e806037670666970a9661a83c16f",
      "size": 6939
    },
    "references/knowledge/state-lifecycle.md": {
      "sha256": "443bdfe1fecc2c680532ccc038e40c2ff92aeeca84899a513ceacae26081346a",
      "size": 4440
    },
    "references/knowledge/state-migration.md": {
      "sha256": "6da9a09846019003bd785a6bb9e7447cb0b610676536c07431209c06f429477a",
      "size": 10368
    },
    "references/knowledge/state-ops.md": {
      "sha256": "a77f098709d55a2187e5df9af2597e930efa87aff910c381aa21df293bf0ecfa",
      "size": 18538
    },
    "references/knowledge/state-schema.md": {
      "sha256": "a16852c8ef939038ebf52c044281e6d84bf2615b7803d3f3fc4e6867381c649e",
      "size": 18340
    },
    "references/knowledge/teaching-personality.md": {
      "sha256": "36df03d962712b5ae516c5aa2c0d9c04a6ec5061e3df108dee2fd3d13af08282",
      "size": 8289
    },
    "references/openai-runtime.md": {
      "sha256": "7548e204b9649b95ce3592cf6598ab8ea68fb6cb01ac6b865ae877bc5754200a",
      "size": 5791
    },
    "references/roles/code-reviewer.md": {
      "sha256": "d897ff4689f4b65511470c60e8799ae06996ce789de3250c88dc2461936e210b",
      "size": 4364
    },
    "references/roles/resource-finder.md": {
      "sha256": "926bfc0af602b20b5676aec91bb49e2d4545d99fc6be08f5ea137147aeaffcaf",
      "size": 3391
    },
    "references/roles/skill-assessor.md": {
      "sha256": "9255b876bc292a56f3bdf7f26a7a60933717f1f524339b80bb0e4413f0128856",
      "size": 5044
    },
    "references/roles/trajectory-analyzer.md": {
      "sha256": "9adf09de763028ed87b968776e2ab5bd599263c81b8c50b9c354ad73cad67a52",
      "size": 9555
    },
    "rules/learning-project.md": {
      "sha256": "750e4231e54a74092eb318de07df7f01aa9eca5fec5d8859a00fbb7b5edfea18",
      "size": 2990
    },
    "scripts/bodhi-state": {
      "sha256": "7578378453a67cb2af5314c11ed8bce9d6db0e0807bcea667b08b19fa6670d15",
      "size": 156837
    },
    "skills/assess/SKILL.md": {
      "sha256": "39dba44d9b53dc968b029806fc96fcf9e6e4eab9b64c444508e5e15e6ee97dda",
      "size": 5238
    },
    "skills/continue/SKILL.md": {
      "sha256": "53c556696123c7dbfeaf27bd901737798fddce6fce6a0c3b830b32acbfb604fd",
      "size": 13946
    },
    "skills/debug-together/SKILL.md": {
      "sha256": "6e62ded8a7f189ec78f8a21750cfad31ca7229f0d1968169faf869d0bb6de6a8",
      "size": 9725
    },
    "skills/evaluate/SKILL.md": {
      "sha256": "164c2e003501727cf05bc3c237ec7e4d9efc0b6cde6c6d238ccd0e1f373a6176",
      "size": 15607
    },
    "skills/forget/SKILL.md": {
      "sha256": "d641f0b5bf1c1da25e193e0a4e2aa0c3ac2fd1b9a9026ebc8c791a277c4d283e",
      "size": 5837
    },
    "skills/housekeep/SKILL.md": {
      "sha256": "d63e45fecf3006b919884f7166dc102cf3423e805b4c681ef45830ae58063431",
      "size": 16089
    },
    "skills/learn/SKILL.md": {
      "sha256": "024120add5310120e8de56e525539e62d2ebcd4fb1467847b47996873fd1d299",
      "size": 18650
    },
    "skills/learn/references/host-without-files.md": {
      "sha256": "16638a6b467f2a50e10f5efd021c90fbde9041bc9c9fb0bc8917af2f22103167",
      "size": 3212
    },
    "skills/mentor/SKILL.md": {
      "sha256": "ef99a69b02abd8f33fc10c211622fbd9e385726debffd4f4c6d21af8db420605",
      "size": 8784
    },
    "skills/pair/SKILL.md": {
      "sha256": "8812ed3d05efc19f4a92f7a315e00602051905d77df364089b3ed87bb92d53e7",
      "size": 14249
    },
    "skills/plan/SKILL.md": {
      "sha256": "394343a9b57f70888689903bf1c2fb31f8cd7fe2b251c894b2d5d225324dbde8",
      "size": 9495
    },
    "skills/practice/SKILL.md": {
      "sha256": "b7efcb8d6fa3ace4fa97e1bb6a57776603d5683b7b08c660ec6faaf1dec7b1c8",
      "size": 15974
    },
    "skills/progress/SKILL.md": {
      "sha256": "d56fb4517b5ec518215562fa58971308c3c80de9c7f3b3aed749154c84a52d79",
      "size": 15871
    },
    "skills/progress/references/module-row.md": {
      "sha256": "f42ee8834c66c00e07fecb3673aec92dc912a351d008f5d0cbe928963fbb377e",
      "size": 3457
    },
    "skills/quiz/SKILL.md": {
      "sha256": "467265ee8d719aa13cc7b32ca74928a05adc6d492fc613b69a0a011e6a1b1d78",
      "size": 13178
    },
    "skills/reflect/SKILL.md": {
      "sha256": "0f9fe2b497677d900c03bb45ea772dc49b7b2e5315153b09d7de2c28771a3547",
      "size": 13690
    },
    "skills/reflect/references/revision-sheet.md": {
      "sha256": "40b9a5a76f36d33fb5a9c45283a51715958938e12c6561df62b6ad7e9e915db8",
      "size": 4299
    },
    "skills/resources/SKILL.md": {
      "sha256": "3550f8bbaa6920bbfb0fb0957d8a51d1a30fce034cf163c63352b32b5d62de79",
      "size": 5773
    },
    "skills/review/SKILL.md": {
      "sha256": "f2130a6ce3514ea078cf863e5e028fe32277cfe8be7d2ff0288d499cad7c9ea6",
      "size": 5731
    },
    "skills/teach-back/SKILL.md": {
      "sha256": "efb0015bb54fb98ac157ea08de91454d25b4d02fee8b95c24be02dc7f4b2d45f",
      "size": 18553
    },
    "skills/teach/SKILL.md": {
      "sha256": "1cc10e630e6eb7044c67826296c1c51ee17affa40ea9c68257595069ad697e91",
      "size": 18792
    },
    "skills/teach/references/prerequisite-gate.md": {
      "sha256": "0c9213e3a711c6df19c1631b1ce608aa31539d6f76e81835590abbec1ffa2ce5",
      "size": 4591
    },
    "skills/teach/references/understanding-only.md": {
      "sha256": "498d255b957913ede9e16f60c0ec8595ac521c5f8c487035576e1f29867b58c1",
      "size": 3038
    }
  }
}

SHA-256 of public snapshot: 7f497de0fe011e3278211f20d37e9745886d72b8342c3161551b3f92945dd723