Update to BodhiKit
Snapshot Oct 29, 2026 · 18:37 UTC · version 1.25.1
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.
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
Sep 30, 2026 · 23:15 UTC
{"sha256":"1d03575fb38b6cc49a232b2de36098c622a225bad62c7beb6f6071573505f831","size":54}
Oct 29, 2026 · 18:37 UTC
Not present
Package file
Sep 30, 2026 · 23:15 UTC
{"sha256":"86a71e096653ce6d87bca9c9dde2e433d1e4f85969b12295c06693cbfa7e43f6","size":707205}
Oct 29, 2026 · 18:37 UTC
Not present
Package file
Sep 30, 2026 · 23:15 UTC
{"sha256":"86a71e096653ce6d87bca9c9dde2e433d1e4f85969b12295c06693cbfa7e43f6","size":707205}
Oct 29, 2026 · 18:37 UTC
Not present
Package file
Sep 30, 2026 · 23:15 UTC
{"sha256":"86a71e096653ce6d87bca9c9dde2e433d1e4f85969b12295c06693cbfa7e43f6","size":707205}
Oct 29, 2026 · 18:37 UTC
Not present
Compare saved observations
Download comparison JSONChanged files
Choose a file to see its text changes. Binary or large files show metadata only.
.codex-plugin/assets/composer-icon.png →
.codex-plugin/assets/logo-dark.png →
.codex-plugin/assets/logo.png →
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 →
scripts/bodhi-session-context.py →
scripts/bodhi-stop-hook-core.py →
skills/learn/references/host-without-files.md →
skills/progress/references/module-row.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
{
"sha256": "1d03575fb38b6cc49a232b2de36098c622a225bad62c7beb6f6071573505f831",
"size": 54
}Field is absent
removed /files/.codex-plugin~1assets~1composer-icon.png
{
"sha256": "86a71e096653ce6d87bca9c9dde2e433d1e4f85969b12295c06693cbfa7e43f6",
"size": 707205
}Field is absent
removed /files/.codex-plugin~1assets~1logo-dark.png
{
"sha256": "86a71e096653ce6d87bca9c9dde2e433d1e4f85969b12295c06693cbfa7e43f6",
"size": 707205
}Field is absent
removed /files/.codex-plugin~1assets~1logo.png
{
"sha256": "86a71e096653ce6d87bca9c9dde2e433d1e4f85969b12295c06693cbfa7e43f6",
"size": 707205
}Field is absent
changed /files/.codex-plugin~1plugin.json/sha256
"7959ec5f20385f90ad1720f6a4f9083ccb59e15c49642e97d55819400e5cfdd5"
"9776b01d162c5c0b103d15cfc3e9fc8145827aa2ab7392669cd6f24acb329136"
changed /files/.codex-plugin~1plugin.json/size
1671
1908
removed /files/hooks~1hooks.json
{
"sha256": "11b4389d7c8dc89b00a6999e10826679d2b37d9b5efb2e741ec4f354b689555a",
"size": 889
}Field is absent
changed /files/references~1knowledge~1ai-learning-safeguards.md/sha256
"40c973d8ffd3569b32d9217c1debda5df3c12ed985ad2e7a5bc1039a2d3d8d66"
"46a09877f8c0fa3446445fbb2295a06da3e900625bb91731b6cf81148e33f057"
changed /files/references~1knowledge~1ai-learning-safeguards.md/size
1196
1301
changed /files/references~1knowledge~1blooms-taxonomy.md/sha256
"b89da24b39d282696e37903711751cd27277b362891346e13c76ecc100c107b9"
"0b454bdeff7d48a2f46d215ddb1a03d1f7839db1f827240261acce18fcfdf6e0"
changed /files/references~1knowledge~1blooms-taxonomy.md/size
8639
10995
changed /files/references~1knowledge~1difficulty-calibration.md/sha256
"d9ac4f84c33c9208364f970c27acb4335a8a04254a7a6756c95a9e4f08281628"
"80309abfcff9f59de2ad0158d5eecf6c89a2ef928379a97055c5ab2a0059f625"
changed /files/references~1knowledge~1difficulty-calibration.md/size
7770
10162
added /files/references~1knowledge~1evidence-kinds.md
Field was absent
{
"sha256": "202a49b10cbf7d48407460d27c19c320fd40550ac24bea0daecc18aab19ff0bc",
"size": 12193
}changed /files/references~1knowledge~1feynman-technique.md/sha256
"ea1a13191ebe3b0ca71b4cbcf777643f4e72ba4bb793ce41c4cdfacd4c9016bc"
"dd86925a066e0f7de3035281b13919e4352235c77cd919f06df777f8c008125a"
changed /files/references~1knowledge~1feynman-technique.md/size
11666
11805
changed /files/references~1knowledge~1growth-mindset.md/sha256
"41427b526924b874c120a55334adb6b4f3235d3a4c5ecb3bfb66115b5fb71f8b"
"c81ca91e7ff8308e6ed13bbe1448a160733c5fa67b88103348c320f7fe284b7c"
changed /files/references~1knowledge~1growth-mindset.md/size
1265
1288
changed /files/references~1knowledge~1metacognition.md/sha256
"f56593c730a8864b3ba50bb1be09b0d2e4591d8c34b84b5cbaab9f0ea94e416a"
"d7f5e8644584475b420b9c502331d2d30dd5a457fa176892e58e481535e55370"
changed /files/references~1knowledge~1metacognition.md/size
2652
2669
changed /files/references~1knowledge~1state-lifecycle.md/sha256
"a49f5723b1288396e53824587013895a0bc4a7bb9fb258ed1920211ad5215c8e"
"443bdfe1fecc2c680532ccc038e40c2ff92aeeca84899a513ceacae26081346a"
changed /files/references~1knowledge~1state-lifecycle.md/size
3887
4440
changed /files/references~1knowledge~1state-ops.md/sha256
"37c0f073d26c2a7d0dad4892dd43e3a32405652cfea6b59d8b716421c239558e"
"a77f098709d55a2187e5df9af2597e930efa87aff910c381aa21df293bf0ecfa"
changed /files/references~1knowledge~1state-ops.md/size
18095
18538
changed /files/references~1knowledge~1state-schema.md/sha256
"e33a2207fd74dc8abd454630180b45cdef37d8f0d849c43b20e21c16ad834559"
"a16852c8ef939038ebf52c044281e6d84bf2615b7803d3f3fc4e6867381c649e"
changed /files/references~1knowledge~1state-schema.md/size
18478
18340
changed /files/references~1knowledge~1teaching-personality.md/sha256
"02238d3459cc7043fa730fada21ab8b187dc007f59fbedf5b7b798217daa12c1"
"36df03d962712b5ae516c5aa2c0d9c04a6ec5061e3df108dee2fd3d13af08282"
changed /files/references~1knowledge~1teaching-personality.md/size
7879
8289
changed /files/references~1openai-runtime.md/sha256
"930178414b69fc46abdd2da1b3ddd66714c7ac8b506235ddbc0dc0ef2f4a1d0f"
"7548e204b9649b95ce3592cf6598ab8ea68fb6cb01ac6b865ae877bc5754200a"
changed /files/references~1openai-runtime.md/size
3172
5791
changed /files/references~1roles~1resource-finder.md/sha256
"9a3a69e4f6bae00fca0eacc1aca03b516df888ec1ee5bfeb656e457a522d0922"
"926bfc0af602b20b5676aec91bb49e2d4545d99fc6be08f5ea137147aeaffcaf"
changed /files/references~1roles~1resource-finder.md/size
2540
3391
changed /files/references~1roles~1skill-assessor.md/sha256
"7458796e080cef18e3d280d8b044031c029af0f4febb0d42c6d61c745b8e601d"
"9255b876bc292a56f3bdf7f26a7a60933717f1f524339b80bb0e4413f0128856"
changed /files/references~1roles~1skill-assessor.md/size
4575
5044
changed /files/rules~1learning-project.md/sha256
"b5c0f0a6b812bf283d05f290a7fc37aa3e5da39698fd4f1b59f8929ba4cab5ca"
"750e4231e54a74092eb318de07df7f01aa9eca5fec5d8859a00fbb7b5edfea18"
changed /files/rules~1learning-project.md/size
2274
2990
removed /files/scripts~1bodhi-session-context.py
{
"sha256": "d866d4defd9522badf7c9c50d23b3ba89b08b4a8b3cdaa958e69ac4a9f62d506",
"size": 1886
}Field is absent
changed /files/scripts~1bodhi-state/sha256
"576cc4ef92a0b936750b8a6aed55bfa332a07debd08c79f1fa635a3f4d5f3fa7"
"7578378453a67cb2af5314c11ed8bce9d6db0e0807bcea667b08b19fa6670d15"
changed /files/scripts~1bodhi-state/size
122992
156837
removed /files/scripts~1bodhi-stop-hook-core.py
{
"sha256": "84b2626cfa6f0ad5a8f91477dbfb3718b1554be21275590ce84eae47f1bd615d",
"size": 10762
}Field is absent
removed /files/scripts~1bodhi-stop-hook.py
{
"sha256": "06c22911aac5c4962ec0d434d77cfba42cc3c47e0b47baec5605697bd394a319",
"size": 6597
}Field is absent
changed /files/skills~1assess~1SKILL.md/sha256
"ce0d80b010f7753462336212e486084e6b0c89a758eec9265c61b0e507a21cf5"
"39dba44d9b53dc968b029806fc96fcf9e6e4eab9b64c444508e5e15e6ee97dda"
changed /files/skills~1assess~1SKILL.md/size
4960
5238
changed /files/skills~1continue~1SKILL.md/sha256
"41e702398f6b386425c3642992dce47a9e5296e9f6892b79c7865474c3c911a5"
"53c556696123c7dbfeaf27bd901737798fddce6fce6a0c3b830b32acbfb604fd"
changed /files/skills~1continue~1SKILL.md/size
12579
13946
changed /files/skills~1learn~1SKILL.md/sha256
"e264313bd7d21da17b25b322a3ded61537499aa9eb02139106bba65c6087dc6a"
"024120add5310120e8de56e525539e62d2ebcd4fb1467847b47996873fd1d299"
changed /files/skills~1learn~1SKILL.md/size
16378
18650
added /files/skills~1learn~1references~1host-without-files.md
Field was absent
{
"sha256": "16638a6b467f2a50e10f5efd021c90fbde9041bc9c9fb0bc8917af2f22103167",
"size": 3212
}changed /files/skills~1plan~1SKILL.md/sha256
"649dc8184dfbbcbfff11475c9d477e98b5ec3eb1bfd0c8dc0f8f08c6ad252242"
"394343a9b57f70888689903bf1c2fb31f8cd7fe2b251c894b2d5d225324dbde8"
changed /files/skills~1plan~1SKILL.md/size
7906
9495
changed /files/skills~1practice~1SKILL.md/sha256
"a06c910e2a65faddcedfe9fca03b9f7b38f1c8e0488021c23ce10daa32afe401"
"b7efcb8d6fa3ace4fa97e1bb6a57776603d5683b7b08c660ec6faaf1dec7b1c8"
changed /files/skills~1practice~1SKILL.md/size
14638
15974
changed /files/skills~1progress~1SKILL.md/sha256
"dd89df465be4bda1ad8761f84f25337a66a30109c06d6a5688c03a03df260095"
"d56fb4517b5ec518215562fa58971308c3c80de9c7f3b3aed749154c84a52d79"
changed /files/skills~1progress~1SKILL.md/size
15300
15871
added /files/skills~1progress~1references~1module-row.md
Field was absent
{
"sha256": "f42ee8834c66c00e07fecb3673aec92dc912a351d008f5d0cbe928963fbb377e",
"size": 3457
}changed /files/skills~1quiz~1SKILL.md/sha256
"0a67eed819797201d6439e11f69a3700f5c294a6a200cb575ac7a59458b19bb5"
"467265ee8d719aa13cc7b32ca74928a05adc6d492fc613b69a0a011e6a1b1d78"
changed /files/skills~1quiz~1SKILL.md/size
12383
13178
changed /files/skills~1reflect~1SKILL.md/sha256
"679eec247211145739d2457049ecaa9225e8d0695b64613c43c2d8f1cecc3b13"
"0f9fe2b497677d900c03bb45ea772dc49b7b2e5315153b09d7de2c28771a3547"
changed /files/skills~1reflect~1SKILL.md/size
13368
13690
changed /files/skills~1reflect~1references~1revision-sheet.md/sha256
"b1d82c3a4dd5cf049eae9ebeb10ec1806604eed9734a31acfbef99f1df270943"
"40b9a5a76f36d33fb5a9c45283a51715958938e12c6561df62b6ad7e9e915db8"
changed /files/skills~1reflect~1references~1revision-sheet.md/size
3648
4299
changed /files/skills~1resources~1SKILL.md/sha256
"22004fad8974cfb0bf2c6c0fdadac3b24153384b50b404ebf90cb40ef02d58d5"
"3550f8bbaa6920bbfb0fb0957d8a51d1a30fce034cf163c63352b32b5d62de79"
changed /files/skills~1resources~1SKILL.md/size
5517
5773
changed /files/skills~1review~1SKILL.md/sha256
"f873e695ef76a565521295911a98607166fb23a56c414f69ad7aaf1e873a7673"
"f2130a6ce3514ea078cf863e5e028fe32277cfe8be7d2ff0288d499cad7c9ea6"
changed /files/skills~1review~1SKILL.md/size
5075
5731
changed /files/skills~1teach~1SKILL.md/sha256
"8a5ab774f1c1f3737d5d55ed3ca97447f19f3588dd832b88669668f32f264fe0"
"1cc10e630e6eb7044c67826296c1c51ee17affa40ea9c68257595069ad697e91"
changed /files/skills~1teach~1SKILL.md/size
18056
18792
changed /files/skills~1teach~1references~1prerequisite-gate.md/sha256
"78fb9e5cdb60d7169cb796a91fb5286b97852ea59b296ccc58cd3fa4b85658b6"
"0c9213e3a711c6df19c1631b1ce608aa31539d6f76e81835590abbec1ffa2ce5"
changed /files/skills~1teach~1references~1prerequisite-gate.md/size
3910
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