learningfoundry 0.79.3__tar.gz → 0.83.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/CHANGELOG.md +92 -0
  2. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/PKG-INFO +88 -5
  3. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/README.md +85 -4
  4. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/pyproject.toml +8 -1
  5. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/__init__.py +1 -1
  6. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/asset_resolver.py +11 -2
  7. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/cli.py +114 -0
  8. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/exceptions.py +25 -0
  9. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/generator.py +61 -6
  10. learningfoundry-0.83.0/src/learningfoundry/integrations/nbfoundry.py +54 -0
  11. learningfoundry-0.83.0/src/learningfoundry/integrations/nbfoundry_stub.py +49 -0
  12. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/integrations/protocols.py +4 -1
  13. learningfoundry-0.83.0/src/learningfoundry/launch.py +349 -0
  14. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/resolver.py +80 -5
  15. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/schema_v1.py +58 -0
  16. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/ContentBlock.svelte +4 -1
  17. learningfoundry-0.83.0/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +124 -0
  18. learningfoundry-0.83.0/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.test.ts +133 -0
  19. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/db/database.test.ts +5 -3
  20. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/db/progress.test.ts +42 -0
  21. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/db/progress.ts +20 -0
  22. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/types/index.ts +20 -5
  23. learningfoundry-0.79.3/src/learningfoundry/integrations/nbfoundry_stub.py +0 -31
  24. learningfoundry-0.79.3/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -35
  25. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/.gitignore +0 -0
  26. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/LICENSE +0 -0
  27. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/docs/specs/nbfoundry/README.md +0 -0
  28. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/docs/specs/pyve/README.md +0 -0
  29. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/docs/specs/quizazz/README.md +0 -0
  30. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/__main__.py +0 -0
  31. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/config.py +0 -0
  32. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/directives.py +0 -0
  33. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/integrations/__init__.py +0 -0
  34. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/integrations/d3foundry_stub.py +0 -0
  35. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/integrations/quizazz.py +0 -0
  36. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/logging_config.py +0 -0
  37. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/parser.py +0 -0
  38. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/pipeline.py +0 -0
  39. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/py.typed +0 -0
  40. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/schema_extensions.py +0 -0
  41. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/e2e/README.md +0 -0
  42. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/e2e/finish.spec.ts +0 -0
  43. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/e2e/fixtures/curriculum.json +0 -0
  44. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/e2e/global-teardown.ts +0 -0
  45. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/e2e/lifecycle.spec.ts +0 -0
  46. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/e2e/navigation.spec.ts +0 -0
  47. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/e2e/progress.spec.ts +0 -0
  48. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/e2e/reset.spec.ts +0 -0
  49. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/e2e/text-block-bottom.spec.ts +0 -0
  50. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/e2e/video.spec.ts +0 -0
  51. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/package.json +0 -0
  52. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/playwright.config.ts +0 -0
  53. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/pnpm-lock.yaml +0 -0
  54. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/pnpm-workspace.yaml +0 -0
  55. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/app.css +0 -0
  56. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/app.html +0 -0
  57. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/AssessmentBlock.svelte +0 -0
  58. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/AssessmentBlock.test.ts +0 -0
  59. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonList.svelte +0 -0
  60. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonList.test.ts +0 -0
  61. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte +0 -0
  62. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts +0 -0
  63. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/LockedLessonPlaceholder.svelte +0 -0
  64. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.svelte +0 -0
  65. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.test.ts +0 -0
  66. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/Navigation.svelte +0 -0
  67. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
  68. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
  69. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte +0 -0
  70. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.test.ts +0 -0
  71. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/RecordingPausedBanner.svelte +0 -0
  72. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/RecordingPausedBanner.test.ts +0 -0
  73. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/ResetCourseButton.svelte +0 -0
  74. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/ResetCourseButton.test.ts +0 -0
  75. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.observer.test.ts +0 -0
  76. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.svelte +0 -0
  77. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.test.ts +0 -0
  78. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -0
  79. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.test.ts +0 -0
  80. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
  81. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/lesson-view.helpers.ts +0 -0
  82. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.helpers.ts +0 -0
  83. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.test.ts +0 -0
  84. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/mount.test.ts +0 -0
  85. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/navigation.helpers.ts +0 -0
  86. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/navigation.test.ts +0 -0
  87. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
  88. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/db/database.ts +0 -0
  89. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/db/index.ts +0 -0
  90. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/db/user-id.test.ts +0 -0
  91. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/db/user-id.ts +0 -0
  92. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.test.ts +0 -0
  93. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
  94. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/stores/db-init.test.ts +0 -0
  95. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/stores/db-init.ts +0 -0
  96. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.test.ts +0 -0
  97. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.ts +0 -0
  98. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/utils/assessment-passed.test.ts +0 -0
  99. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/utils/assessment-passed.ts +0 -0
  100. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/utils/duration.test.ts +0 -0
  101. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/utils/duration.ts +0 -0
  102. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.test.ts +0 -0
  103. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.ts +0 -0
  104. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown-directives.ts +0 -0
  105. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.test.ts +0 -0
  106. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.ts +0 -0
  107. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/utils/progress.test.ts +0 -0
  108. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/utils/progress.ts +0 -0
  109. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
  110. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.svelte +0 -0
  111. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.ts +0 -0
  112. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/routes/+page.svelte +0 -0
  113. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +0 -0
  114. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/page.test.ts +0 -0
  115. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/routes/[module]/assessment/[id]/+page.svelte +0 -0
  116. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/routes/[module]/assessment/[id]/page.test.ts +0 -0
  117. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/routes/layout.helpers.ts +0 -0
  118. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.test.ts +0 -0
  119. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.ts +0 -0
  120. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/src/routes/layout.test.ts +0 -0
  121. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/static/.gitkeep +0 -0
  122. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/svelte.config.js +0 -0
  123. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/test-results/.last-run.json +0 -0
  124. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/tsconfig.json +0 -0
  125. {learningfoundry-0.79.3 → learningfoundry-0.83.0}/src/learningfoundry/sveltekit_template/vite.config.ts +0 -0
@@ -7,6 +7,98 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.83.0] - 2026-06-18
11
+
12
+ **Option C — live marimo exercises** (Subphase K-2, Stories K.g–K.j). The single release for the subphase: nbfoundry now compiles a `ready` exercise to a runnable **marimo notebook** instead of a static `sections`/`expected_outputs` dict (which rendered as inert source in a `<pre>` — no executed cells, plots, or metrics). learningfoundry stages the notebook + an `exercises-manifest.json` sidecar, ships a learner-side `launch`/`stop` CLI that owns the notebook's lifecycle, and the frontend `ready` renderer becomes a launch banner.
13
+
14
+ ### Added
15
+
16
+ - **`learningfoundry launch <id>` / `learningfoundry stop [<id>]` CLI** (Stories K.i.1–K.i.4) — the learner-side runtime that owns a `ready` exercise's marimo lifecycle (a static page cannot spawn an OS process). `launch` resolves the notebook path / `mode` / port from `exercises-manifest.json`, socket-probes the port, manages a port-keyed pidfile under `.learningfoundry/`, and spawns `marimo edit|run <notebook> --headless -p <port> --no-token` detached; it offers to replace a **launch-owned** marimo on the port but never blind-kills a **foreign** process. `stop <id>` tears down one exercise; bare `stop` stops all launch-owned notebooks. Cross-platform Python (`marimo` is a learner-runtime dependency found via `PATH`, never imported by the build).
17
+ - **Notebook staging + `exercises-manifest.json`** (Story K.h) — the resolver pulls `notebook_source` out of the banner content into an `ExerciseArtifact`; the generator writes each notebook to `exercises/<id>/<id>.py` (outside the web root) and the `id → {notebook_path, mode, port}` manifest sidecar at the project root.
18
+ - **`ExerciseBlock.mode`** (`edit` | `run`, default `edit`; Story K.g) — the author's per-exercise choice of how `launch` serves the notebook (editable notebook vs. read-only app).
19
+ - **`progressRepo.getExerciseStatus(exerciseRef)`** (Story K.j.1) — reads the persisted `exercise_status` so the banner derives its completed slate on load.
20
+
21
+ ### Changed
22
+
23
+ - **nbfoundry contract → Option C** (Story K.g) — `compile_exercise` returns `{title, description, hints, environment, notebook_source}` (consumer-dependency-spec BR-1); the codegen MUST be torch-free (torch is a learner-runtime dependency only). The pass-through `NbfoundryProvider` carries the new dict unchanged.
24
+ - **`ExerciseBlock.svelte` `ready` renderer → launch banner** (Story K.j.1) — Copy the `learningfoundry launch <id>` command (transient "Copied ✓") → Open Exercise ↗ (new tab to `http://localhost:<port>`) → Mark as Complete (persists `exercise_status`, fires the completion callbacks) → completed slate (derived on load). Stub status still renders the placeholder.
25
+ - **`ExerciseContent` TS type + `stub_exercise()` → banner shape** (Story K.j.1) — `description` replaces `instructions`; `mode`/`port` are ready-only optionals; the resolver injects `id`/`status`/`mode`/`port` onto `ready` content.
26
+
27
+ ### Removed
28
+
29
+ - **The Option-B static-display path** (Stories K.h, K.j.1) — `ExerciseContent.sections`/`expected_outputs`/`assets`, the `ExerciseSection`/`ExpectedOutput` TS types, and the K.e `static/exercises/<id>/` image-asset staging. The live notebook renders its own cells and outputs at run time.
30
+
31
+ ### Notes
32
+
33
+ - **Phase-bundled release.** Stories K.g–K.j ran unversioned; this single **minor** bump (v0.83.0) ships the whole subphase. Future: Marimo-WASM in-browser execution (Option A) for non-GPU exercises and graded `submission` (cell-output scoring) remain deferred — the banner/launch contract is forward-compatible.
34
+
35
+ ### Verified
36
+
37
+ - `pyve test` → 498 passed. `npx vitest run` → 289 passed. `svelte-check` → 0 errors / 0 warnings. `ruff` + `mypy src/` clean.
38
+
39
+ ## [0.82.0] - 2026-06-18
40
+
41
+ `ExerciseBlock` ready renderer + real `ExerciseContent` types (Story K.f), the third and final story of the Subphase K-1 nbfoundry bundle. A `ready` exercise now renders inline in the SvelteKit frontend (manual-completion flavor) instead of drawing only instructions + hints; graded `submission` remains deferred to `## Future`.
42
+
43
+ ### Added
44
+
45
+ - **`ExerciseBlock.svelte` ready renderer (manual completion).** Renders code-scaffold `sections` (read-only in v1; the `editable` flag marks learner insertion points but is reserved for the Marimo-WASM future), `expected_outputs` (`image` via the runtime-composed `/exercises/<id>/<path>` URL with `alt` + `loading="lazy"`; `text`/`table` inline), collapsible hints, and local-run setup instructions from `environment`. A "Mark as Complete" control persists `exercise_status` via `progressRepo.updateExerciseStatus(id, 'complete')`, fires a typed `oncomplete` event `{ exerciseRef: id, status: 'completed' }`, and contributes to lesson-level block completion through `ContentBlock`. `status: stub` still renders the placeholder card.
46
+ - **Real `ExerciseContent` TypeScript types** (`lib/types/index.ts`): `ExerciseSection` (title/description/code/editable), the `ExpectedOutput` discriminated union (`image` with `path`+`alt`; `text`/`table` with `content`), `ExerciseEnvironment`, plus `assets: string[]` and the resolver-injected `id: string`. Kept in lockstep with the Python compiled-exercise dict (Hidden Coupling).
47
+
48
+ ### Changed
49
+
50
+ - **Resolver injects the exercise `id` into the resolved content** (both stub and ready) so the frontend has the `/exercises/<id>/<path>` asset-URL namespace and the `exerciseRef` progress key. Authoritative over the compiled dict (nbfoundry doesn't know learningfoundry's id or an explicit author override).
51
+ - **`stub_exercise()` factory now includes `assets: []`** so the `ExerciseContent` type invariant holds for stub content too (matches the documented stub shape).
52
+
53
+ ### Notes
54
+
55
+ - **Pinned open item resolved (no notebook-location field).** The released-nbfoundry compiled dict carries no runnable-notebook location in the v1 (Option B / static) path — `marimo_wasm_bundle` is the Option-A future field. The renderer therefore surfaces the code-scaffold `sections` plus `environment.setup_instructions` and relies on the learner's cloned curriculum repo to run the exercise locally. Authoritative source: `docs/specs/nbfoundry/consumer-dependency-spec.md` BR-1 + § "v1 Rendering Behavior".
56
+
57
+ ### Verified
58
+
59
+ - `pyve test` → 453 passed (was 450; +3 resolver id-injection tests). `npx vitest run` → 285 passed (+7 `ExerciseBlock.test.ts`). `svelte-check` → 0 errors. `ruff` + `mypy src/` clean.
60
+
61
+ ## [0.81.0] - 2026-06-18
62
+
63
+ Exercise `id` + asset staging (Story K.e), the second of the Subphase K-1 bundle. A compiled `ready` exercise's referenced asset files are now staged into `static/exercises/<id>/<path>`, where `id` is the build-output namespace and the progress key — auto-derived from the `ref` stem and unique curriculum-wide.
64
+
65
+ ### Added
66
+
67
+ - **`ExerciseBlock.id: str | None = None`** in `schema_v1.py`. Auto-derived from the `ref` filename stem when omitted; uniqueness enforced **curriculum-wide** on `CurriculumDef` (the id is a `static/exercises/<id>/` URL + progress key, not just per-module). A stem collision between two exercises — even in different modules — fails loud at parse time so the author sets an explicit `id`. Mirrors the `AssessmentDefinition.id` auto-gen precedent (Story J.r).
68
+ - **Exercise asset staging in the resolver.** After compiling a `ready` exercise, the resolver reads the dict's `assets: list[str]` and emits `Asset(source=base_dir/path, dest_relative="exercises/<id>/<path>")` into the shared `assets_by_dest` aggregator (deduped on `dest_relative`). `stub` exercises carry no assets and stage nothing.
69
+ - **`static/exercises` added to the generator's `_PRESERVED_PATHS`**, so previously-staged exercise assets survive a `learningfoundry build` re-run. The existing asset-copy loop stages exercise assets unchanged (it writes any `dest_relative`).
70
+
71
+ ### Changed
72
+
73
+ - **Generalized the `Asset` dedup note and `_copy_assets` docstring** to cover both content-hashed image paths and non-hashed exercise paths (the dedup key is `dest_relative` for both; the size-based copy skip is exact for hashed paths, a cheap heuristic for exercise paths).
74
+
75
+ ### Verified
76
+
77
+ - `pyve test` → 450 passed (was 437; +13 new tests across `test_schema_v1.py`, `test_resolver.py`, `test_generator.py`). No regressions.
78
+ - `ruff check` clean; `mypy src/` clean.
79
+
80
+ ## [0.80.0] - 2026-06-18
81
+
82
+ Real nbfoundry exercise integration (Story K.d), the first of the Subphase K-1 bundle. Now that nbfoundry is published, `ready` exercise blocks compile through a real `NbfoundryProvider`; stub-vs-real selection is a per-block `status` field handled in the resolver, defaulting to `ready` so a typo'd `ref` fails loud instead of silently degrading to a placeholder.
83
+
84
+ ### Added
85
+
86
+ - **`NbfoundryProvider` (`integrations/nbfoundry.py`).** A real `ExerciseProvider` that lazy-imports and delegates to `nbfoundry.compile_exercise(ref_path, base_dir)`, returning its dict unchanged. Missing package → `ImportError` with a `pip install learningfoundry[nbfoundry]` hint; any nbfoundry error → `IntegrationError` citing `ref_path`. Signature-identical to the protocol — the `status` switch lives in the resolver, not the provider.
87
+ - **`[nbfoundry]` optional-dependency extra (`nbfoundry>=0.1`)** in `pyproject.toml`, plus a mypy `ignore_missing_imports` override mirroring `quizazz`.
88
+ - **`ExerciseBlock.status: Literal["stub", "ready"] = "ready"`** in `schema_v1.py`. `ready` (default) compiles via `NbfoundryProvider`; `stub` is the explicit "not built yet" opt-in.
89
+ - **`stub_exercise(ref_path)` factory (`integrations/nbfoundry_stub.py`).** Single source of truth for the placeholder dict, shared by the resolver's `stub` path and the retained `NbfoundryStub`.
90
+
91
+ ### Changed
92
+
93
+ - **Resolver owns the `status` switch.** `status: stub` emits `stub_exercise()` directly — no provider call, no nbfoundry import, so an all-stub curriculum never imports nbfoundry. `status: ready`/default compiles via the provider and fails loud on a bad ref. The default exercise provider is now `NbfoundryProvider` (was `NbfoundryStub`).
94
+ - **`NbfoundryStub` demoted to a test double / "no-notebooks" injectable** — it is no longer the default provider and is never selected by the `status` switch.
95
+ - **Provider protocols are now `@runtime_checkable`**, enabling runtime `isinstance` conformance assertions alongside the mypy-checked contract.
96
+
97
+ ### Verified
98
+
99
+ - `pyve test` → 437 passed (was 413; +24 new tests across `test_nbfoundry.py`, `test_nbfoundry_stub.py`, `test_schema_v1.py`, `test_resolver.py`). No regressions.
100
+ - `ruff check` clean; `mypy src/` clean. The protocol-match contract is enforced in CI via the resolver's `exercise_provider = NbfoundryProvider()` default assignment into an `ExerciseProvider`-typed slot.
101
+
10
102
  ## [0.79.3] - 2026-06-15
11
103
 
12
104
  Fix `learningfoundry preview` appearing to hang silently during the `pnpm install` step and then reporting an empty failure on Ctrl-C. The install step captured pnpm's output (with no timeout) while leaving stdin attached, so progress and any interactive prompt were hidden — a prompting or slow install looked like a silent hang — and a non-zero exit produced `` `pnpm install` failed in `dist`: `` with nothing after the colon. Fixed via Story K.a.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learningfoundry
3
- Version: 0.79.3
3
+ Version: 0.83.0
4
4
  Summary: A curriculum engine that turns a YAML curriculum definition into a deployable SvelteKit learning application.
5
5
  Project-URL: Homepage, https://github.com/pointmatic/learningfoundry
6
6
  Project-URL: Repository, https://github.com/pointmatic/learningfoundry
@@ -23,6 +23,8 @@ Requires-Python: >=3.12
23
23
  Requires-Dist: click>=8.1
24
24
  Requires-Dist: pydantic>=2.0
25
25
  Requires-Dist: pyyaml>=6.0.3
26
+ Provides-Extra: nbfoundry
27
+ Requires-Dist: nbfoundry>=0.1; extra == 'nbfoundry'
26
28
  Provides-Extra: quizazz
27
29
  Requires-Dist: quizazz>=0.1; extra == 'quizazz'
28
30
  Description-Content-Type: text/markdown
@@ -63,7 +65,7 @@ A curriculum engine that turns a YAML curriculum definition into a deployable Sv
63
65
  - **Text** — Markdown content rendered in the browser
64
66
  - **Video** — YouTube embeds
65
67
  - **Assessment** — Interactive assessments via [quizazz](https://github.com/pointmatic/quizazz) (optional)
66
- - **Exercise** — Executable notebooks via nbfoundry (stub provided)
68
+ - **Exercise** — Scaffolded model-training exercises via [nbfoundry](https://github.com/pointmatic/nbfoundry) (optional; `status: stub` for not-yet-built)
67
69
  - **Visualization** — D3-based charts via d3foundry (stub provided)
68
70
 
69
71
  Learner progress is persisted locally in SQLite (via sql.js) — no backend required.
@@ -76,10 +78,12 @@ Learner progress is persisted locally in SQLite (via sql.js) — no backend requ
76
78
  pip install learningfoundry
77
79
  ```
78
80
 
79
- **With optional quizazz support:**
81
+ **With optional integration extras:**
80
82
 
81
83
  ```bash
82
- pip install "learningfoundry[quizazz]"
84
+ pip install "learningfoundry[quizazz]" # assessments
85
+ pip install "learningfoundry[nbfoundry]" # exercises
86
+ pip install "learningfoundry[quizazz,nbfoundry]"
83
87
  ```
84
88
 
85
89
  **Requirements:**
@@ -207,6 +211,27 @@ This serves the SvelteKit project from source via Vite's dev server; it does **n
207
211
 
208
212
  ---
209
213
 
214
+ ### `learningfoundry launch` / `learningfoundry stop`
215
+
216
+ Run a `ready` exercise's marimo notebook locally. These are **learner-side** commands — run from inside the generated app (where `build` writes `exercises-manifest.json`), not from the author's repo. The generated app is static and a browser page can't spawn a process, so the exercise's banner asks the learner to copy and run `learningfoundry launch <id>`.
217
+
218
+ ```
219
+ Usage: learningfoundry launch [OPTIONS] EXERCISE_ID
220
+ learningfoundry stop [OPTIONS] [EXERCISE_ID]
221
+
222
+ Options:
223
+ --dir PATH Directory holding exercises-manifest.json (the
224
+ generated app's root). [default: .]
225
+ --log-level LEVEL Logging verbosity. [default: INFO]
226
+ --help Show this message and exit.
227
+ ```
228
+
229
+ `launch <id>` resolves the notebook path / `mode` / port from the manifest, checks the port, then spawns `marimo edit|run <notebook> --headless -p <port> --no-token` detached (so it outlives the CLI) and prints the local URL. If the port is already held by a **launch-owned** marimo it offers to replace it; a **foreign** process on the port is never killed. `stop <id>` tears down that exercise's notebook; bare `stop` stops every launch-owned notebook.
230
+
231
+ > **`marimo` is a learner-runtime dependency**, not a learningfoundry dependency — `launch` finds it on `PATH` (it is never imported by the build). Install it alongside the exercise's other run requirements (e.g. `pip install marimo torch`); the banner lists them. A missing `marimo` yields an install hint, not a traceback.
232
+
233
+ ---
234
+
210
235
  ## Curriculum YAML Format
211
236
 
212
237
  ```yaml
@@ -266,10 +291,13 @@ curriculum:
266
291
  source: quizazz
267
292
  ref: assessments/mod-01-assessment.yml
268
293
 
269
- # Exercise block — requires nbfoundry (stub included)
294
+ # Exercise block — `ready` requires learningfoundry[nbfoundry];
295
+ # `status: stub` renders a placeholder with no install needed.
296
+ # See "Authoring nbfoundry exercises" below for the full author flow.
270
297
  - type: exercise
271
298
  source: nbfoundry
272
299
  ref: exercises/mod-01-exercise.yml
300
+ status: stub
273
301
 
274
302
  # Visualization block — requires d3foundry (stub included)
275
303
  - type: visualization
@@ -692,6 +720,61 @@ questions:
692
720
  - **`learningfoundry[quizazz]` is an optional extra.** Plain `pip install learningfoundry` does not pull in quizazz; running `learningfoundry build` on a curriculum that references `source: quizazz` will fail with an `ImportError`. Install the extra explicitly.
693
721
  - **`<QuizBlock>` is a vendor component name** — preserved at the vendor boundary. A future "consistency rename" pass that tried to rename it to `<AssessmentBlock>` (learningfoundry's wrapper component) would break the integration silently. See the "Vendor terminology stops at the vendor boundary" note in [project-essentials.md](docs/specs/project-essentials.md).
694
722
 
723
+ ### Authoring nbfoundry exercises
724
+
725
+ [nbfoundry](https://github.com/pointmatic/nbfoundry) is the exercise provider — scaffolded, model-training exercises. Under the hood (Option C) nbfoundry compiles each exercise to a **runnable marimo notebook**; learningfoundry stages it at build time and the lesson renders a **launch banner**. The learner copies the `learningfoundry launch <id>` command, runs it to start the notebook locally, opens it in a new tab, works through the cells, and clicks **Mark as Complete**. The exercise runs in the learner's own Python environment, so PyTorch and other heavy dependencies stay out of the build and the browser. In-browser (Marimo-WASM) execution and graded submission are deferred to future versions; the authored YAML does not change when they land.
726
+
727
+ **What you need.**
728
+
729
+ - *Build side:* `pip install learningfoundry[nbfoundry]` installs the [`nbfoundry` PyPI package](https://pypi.org/project/nbfoundry/) so `learningfoundry build` can compile exercise YAML into the notebook. Like `[quizazz]`, it is an optional extra — plain `pip install learningfoundry` does not pull it in, and a `ready` exercise referencing `source: nbfoundry` without it fails the build with an `ImportError` and an install hint.
730
+ - *Learner side:* running the exercise needs **`marimo`** (plus the exercise's own dependencies, e.g. `torch`) on the learner's `PATH`. `learningfoundry launch` finds `marimo` there — it is a **learner-runtime** dependency, never imported by the build. The banner lists the exercise's prerequisites.
731
+
732
+ **Referencing an exercise.** Add an `exercise` content block to a lesson:
733
+
734
+ ```yaml
735
+ content_blocks:
736
+ - type: exercise
737
+ source: nbfoundry
738
+ ref: exercises/mod-01/cnn-classifier.yml # located under --base-dir
739
+ status: ready # "ready" (default) | "stub"
740
+ mode: edit # "edit" (default) | "run"
741
+ # id: cnn-classifier # optional; see below
742
+ ```
743
+
744
+ - **`status: ready`** (the default) compiles the exercise to a notebook at build time. A typo'd `ref` fails the build **loud** rather than silently degrading to a placeholder.
745
+ - **`status: stub`** renders the "nbfoundry integration pending" placeholder card — the explicit "not built yet" opt-in while you scaffold the curriculum. No nbfoundry call, no install needed for stub-only curricula.
746
+ - **`mode`** chooses how `learningfoundry launch` serves the notebook: **`edit`** (the default) opens marimo's editable notebook (the learner writes code into the scaffold); **`run`** opens it as a read-only app. It is the same notebook either way — the choice is pedagogical.
747
+
748
+ **How `id` works.** Every exercise has an `id` that is two things at once: the **notebook namespace** (the build writes the marimo notebook to `exercises/<id>/<id>.py` and indexes it in `exercises-manifest.json`, which `learningfoundry launch <id>` reads) and the **progress key** (`exerciseRef`) recorded in the in-browser database. When you omit `id:`, it is auto-derived from the `ref` filename **stem** (`exercises/mod-01/cnn-classifier.yml` → `cnn-classifier`). The `id` must be **unique across the whole curriculum** — not just within a module.
749
+
750
+ **Organizing source freely.** The `id` namespaces the *output notebook*; it does **not** constrain where you organize *source* content. Lay out exercise YAML however you like under `--base-dir` — nbfoundry locates it via the relative `ref`:
751
+
752
+ ```
753
+ # Source (organize however you like): # Build output (id-namespaced notebook):
754
+ exercises/mod-01/cnn-classifier.yml dist/exercises/cnn-classifier/cnn-classifier.py
755
+ exercises/mod-02/intro.yml dist/exercises/intro/intro.py
756
+ ```
757
+
758
+ The notebook renders its own plots, tables, and metrics when it runs — there is no separate image-staging step (that was the retired Option-B static-display path).
759
+
760
+ **The stem-collision case.** Because the auto-derived `id` is just the filename stem, two exercises whose `ref` files share a stem collide — even in different modules:
761
+
762
+ ```yaml
763
+ # mod-01 lesson: ref: exercises/mod-01/intro.yml → id "intro"
764
+ # mod-02 lesson: ref: exercises/mod-02/intro.yml → id "intro" ❌ build error
765
+ ```
766
+
767
+ This is a **loud build-time error**, not a silent overwrite (two exercises would otherwise share one notebook path + progress key). Fix it by setting an explicit `id:` on at least one:
768
+
769
+ ```yaml
770
+ - type: exercise
771
+ source: nbfoundry
772
+ ref: exercises/mod-02/intro.yml
773
+ id: mod-02-intro # explicit, curriculum-unique
774
+ ```
775
+
776
+ **Why a stable `id` matters.** Set an explicit `id:` when you expect to **reorganize source content** later. Because the `id` is both the notebook namespace and the progress key, keeping it stable while you move or rename the underlying `ref` file preserves both the `learningfoundry launch <id>` command *and* learners' recorded completion — whereas relying on the auto-derived stem means a rename silently changes the `id` and orphans prior progress.
777
+
695
778
  ### Assessments
696
779
 
697
780
  Each module declares an `assessments[]` array; each entry carries:
@@ -34,7 +34,7 @@ A curriculum engine that turns a YAML curriculum definition into a deployable Sv
34
34
  - **Text** — Markdown content rendered in the browser
35
35
  - **Video** — YouTube embeds
36
36
  - **Assessment** — Interactive assessments via [quizazz](https://github.com/pointmatic/quizazz) (optional)
37
- - **Exercise** — Executable notebooks via nbfoundry (stub provided)
37
+ - **Exercise** — Scaffolded model-training exercises via [nbfoundry](https://github.com/pointmatic/nbfoundry) (optional; `status: stub` for not-yet-built)
38
38
  - **Visualization** — D3-based charts via d3foundry (stub provided)
39
39
 
40
40
  Learner progress is persisted locally in SQLite (via sql.js) — no backend required.
@@ -47,10 +47,12 @@ Learner progress is persisted locally in SQLite (via sql.js) — no backend requ
47
47
  pip install learningfoundry
48
48
  ```
49
49
 
50
- **With optional quizazz support:**
50
+ **With optional integration extras:**
51
51
 
52
52
  ```bash
53
- pip install "learningfoundry[quizazz]"
53
+ pip install "learningfoundry[quizazz]" # assessments
54
+ pip install "learningfoundry[nbfoundry]" # exercises
55
+ pip install "learningfoundry[quizazz,nbfoundry]"
54
56
  ```
55
57
 
56
58
  **Requirements:**
@@ -178,6 +180,27 @@ This serves the SvelteKit project from source via Vite's dev server; it does **n
178
180
 
179
181
  ---
180
182
 
183
+ ### `learningfoundry launch` / `learningfoundry stop`
184
+
185
+ Run a `ready` exercise's marimo notebook locally. These are **learner-side** commands — run from inside the generated app (where `build` writes `exercises-manifest.json`), not from the author's repo. The generated app is static and a browser page can't spawn a process, so the exercise's banner asks the learner to copy and run `learningfoundry launch <id>`.
186
+
187
+ ```
188
+ Usage: learningfoundry launch [OPTIONS] EXERCISE_ID
189
+ learningfoundry stop [OPTIONS] [EXERCISE_ID]
190
+
191
+ Options:
192
+ --dir PATH Directory holding exercises-manifest.json (the
193
+ generated app's root). [default: .]
194
+ --log-level LEVEL Logging verbosity. [default: INFO]
195
+ --help Show this message and exit.
196
+ ```
197
+
198
+ `launch <id>` resolves the notebook path / `mode` / port from the manifest, checks the port, then spawns `marimo edit|run <notebook> --headless -p <port> --no-token` detached (so it outlives the CLI) and prints the local URL. If the port is already held by a **launch-owned** marimo it offers to replace it; a **foreign** process on the port is never killed. `stop <id>` tears down that exercise's notebook; bare `stop` stops every launch-owned notebook.
199
+
200
+ > **`marimo` is a learner-runtime dependency**, not a learningfoundry dependency — `launch` finds it on `PATH` (it is never imported by the build). Install it alongside the exercise's other run requirements (e.g. `pip install marimo torch`); the banner lists them. A missing `marimo` yields an install hint, not a traceback.
201
+
202
+ ---
203
+
181
204
  ## Curriculum YAML Format
182
205
 
183
206
  ```yaml
@@ -237,10 +260,13 @@ curriculum:
237
260
  source: quizazz
238
261
  ref: assessments/mod-01-assessment.yml
239
262
 
240
- # Exercise block — requires nbfoundry (stub included)
263
+ # Exercise block — `ready` requires learningfoundry[nbfoundry];
264
+ # `status: stub` renders a placeholder with no install needed.
265
+ # See "Authoring nbfoundry exercises" below for the full author flow.
241
266
  - type: exercise
242
267
  source: nbfoundry
243
268
  ref: exercises/mod-01-exercise.yml
269
+ status: stub
244
270
 
245
271
  # Visualization block — requires d3foundry (stub included)
246
272
  - type: visualization
@@ -663,6 +689,61 @@ questions:
663
689
  - **`learningfoundry[quizazz]` is an optional extra.** Plain `pip install learningfoundry` does not pull in quizazz; running `learningfoundry build` on a curriculum that references `source: quizazz` will fail with an `ImportError`. Install the extra explicitly.
664
690
  - **`<QuizBlock>` is a vendor component name** — preserved at the vendor boundary. A future "consistency rename" pass that tried to rename it to `<AssessmentBlock>` (learningfoundry's wrapper component) would break the integration silently. See the "Vendor terminology stops at the vendor boundary" note in [project-essentials.md](docs/specs/project-essentials.md).
665
691
 
692
+ ### Authoring nbfoundry exercises
693
+
694
+ [nbfoundry](https://github.com/pointmatic/nbfoundry) is the exercise provider — scaffolded, model-training exercises. Under the hood (Option C) nbfoundry compiles each exercise to a **runnable marimo notebook**; learningfoundry stages it at build time and the lesson renders a **launch banner**. The learner copies the `learningfoundry launch <id>` command, runs it to start the notebook locally, opens it in a new tab, works through the cells, and clicks **Mark as Complete**. The exercise runs in the learner's own Python environment, so PyTorch and other heavy dependencies stay out of the build and the browser. In-browser (Marimo-WASM) execution and graded submission are deferred to future versions; the authored YAML does not change when they land.
695
+
696
+ **What you need.**
697
+
698
+ - *Build side:* `pip install learningfoundry[nbfoundry]` installs the [`nbfoundry` PyPI package](https://pypi.org/project/nbfoundry/) so `learningfoundry build` can compile exercise YAML into the notebook. Like `[quizazz]`, it is an optional extra — plain `pip install learningfoundry` does not pull it in, and a `ready` exercise referencing `source: nbfoundry` without it fails the build with an `ImportError` and an install hint.
699
+ - *Learner side:* running the exercise needs **`marimo`** (plus the exercise's own dependencies, e.g. `torch`) on the learner's `PATH`. `learningfoundry launch` finds `marimo` there — it is a **learner-runtime** dependency, never imported by the build. The banner lists the exercise's prerequisites.
700
+
701
+ **Referencing an exercise.** Add an `exercise` content block to a lesson:
702
+
703
+ ```yaml
704
+ content_blocks:
705
+ - type: exercise
706
+ source: nbfoundry
707
+ ref: exercises/mod-01/cnn-classifier.yml # located under --base-dir
708
+ status: ready # "ready" (default) | "stub"
709
+ mode: edit # "edit" (default) | "run"
710
+ # id: cnn-classifier # optional; see below
711
+ ```
712
+
713
+ - **`status: ready`** (the default) compiles the exercise to a notebook at build time. A typo'd `ref` fails the build **loud** rather than silently degrading to a placeholder.
714
+ - **`status: stub`** renders the "nbfoundry integration pending" placeholder card — the explicit "not built yet" opt-in while you scaffold the curriculum. No nbfoundry call, no install needed for stub-only curricula.
715
+ - **`mode`** chooses how `learningfoundry launch` serves the notebook: **`edit`** (the default) opens marimo's editable notebook (the learner writes code into the scaffold); **`run`** opens it as a read-only app. It is the same notebook either way — the choice is pedagogical.
716
+
717
+ **How `id` works.** Every exercise has an `id` that is two things at once: the **notebook namespace** (the build writes the marimo notebook to `exercises/<id>/<id>.py` and indexes it in `exercises-manifest.json`, which `learningfoundry launch <id>` reads) and the **progress key** (`exerciseRef`) recorded in the in-browser database. When you omit `id:`, it is auto-derived from the `ref` filename **stem** (`exercises/mod-01/cnn-classifier.yml` → `cnn-classifier`). The `id` must be **unique across the whole curriculum** — not just within a module.
718
+
719
+ **Organizing source freely.** The `id` namespaces the *output notebook*; it does **not** constrain where you organize *source* content. Lay out exercise YAML however you like under `--base-dir` — nbfoundry locates it via the relative `ref`:
720
+
721
+ ```
722
+ # Source (organize however you like): # Build output (id-namespaced notebook):
723
+ exercises/mod-01/cnn-classifier.yml dist/exercises/cnn-classifier/cnn-classifier.py
724
+ exercises/mod-02/intro.yml dist/exercises/intro/intro.py
725
+ ```
726
+
727
+ The notebook renders its own plots, tables, and metrics when it runs — there is no separate image-staging step (that was the retired Option-B static-display path).
728
+
729
+ **The stem-collision case.** Because the auto-derived `id` is just the filename stem, two exercises whose `ref` files share a stem collide — even in different modules:
730
+
731
+ ```yaml
732
+ # mod-01 lesson: ref: exercises/mod-01/intro.yml → id "intro"
733
+ # mod-02 lesson: ref: exercises/mod-02/intro.yml → id "intro" ❌ build error
734
+ ```
735
+
736
+ This is a **loud build-time error**, not a silent overwrite (two exercises would otherwise share one notebook path + progress key). Fix it by setting an explicit `id:` on at least one:
737
+
738
+ ```yaml
739
+ - type: exercise
740
+ source: nbfoundry
741
+ ref: exercises/mod-02/intro.yml
742
+ id: mod-02-intro # explicit, curriculum-unique
743
+ ```
744
+
745
+ **Why a stable `id` matters.** Set an explicit `id:` when you expect to **reorganize source content** later. Because the `id` is both the notebook namespace and the progress key, keeping it stable while you move or rename the underlying `ref` file preserves both the `learningfoundry launch <id>` command *and* learners' recorded completion — whereas relying on the auto-derived stem means a rename silently changes the `id` and orphans prior progress.
746
+
666
747
  ### Assessments
667
748
 
668
749
  Each module declares an `assessments[]` array; each entry carries:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "learningfoundry"
7
- version = "0.79.3"
7
+ version = "0.83.0"
8
8
  description = "A curriculum engine that turns a YAML curriculum definition into a deployable SvelteKit learning application."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -34,6 +34,9 @@ dependencies = [
34
34
  quizazz = [
35
35
  "quizazz>=0.1",
36
36
  ]
37
+ nbfoundry = [
38
+ "nbfoundry>=0.1",
39
+ ]
37
40
 
38
41
  [project.urls]
39
42
  Homepage = "https://github.com/pointmatic/learningfoundry"
@@ -71,6 +74,10 @@ python_version = "3.12"
71
74
  module = "quizazz"
72
75
  ignore_missing_imports = true
73
76
 
77
+ [[tool.mypy.overrides]]
78
+ module = "nbfoundry"
79
+ ignore_missing_imports = true
80
+
74
81
  [tool.coverage.run]
75
82
  source = ["src/learningfoundry"]
76
83
  omit = ["*/sveltekit_template/*"]
@@ -1,4 +1,4 @@
1
1
  # Copyright 2026 Pointmatic
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
 
4
- __version__ = "0.79.3"
4
+ __version__ = "0.83.0"
@@ -85,11 +85,20 @@ _PASSTHROUGH_PREFIXES: tuple[str, ...] = (
85
85
  class Asset:
86
86
  """A single asset that must be copied into the generated project.
87
87
 
88
+ ``dest_relative`` is the dedup key wherever Assets aggregate (the
89
+ resolver's ``assets_by_dest`` map): two records with the same
90
+ destination collapse to one copy. This holds both for the content-hashed
91
+ markdown-image paths produced here (``content/<sha256[:12]>/<basename>``,
92
+ where a matching hash implies matching bytes) and for the **non-hashed**
93
+ exercise-asset paths the resolver emits (``exercises/<id>/<path>``,
94
+ Story K.e) — same key, same dedup semantics.
95
+
88
96
  Attributes:
89
97
  source: Absolute path to the source file on disk.
90
98
  dest_relative: Destination path relative to the generated project's
91
- ``static/`` directory (e.g. ``"content/abc123def456/diagram.png"``).
92
- Always uses forward slashes — this becomes a URL fragment too.
99
+ ``static/`` directory (e.g. ``"content/abc123def456/diagram.png"``
100
+ or ``"exercises/mod-01-exercise-01/data/sample.csv"``). Always
101
+ uses forward slashes — this becomes a URL fragment too.
93
102
  """
94
103
 
95
104
  source: Path
@@ -1,6 +1,7 @@
1
1
  # Copyright 2026 Pointmatic
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
 
4
+ import shutil
4
5
  import sys
5
6
  from pathlib import Path
6
7
 
@@ -13,6 +14,7 @@ from learningfoundry.exceptions import (
13
14
  CurriculumValidationError,
14
15
  CurriculumVersionError,
15
16
  GenerationError,
17
+ LaunchError,
16
18
  SchemaExtensionError,
17
19
  )
18
20
  from learningfoundry.logging_config import setup_logging as _setup_logging
@@ -24,6 +26,7 @@ EXIT_VALIDATION = 1
24
26
  EXIT_RESOLUTION = 2
25
27
  EXIT_GENERATION = 3
26
28
  EXIT_CONFIG = 4
29
+ EXIT_RUNTIME = 5
27
30
 
28
31
 
29
32
  # ---------------------------------------------------------------------------
@@ -270,3 +273,114 @@ def preview(
270
273
  sys.exit(EXIT_CONFIG)
271
274
 
272
275
  click.echo(f"Preview server started at http://localhost:{port}")
276
+
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # launch
280
+ # ---------------------------------------------------------------------------
281
+
282
+ _launch_dir_option = click.option(
283
+ "--dir",
284
+ "launch_dir",
285
+ type=click.Path(file_okay=False, path_type=Path),
286
+ default=Path("."),
287
+ show_default=True,
288
+ help=(
289
+ "Directory holding `exercises-manifest.json` (the generated app's "
290
+ "root). Defaults to the current directory."
291
+ ),
292
+ )
293
+
294
+
295
+ @main.command()
296
+ @click.argument("exercise_id")
297
+ @_launch_dir_option
298
+ @_log_level_option
299
+ def launch(exercise_id: str, launch_dir: Path, log_level: str) -> None:
300
+ """Launch an exercise's marimo notebook locally."""
301
+ _setup_logging(level=log_level)
302
+
303
+ from learningfoundry import launch as _launch
304
+
305
+ try:
306
+ spec = _launch.resolve_launch_spec(launch_dir, exercise_id)
307
+ except LaunchError as exc:
308
+ click.echo(f"Launch error: {exc}", err=True)
309
+ sys.exit(EXIT_VALIDATION)
310
+
311
+ if shutil.which("marimo") is None:
312
+ click.echo(
313
+ "marimo not found on PATH. It is a learner-runtime dependency — "
314
+ "install it (e.g. `pip install marimo`) to run exercises.",
315
+ err=True,
316
+ )
317
+ sys.exit(EXIT_RUNTIME)
318
+
319
+ status = _launch.classify_port(launch_dir, spec.port)
320
+ if status == "foreign":
321
+ click.echo(
322
+ f"Port {spec.port} is in use by another process. Refusing to "
323
+ "kill it — free the port (or stop that process) and retry.",
324
+ err=True,
325
+ )
326
+ sys.exit(EXIT_RUNTIME)
327
+ if status == "ours":
328
+ if not click.confirm(
329
+ f"An exercise is already running on port {spec.port}. Replace it?"
330
+ ):
331
+ click.echo("Left the running exercise in place.")
332
+ return
333
+ _launch.stop_launch_on_port(launch_dir, spec.port)
334
+
335
+ pid = _launch.spawn_detached(_launch.marimo_argv(spec), launch_dir)
336
+ _launch.write_pidfile(
337
+ launch_dir,
338
+ _launch.PidfileEntry(
339
+ pid=pid,
340
+ exercise_id=spec.id,
341
+ port=spec.port,
342
+ mode=spec.mode,
343
+ ),
344
+ )
345
+ click.echo(
346
+ f"Launched `{spec.id}` ({spec.mode}) → http://localhost:{spec.port}"
347
+ )
348
+ click.echo(f"Stop it with: learningfoundry stop {spec.id}")
349
+
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # stop
353
+ # ---------------------------------------------------------------------------
354
+
355
+ @main.command()
356
+ @click.argument("exercise_id", required=False)
357
+ @_launch_dir_option
358
+ @_log_level_option
359
+ def stop(exercise_id: str | None, launch_dir: Path, log_level: str) -> None:
360
+ """Stop a launch-owned marimo notebook (all of them if no id is given)."""
361
+ _setup_logging(level=log_level)
362
+
363
+ from learningfoundry import launch as _launch
364
+
365
+ if exercise_id is not None:
366
+ try:
367
+ spec = _launch.resolve_launch_spec(launch_dir, exercise_id)
368
+ except LaunchError as exc:
369
+ click.echo(f"Stop error: {exc}", err=True)
370
+ sys.exit(EXIT_VALIDATION)
371
+ stopped = _launch.stop_launch_on_port(launch_dir, spec.port)
372
+ if stopped is not None:
373
+ click.echo(f"Stopped `{stopped.exercise_id}` (port {stopped.port}).")
374
+ else:
375
+ click.echo(f"No running exercise found for `{exercise_id}`.")
376
+ return
377
+
378
+ # No id → stop every launch-owned marimo.
379
+ ports = _launch.launched_ports(launch_dir)
380
+ if not ports:
381
+ click.echo("No running exercises.")
382
+ return
383
+ for port in ports:
384
+ stopped = _launch.stop_launch_on_port(launch_dir, port)
385
+ if stopped is not None:
386
+ click.echo(f"Stopped `{stopped.exercise_id}` (port {stopped.port}).")
@@ -43,3 +43,28 @@ class GenerationError(LearningFoundryError):
43
43
  class SchemaExtensionError(LearningFoundryError):
44
44
  """Project schema-extensions file is missing, malformed, or declares an
45
45
  unsupported field type / shape (Story J.h)."""
46
+
47
+
48
+ class LaunchError(LearningFoundryError):
49
+ """Base for `learningfoundry launch` / `stop` runtime errors (Story K.i)."""
50
+
51
+
52
+ class ManifestNotFoundError(LaunchError):
53
+ """The `exercises-manifest.json` sidecar was not found in the launch dir.
54
+
55
+ The learner runs `launch`/`stop` from inside the generated app's root,
56
+ where `build` writes the manifest; a missing file usually means the wrong
57
+ directory or an un-built app.
58
+ """
59
+
60
+
61
+ class UnknownExerciseError(LaunchError):
62
+ """The requested exercise id is absent from the manifest.
63
+
64
+ The message lists the ids the learner can actually launch.
65
+ """
66
+
67
+
68
+ class ManifestError(LaunchError):
69
+ """The `exercises-manifest.json` sidecar is malformed (bad JSON, not an
70
+ object, or an entry missing required fields)."""