# Plan: SCORM Packager (`scripts/package_scorm.py`)

## Origin

Two faculty-shared SCORM zips (`SolaraSW-Onboarding-SCORM`, `PMG525-Solara-BranchingScenario-SCORM`)
were generated by another Claude session and uploaded to Canvas "as SCORM for a gradebook
connection." Analysis found both are **valid SCORM 1.2 envelopes (`scormtype="sco"`) wrapped around
untracked HTML** — neither contains any SCORM runtime (`findAPI`, `LMSInitialize`, `LMSSetValue`,
`LMSCommit`, `LMSFinish`). The branching sample even captures the learner's decision path in
`currentPath` but never transmits it. Result: Canvas creates a gradebook column that never fills.

ASUCB currently deploys interactives via uploaded HTML + iframe (`deploy_interactives.py`), which
**cannot write to the gradebook**. SCORM is the only mechanism that gives a self-contained
interactive a real completion/score hook without being a native Canvas quiz.

## Verified constraints (do not re-derive — checked against prod 2026-06-12)

1. **No SCORM content-migration on ASU prod.** `GET /courses/260866/content_migrations/migrators`
   returns: zip_file_importer, angel, blackboard, canvas_cartridge, common_cartridge, course_copy,
   d2l, moodle, qti. **No `scorm_importer`.** SCORM import on ASU runs through the Canvas **Files UI**
   ("Import as graded assignment"), powered by Canvas's built-in SCORM player feature — NOT the REST
   API. Therefore the packager produces a zip + manual upload instructions; it does **not** auto-import.
2. `.env` points at **prod `canvas.asu.edu`**. Test sandbox = **course 260866 `2026_Brent_LAW_Sandbox`**
   (Brent's own). The 218764/218765 IDs from memory are on the **dev instance** — wrong token, ignore.
3. SCORM 1.2 `cmi.core.suspend_data` SPM = 4096 chars — truncate.
4. SCORM 1.2 `cmi.core.lesson_status` vocab: passed / completed / failed / incomplete / browsed /
   not attempted. `score.raw` is 0–100 (with score.min/score.max).

## Decisions (locked with the ID this session)

- **SCORM 1.2** (not 2004).
- **Configurable per package**: `completion` mode (default) or `scored` mode (0–100, optional mastery).
- **Injected `window.ASUCB` JS bridge** is the completion/score contract. Must work for hand-built HTML
  (the two samples), not just generator output.
- **SCORM is opt-in.** Default interactive deploy stays iframe HTML, unchanged. `--scorm` opts in.

## Deliverable 1 — `scripts/package_scorm.py`

### CLI
```
--html-file PATH      (required) self-contained interactive HTML
--title TITLE         (required) SCO title (manifest org/item title)
--identifier ID       (optional) manifest identifier; default = slug(title)
--mode {completion,scored}   default completion
--mastery-score N     (optional, scored only) 0–100 passing threshold -> manifest masteryscore + passed/failed
--output PATH         (optional) output zip; default <html-basename>-SCORM.zip beside source
--no-bridge           (optional) skip bridge injection (HTML already wired)
```

### Functions (each independently unit-tested)
- `inject_bridge(html: str, mode: str, mastery: int|None) -> str`
  Inserts `<!-- ASUCB-SCORM-BRIDGE v1 -->` sentinel + `<script>` before `</body>`
  (fallback `</html>`, fallback append). **Idempotent** — sentinel present => return unchanged.
  Preserves all original markup verbatim (minimal-diff: only adds the script block).
- `build_manifest(title, identifier, mode, mastery) -> str`
  SCORM 1.2 manifest XML. `scormtype="sco"`, `href="index.html"`. XML-escape title.
  `<adlcp:masteryscore>N</adlcp:masteryscore>` under `<item>` iff mode==scored and mastery set.
- `package(html_file, title, identifier, mode, mastery, output, inject=True) -> dict`
  Read html -> inject (unless --no-bridge) -> write zip with **root-level** `imsmanifest.xml` +
  `index.html`. Returns `{"ok": True, "zip": path, "mode": ..., "instructions": <str>}`.
- Path-component safety on identifier/output (reuse the `staging_manager` validation pattern;
  reject `..`, unsafe chars; return `{"ok": False, "error": ...}` rather than raise).

### Injected bridge (SCORM 1.2) — behavior contract
```js
// sentinel: <!-- ASUCB-SCORM-BRIDGE v1 -->
window.ASUCB.init()            // findAPI (parent+opener chain, <=7 hops) -> LMSInitialize;
                               //   if status not-attempted/unknown -> set "incomplete"; scored: score.min/max 0/100
window.ASUCB.setScore(n)       // clamp 0..100 -> cmi.core.score.raw + Commit
window.ASUCB.setSuspendData(s) // slice(0,4096) -> cmi.core.suspend_data + Commit
window.ASUCB.complete(score,d) // scored+mastery -> passed/failed vs N; scored w/o mastery or completion -> completed;
                               //   sets score.raw if scored & score given; sets suspend_data if d; Commit
window.ASUCB.finish()          // LMSFinish (once)
```
- Auto: `init()` on DOMContentLoaded; `finish()` on pagehide/beforeunload.
- **No-op gracefully when no API found** (standalone preview / iframe): no thrown errors, just skips
  reporting. Same injected HTML must work both as iframe (bridge dormant) and as SCORM (bridge reports).

### Acceptance criteria (pytest — Codex writes these)
1. inject_bridge output contains sentinel, `window.ASUCB`, findAPI, LMSInitialize.
2. inject_bridge idempotent (2x => one bridge).
3. Original markup preserved (html minus injected script == original).
4. build_manifest: well-formed XML; scormtype=sco; masteryscore present iff scored+mastery; title escaped.
5. package(): zip namelist == {imsmanifest.xml, index.html}; both readable; html has bridge.
6. CLI scored+mastery reflected in manifest; default completion has no masteryscore.
7. Path-safety: traversal/unsafe identifier|output => {"ok": False}.
8. Wrapping the real `PMG525` sample HTML round-trips (parses, zips, bridge present, original screens intact).

## Deliverable 2 — `deploy_interactives.py` integration

- Add `--scorm`, `--scorm-mode {completion,scored}`, `--scorm-mastery N`.
- Default (no `--scorm`): existing iframe path, **unchanged**.
- `--scorm`: call `package_scorm.package()`, write zip (beside source or `courses/{id}/staging/scorm/`),
  then PRINT exact manual-upload instructions (Canvas Files -> upload zip -> Import as Graded/Ungraded
  Assignment). **No API import claim.**
- Producing a local zip is not a Canvas write -> no push-gate needed for the zip. The Canvas import is
  the ID's deliberate manual action.

## Deliverable 3 — docs/version

- `skills/interactive-content/SKILL.md`: add a SCORM-export option note (opt-in; iframe default;
  manual upload last-mile; when to choose SCORM vs iframe vs native quiz).
- `CHANGELOG.md` `## X.Y.Z` (feat -> minor bump).
- Bump both `.claude-plugin/plugin.json` + `.codex-plugin/plugin.json` + `VERSION` together; tag after merge.
- CLAUDE.md §"Interactive deploy routing": note SCORM as opt-in additive path.

## Verification — DONE (2026-06-12)

- **Static**: full `pytest tests/ -q` → 1665 passed, 0 regressions. 19 tests `test_package_scorm.py` +
  3 tests `test_deploy_interactives_scorm.py`.
- **Dynamic bridge proof** (Playwright + mock `window.API`): the committed fixture
  `tests/fixtures/pmg525_branching_sample.html` is the **unwired faculty original** (no
  `ASUCB.complete()` call) — it exercises the round-trip/injection tests only. For the live drive, a
  **separate wired copy** (faculty HTML + a one-line `goToEnd()` → `window.ASUCB.complete(scoreFromPath(...))`
  call, built at `/tmp/pmg525_wired.html`, NOT committed) was packaged and run: driving A→A1→Complete, the
  mock recorded `LMSInitialize` → `lesson_status=incomplete`+`score.min/max` → `Commit` →
  `suspend_data="A>A1"` → `score.raw=88` → `lesson_status=passed` → `Commit` → `Finish`. No-API harness:
  bridge dormant, **zero throws** (iframe-compat confirmed). The same wired copy was used for the ASU e2e below.
- **End-to-end on ASU production** (canvas.asu.edu course 260866, Brent sandbox):
  corrected the import path — ASU has **no SCORM content-migration API** and does **not** import via Files;
  SCORM runs through the **SCORM LTI tool** (Course nav → "SCORM" → Upload → "Choose import type" →
  graded assignment → Go). Uploaded the wired package, imported as graded, completed as Test Student.
  Result confirmed via Submissions API (independent of the player display):
  `assignment 7482328 'PMG 525 — Solara Decision Simulation (SCORM test)' external_tool 100pts;
  submission user=1569219 score=88.0 grade=88% state=graded`. **Score posts to the gradebook.**

## Wrong doors found (recorded so they aren't retried)

ASU SCORM is the SCORM LTI tool, NOT: `content_migrations` migrators (no `scorm_importer`), the Files-UI
upload (no import modal; file Actions menu has no import option), or the course `external_tools` nav list
(the SCORM tool is account-level, surfaces at `external_tools/4455`, course nav "SCORM").

## Workflow

Codex co-implementation (Deliverable 1 + tests). I review the diff (not the summary), run full
pytest, run the dynamic bridge proof, then PR. Coder (Codex) != reviewer (me/Opus).
