Skip to content

feat: the learn platform — three faces, three subjects, signed split, launch gate (learn uplift)#4

Merged
OriNachum merged 29 commits into
mainfrom
feat/learn-uplift
Jul 11, 2026
Merged

feat: the learn platform — three faces, three subjects, signed split, launch gate (learn uplift)#4
OriNachum merged 29 commits into
mainfrom
feat/learn-uplift

Conversation

@OriNachum

Copy link
Copy Markdown
Contributor

What

The learn platform — the full learn-uplift build (spec + plan landed in #3, tracked cross-repo in french-cli#2, spanish-cli#2, culture-guide#10, org#7). Built by a 16-task workforce in 5 TDD-gated waves; suite 22 → 353 tests (+12 gated E2E), coverage ~96%.

  • Subject registry + conformance gatelearn subjects, learn subject doctor <name>; french/spanish/culture-guide all healthy (8/8 checks each), driven purely as subprocess --json runtimes.
  • Subject-plugin contract v1.0 — 11 versioned schemas + stdlib validator as package data; directive pattern (subjects stay LLM-free); shared story schema.
  • Motivation layer — deterministic scores/streaks/decay/review-queues/what-next; completed-track test proves never-ending mode.
  • Learner profilelearn auth login (GitHub device flow), offline-first ledger + sync, progress/next/record verbs.
  • Three faces via agentfront ≥0.20learn mcp serve, learn site serve, learn site export; surfaces_agree in CI. Upstream gaps filed: agentfront#52, #53.
  • Web facesite-astro/: 29 static pages in org's design (zero new tokens — machine-checked subset of org's global.css), signed-out/in split with a zero-API static proof, Pages deploy workflow (agentculture-learn).
  • API worker — GitHub OAuth, KV/D1 append-only ledger, tutoring broker (env-configured inference endpoint, never a provider SDK), and the agentculture.org/learn/* zone mount: one worker serves /learn/api/* and proxies the static site.
  • Launch gatetools/launch-gate/run.sh: 66 machine-checked assertions (web via Playwright at phone+desktop, CLI golden --json, MCP harness, launch-bar numbers). Current result: GATE: PASS, 66/66. Live-mode rerun: LIVE_ORIGIN=… run.sh.

Post-merge operator steps (documented in workers/learn-api/README.md + site-astro/README.md)

  1. Add CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID secrets to this repo → re-run the deploy workflow.
  2. Create the GitHub OAuth app (device flow enabled) + wrangler secret put (SESSION_SECRET, GITHUB_CLIENT_SECRET); provision KV/D1; deploy the worker with the zone route.
  3. Merge org#8 (Learn nav — marked merge-at-cutover) once agentculture.org/learn/ resolves; then LIVE_ORIGIN launch-gate rerun.

Version 0.4.2 → 0.5.0.

  • learn-cli (Claude)

OriNachum and others added 26 commits July 11, 2026 03:30
…as, validator, story fixtures (t2)

Ship the contract's machine-readable half as package data:

- learn/contract/schemas/*.json — 11 versioned JSON Schemas: the eight tutor
  verbs (overview, progress, advice, story list/read, lesson, practice,
  record, doctor), the shared story content schema, and the stderr error
  shape. Every payload requires schema_version (^1\.[0-9]+$); doctor pins
  contract_version. record's raw-result object structurally forbids
  score/grade/points — subjects report raw results, learn computes scores.
- learn/contract/_validate.py — stdlib-only validator (runtime deps stay
  empty) covering exactly the keyword subset the schemas use, with an
  unsupported-keyword guard.
- learn/contract/__init__.py — CONTRACT_VERSION, shared vocabularies
  (mastery ladder, results, story levels), load_schema/validate API loadable
  from the installed wheel for t3's conformance gate.
- tests/fixtures/stories/ — the acceptance fixtures: a french graded story,
  a spanish graded story, and a culture-guide narrative scenario, all
  validating against story.json.
- tests/fixtures/payloads/ — one golden example payload per schema.
- 109 new tests (validator semantics, schema inventory/versioning, golden
  payloads, story acceptance + negative cases).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
The human/agent-readable contract doc: the portal/subject/driver model and
directive pattern, the normative state division (subjects own content +
per-learner mastery; learn owns the deterministic motivation layer; drivers
tutor and record), invocation/stream/exit-code conventions, the eight tutor
verbs with their payload schemas, shared vocabularies, the schema_version
policy, the story schema with its three reference fixtures, and the
registration-not-fork procedure for adding a subject.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
… 109 tests)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…toring broker (t11)

The thin dynamic layer behind agentculture.org/learn (spec c22): auth sessions,
the cross-subject learner ledger (KV + D1), and the tutoring broker. Everything
lives under workers/learn-api/ plus docs/hosting.md; disjoint from the Python
package.

- GitHub OAuth, web + device flow, issuing short-lived HMAC-signed session
  tokens (Bearer for CLI/MCP, cookie for web). Only the GitHub id + display name
  are persisted — no password, no email (scope read:user; email never read).
- Progress read/write mirroring the subject-plugin contract v1.0: POST /api/record
  validates the `recorded` shape and rejects score/grade/points exactly like
  record.json; GET /api/progress/:subject derives a progress-shaped payload from
  the append-only ledger.
- POST /api/tutor brokers to env.INFERENCE_URL (a served cloudai/ec2bedrock
  endpoint — never a bespoke provider SDK). Auth middleware runs before the
  broker route, so signed-out requests can NEVER trigger a model call — proven
  by test (zero inference calls when signed out).
- docs/hosting.md: the cost-when-busy note (honesty condition h8).
- 34 tests via node --test with in-memory KV/D1 stubs — no network, no wrangler.
  markdownlint passes on all new docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
… conformance gate (t3)

learn-cli becomes a real portal: which subjects exist is pure DATA
(learn/subjects/registry.json, overridable via LEARN_SUBJECTS_REGISTRY),
and subjects are driven ONLY as external subprocesses over --json — never
imported. Registers french/spanish/culture-guide with the pinned argv_prefix
shapes (culture-guide mounts contract verbs under a `subject` noun).

Registry API (learn.subjects): SubjectEntry (metadata-only; unknown keys are
rejected so content/progression can never leak in), load_registry, get_subject,
resolve_executable, is_available.

New CLI surface:
- `learn subjects [--json]` — list registered subjects with availability.
- `learn subject overview` — describe the noun (rubric: overview on any noun
  with verbs).
- `learn subject doctor <name> [--json]` — the conformance gate: spawns the
  subject's verbs and validates each --json payload against the contract schemas
  (executable-found, verb-doctor + contract-version pin, overview/progress/
  advice/story-list, and an error/exit-contract probe on a bad invocation).
  Emits the standard doctor payload (itself a valid subject_doctor payload).
  Exit 0 conforms, 2 drift/uninstalled (environment), 1 unknown subject (user).

Acceptance criteria met:
1. doctor validates verbs/schema_version/JSON payloads/exit codes and FAILS on
   drift (proven by a drifted fixture: payload + error-contract drift caught).
2. a dummy 4th subject registers and PASSES with zero new platform code
   (data entry + external conformant fixture only).
3. deleting a registry entry removes the subject from every face; tests enforce
   learn-cli ships no subject content/progression logic.

Tests: +24 (155 total). Coverage 92.7%. black/isort/flake8/bandit/markdownlint
and `teken cli doctor . --strict` (26/26) all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…rmance gate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…, adaptive what-next (t9)

The motivation layer learn-cli owns per the contract's state division: subjects
report raw pass|partial|fail observations and own their mastery ladder; this pure
package derives all *motivation* from the recorded-result ledger alone — no LLM,
no wall clock. Every time-dependent function takes an explicit `now: datetime`
(UTC-normalized), so identical history yields identical numbers on web, CLI, MCP.

Package (learn/motivation/, zero new runtime deps):
- score_exercise / score_session / score_lesson / mean_score — 0-100 scores
  (result base 0/60/100 blended 0.6*result + 0.4*accuracy when countable).
- streaks — per-subject and cross-subject day streaks; day boundary = UTC
  midnight; alive until a full day is missed.
- decayed_mastery — exponential retention on top of the stored ladder
  (10-day half-life; base strengths unknown/intro/practiced/mastered = 0/.4/.7/1).
- build_state / review_queue — fold the ledger into per-item state, surface
  touched items decayed below 0.5 (urgent < 0.35), most-decayed first.
- what_next — one typed Recommendation, never None: the completed-track
  (progress.done) case switches to maintenance + depth mode, so a learner who
  mastered every item of every track always has a meaningful next action.

Tests (+90): golden-history fixtures freeze exact scores/streaks/queue/what-next
(constant changes become visible diffs); a 40-cycle completed-track simulation
proves the never-ending invariant; AST guards enforce no wall-clock/network/LLM
imports. Motivation package at 100% coverage; all lint/rubric gates green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…view queues, what-next

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
Builds the /learn/ surface on org/site-astro's design system verbatim
(global.css tokens, Layout/Header/Footer/PageHero/Mark, both fonts; zero new
tokens) — a landing page (subject catalog), one sub-page per subject
(modules + story ladder), and a story reader per story, generated via
getStaticPaths over committed content-export fixtures authored from the real
french-cli/spanish-cli/culture-guide story and curriculum content. Adds
scripts/check-export-pages.mjs (npm run check) to fail the build if the
export and the built pages ever drift apart, and
.github/workflows/deploy-site.yml (build, check, then wrangler pages deploy
to a new "agentculture-learn" Cloudflare Pages project — production on
main, preview otherwise). The deploy step wraps dist/ under a learn/ folder
so Astro's base-prefixed asset hrefs resolve correctly on a plain static
host; hand-written page links stay relative so the site doesn't care where
it's ultimately mounted. Also widens .markdownlint-cli2.yaml's node_modules
ignore to a recursive pattern now that site-astro/ has its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…flow, export-page check)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…ger + sync (t12)

Adds learn/profile/ (stdlib-only, XDG-style local state: ledger.jsonl,
auth.json 0600, sync.json) and the learner-facing verbs it backs:

- `learn auth login|logout|status|overview` — GitHub device-flow sign-in via
  the learn API (t11), linking the CLI to the same learner account the web
  uses. Sign-in is additive only; every verb works fully offline with no
  session.
- `learn progress [subject]` — drives each registered+installed subject's own
  `progress --json` and blends the facts with the local ledger via
  learn.motivation for per-subject score/streak/item-history plus an overall
  streak.
- `learn next` — renders learn.motivation.what_next() over the full ledger.
- `learn record <subject> --item --result ...` — proxies to the subject's own
  `record` verb (source of truth for mastery), ledgers the ack locally, and
  best-effort syncs unsynced rows when signed in (never fails the command on
  a network error).

Sync is one-way push only for v1 (POST /api/record per unsynced row, cursor
tracked in sync.json) — documented in learn/profile/_sync.py's docstring.
learn/subjects/driver.py is the new runtime subprocess driver (the
conformance gate's read-only counterpart) these verbs use to call subjects.

Extends the conformant_subject.py test fixture with a `record` verb so the
new proxy path has something conformant to drive in tests. New tests cover
the profile store, the device-flow login (via a real localhost http.server
fixture), and progress/next/record including the anonymous-never-touches-
network guarantee.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…ore rule

The Python-template .gitignore's 'lib/' pattern silently excluded
site-astro/src/lib/ from t13's commit, and the ignored file was deleted
with the worktree. Scope the rule to the repo root (/lib/) and
reconstruct the typed content-export loader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…rn site export' (t10)

Derive learn-cli's three faces from one agentfront App (learn/front/):
a parallel registry whose 9 tools wrap the same operations the hand-rolled
CLI uses (subject registry + subprocess driving, never importing subject
code), plus 3 generated docs. The CLI, MCP, and HTTP faces enumerate
identically — proven in CI by agentfront's surfaces_agree.

- Depend on agentfront[mcp]>=0.20 (runtime dep; updates the "zero third-party
  dependencies" note in CLAUDE.md). agentfront 0.20.0 is on PyPI with the mcp extra.
- learn/front/: build_app() -> App; _driver.py drives subject contract verbs
  over subprocess --json (generalizes the conformance gate); _docs.py generates
  the portal/contract/subjects doc pages from package data (deterministic);
  _export.py writes the pinned static-content bundle.
- New CLI verbs (hand-rolled CLI, register() pattern + explain catalog entries):
  'learn mcp serve' (stdio single-'run' MCP server), 'learn mcp overview',
  'learn site serve' (WSGI markdown site), 'learn site export --out <dir>',
  'learn site overview'.
- 'learn site export' emits the pinned format t13's Astro site consumes:
  meta.json, subjects.json, stories-<subject>.json (available subjects only,
  full story objects from story list+read), docs/<slug>.md. Deterministic
  (sorted keys, no wall clock); never fails on a missing subject.
- Extend the conformant_subject fixture with story read / lesson / practice /
  record so the App tools and export are exercised end to end without french/
  spanish installed (via LEARN_SUBJECTS_REGISTRY).
- Ignore .venv in markdownlint config (new transitive deps ship LICENSE.md).

Gates: 297 tests pass, coverage 96%, black/isort/flake8/bandit/markdownlint
clean, teken rubric 26/26.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…ve/export, surfaces-agree gate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze

# Conflicts:
#	.markdownlint-cli2.yaml
…rogress/next/record verbs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze

# Conflicts:
#	learn/cli/__init__.py
#	learn/explain/catalog.py
#	tests/fixtures/subjects/conformant_subject.py
…lic export

Subject repos ship 'dev-' stories as in-repo test fixtures (t4/t5/t6 authored
them so code and content tasks could land in parallel). They are not learner
content: the raw story list (CLI/MCP faces) still shows them, but 'learn site
export' — the public web content — filters them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
Replaces t13's hand-authored fixtures: spanish now shows its 3 modules
(the fixtures predated t5's port), dev- stories are excluded by the
exporter, and the export docs/ pages ride along per the pinned format.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
Pages can't be mounted at a path, so the worker takes the whole /learn
route: /learn/api/* normalizes to the API, everything else proxies
read-only to PAGES_ORIGIN (the agentculture-learn Pages project, which
serves under /learn/ per the deploy wrapping). One worker deploy = the
whole same-origin mount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…t14)

Implements the spec's resource gate on site-astro/: signed-out visitors get
a provably pure-static site (zero calls to the learn API, hence zero model
calls), while signed-in visitors get progress/streak/next-lesson panels and
per-exercise result recording, hydrated client-side after a GET /api/me
session check.

- src/lib/api.ts: the single API_BASE constant (default same-origin
  /learn/api), imported by Header.astro's static "Sign in" href and every
  fetch() in the new client script.
- src/scripts/learner.js: the one piece of client JS beyond org's reveal
  script. Calls GET /api/me once; a 401/network error stamps
  data-auth="out" and stops (no further request of any kind); a 200 stamps
  data-auth="in" and only then hydrates learner panels
  (GET /api/progress/:subject), wires story-exercise recording
  (POST /api/record), and sign-out (POST /api/auth/logout).
- global.css: one shared `.signedin-only { display: none; }` default; each
  component pairs it with its own `html[data-auth="in"] ...` show/hide
  override, built entirely from existing tokens.
- Header.astro: an auth slot (quiet "Sign in" link vs. name + sign out).
- LearnerPanelSubject.astro / LearnerPanelOverview.astro: one card in both
  states so there's no layout shift; signed-out is an invitation, signed-in
  shows mastered/touched counts (honestly labeled as ledger coverage, not
  curriculum totals) and a "last active" chip standing in for a day-based
  streak the API doesn't expose.
- Story reader: signed-out keeps t13's CTA line; signed-in swaps in
  pass/partial/fail recording buttons per exercise, optimistic with a quiet
  inline status on failure.
- scripts/check-static-auth.mjs (npm run check:static-auth / test:static,
  now part of npm run check): proves the zero-API invariant statically —
  no built page's <html> carries data-auth, the CSS actually hides
  .signedin-only by default, and learner.js's fetch() calls are confined to
  the /api/me|/progress/|/record|/auth/* whitelist and lexically gated
  behind a confirmed session (checked against both source and the built,
  minified bundle).

npm run build / npm run check green (29 pages, unchanged); Python suite
untouched (353 passed); teken cli doctor --strict green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
… zero-API static proof

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…le launch bar (t16)

Add tools/launch-gate/ — one entrypoint `bash tools/launch-gate/run.sh` that
runs the whole pre-launch gate against the REAL production topology locally (no
cloud, no wrangler) and prints a machine-checked PASS/FAIL table.

Topology (api-server.mjs): wraps workers/learn-api's real default export with
its own test stubs (KVStub/D1Stub/makeEnv/mintToken from test/helpers.js) and a
loopback static server for the built site, with the worker's PAGES_ORIGIN
pointed at it — so the /learn/* zone-mount proxy is exercised for real and the
browser sees ONE origin (/learn static + /learn/api served by the worker).

The walk (walk.mjs, Playwright, phone 390x844 + desktop 1280x800):
- signed-out: 3 cards, story body+glossary, html[data-auth=out], invitation +
  CTA, and the network invariant (only GET /api/me — no progress/record/tutor).
- signed-in: html[data-auth=in] + display name; per subject (french, spanish,
  culture-guide) records a pass via the story button, then a CLI parity bridge
  (`learn record` signed in with the same session, synced to the local API)
  proves the synced row appears identically in the rendered panel and the API.
- degrades gracefully to a fetch/DOM fallback (browser checks SKIPPED, never a
  silent pass) if chromium can't launch.

One acceptance test per audience: web (walk.mjs), CLI
(tests/e2e/test_cli_golden.py), agent (tests/e2e/test_mcp_harness.py). The
pytest audiences are gated by RUN_LAUNCH_GATE=1 so the normal suite stays fast
(353 passed, 12 skipped).

The measurable launch bar (launch_bar.py): 3x `learn subject doctor` healthy,
content counts from the real content (>=10 stories x >=3 levels for each
language, >=3 culture-guide scenarios, dev-* excluded), the zero-API static
check (site-astro npm run check), and zero new design tokens vs org's
global.css (CSS token subset). report.py folds every producer's NDJSON results
into the final table and exit code.

Not wired into CI (runs pre-launch, by hand). playwright is the gate package's
own devDependency, not added to site-astro. No version bump.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…h bar (66 checks)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
@OriNachum

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

qodo-code-review Bot commented Jul 11, 2026

Copy link
Copy Markdown

Code Review by Qodo

Grey Divider

New Review Started

This review has been superseded by a new analysis

Grey Divider

Qodo Logo

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Learn uplift: contract v1.0, subject registry+doctor, three faces, site+worker, gate

✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Add a data-driven subject registry plus runtime conformance doctor for external subject CLIs.
• Ship contract v1.0 schemas + stdlib validator, motivation layer, and offline-first learner profile
 with sync.
• Deliver three faces (CLI/MCP/HTTP+export), Astro static web, Cloudflare Worker API, and launch
 gate.
Diagram

graph TD
  CLI["learn CLI"] --> Contract["Contract v1.0"] --> App["agentfront App"] --> Site["Astro static site"] --> Worker["Learn API Worker"] --> Infer{{"Inference endpoint"}}
  CLI --> Subjects{{"Subject CLIs"}}
  App --> Subjects
  CLI --> Profile["Profile+ledger+motivation"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use a full JSON-Schema library at runtime (e.g., jsonschema)
  • ➕ Less custom validator code; broader keyword support
  • ➕ More familiar tooling for schema evolution
  • ➖ Adds runtime dependencies contrary to the project’s “dependency-light” posture
  • ➖ Harder to guarantee schemas don’t rely on unsupported keywords without strict pinning
2. DB-backed server sessions instead of stateless signed tokens
  • ➕ Straightforward session invalidation and server-side auditability
  • ➕ Easier to evolve session metadata
  • ➖ Adds a storage dependency to every authenticated request
  • ➖ More operational complexity; higher per-request cost
3. SSR site instead of static + client hydration
  • ➕ Can render signed-in panels server-side and reduce client JS
  • ➕ Centralizes auth gating in the server
  • ➖ Undermines the signed-out “static and zero API” guarantee by default
  • ➖ Higher cost/complexity for anonymous traffic; caching becomes harder

Recommendation: The PR’s approach aligns tightly with the stated invariants: (1) subjects are external subprocess runtimes validated by a shipped contract; (2) signed-out traffic stays static and cannot spend inference tokens; (3) deterministic motivation is derived only from an append-only ledger; (4) a single registry prevents drift across CLI/MCP/HTTP. The main architectural risk is contract evolution—keep the schema inventory/unsupported-keyword guards and golden fixtures as the hard backstop to prevent silent validator/schema drift.

Files changed (153) +20162 / -4

Enhancement (71) +10956 / -0
__init__.pyWire new CLI commands into the root parser +16/-0

Wire new CLI commands into the root parser

• Registers new command groups (subjects/subject/site/mcp/auth) and new verbs (progress/next/record) so they’re available under 'learn' with consistent error handling.

learn/cli/init.py

auth.pyImplement 'learn auth' (device-flow login/logout/status) +248/-0

Implement 'learn auth' (device-flow login/logout/status)

• Adds GitHub device-flow login against the learn API, stores an on-disk session (0600), supports offline-safe logout, and provides local-only status including pending sync counts. Emits both human text and '--json' payloads.

learn/cli/_commands/auth.py

mcp.pyImplement 'learn mcp serve' MCP surface +115/-0

Implement 'learn mcp serve' MCP surface

• Adds an MCP noun that serves the agentfront App over stdio with a single-dispatch 'run' tool. Converts missing MCP dependency into a structured environment error with remediation.

learn/cli/_commands/mcp.py

next_cmd.pyAdd 'learn next' (deterministic what-next) +88/-0

Add 'learn next' (deterministic what-next)

• Adds a CLI wrapper around the motivation layer’s what-next decision engine, blending optional subject progress facts with the local ledger and remaining functional when offline/no subjects installed.

learn/cli/_commands/next_cmd.py

progress.pyAdd 'learn progress' (blended cross-subject standing) +192/-0

Add 'learn progress' (blended cross-subject standing)

• For each registered subject, drives the subject’s 'progress --json' when available and blends with ledger-derived scoring/streaks. Degrades gracefully for missing/unhealthy subjects to preserve read-only usability.

learn/cli/_commands/progress.py

record.pyAdd 'learn record' (proxy to subject, ledger locally, push sync) +121/-0

Add 'learn record' (proxy to subject, ledger locally, push sync)

• Runs a subject’s 'record' verb, validates the record ack payload against the contract, appends to the local ledger, and best-effort pushes pending ledger rows when signed in (never failing the command on network errors).

learn/cli/_commands/record.py

site.pyAdd 'learn site serve' and 'learn site export' +139/-0

Add 'learn site serve' and 'learn site export'

• Adds an agent-readable HTTP docs surface derived from the agentfront App registry and a deterministic exporter that writes the pinned content bundle consumed by the Astro site.

learn/cli/_commands/site.py

subject.pyAdd 'learn subject' noun with 'doctor' conformance gate +109/-0

Add 'learn subject' noun with 'doctor' conformance gate

• Implements 'learn subject doctor <name>' to run read-only contract probes and validates outputs against shipped schemas, returning exit 0/2 for healthy/drifted. Also adds 'overview' to satisfy the noun rubric.

learn/cli/_commands/subject.py

subjects.pyAdd 'learn subjects' registry listing command +51/-0

Add 'learn subjects' registry listing command

• Lists all registered subjects and whether each subject CLI is installed; supports both text output and '--json' for machine indexing.

learn/cli/_commands/subjects.py

__init__.pyAdd contract package API and schema loader +96/-0

Add contract package API and schema loader

• Defines contract version/vocabularies, schema inventory, schema loading from package data, and a 'validate(instance, schema_name)' API used by the conformance gate and record pipeline.

learn/contract/init.py

_validate.pyAdd stdlib-only JSON Schema subset validator +228/-0

Add stdlib-only JSON Schema subset validator

• Implements a minimal draft-2020-12-aligned subset validator with strict supported-keyword guarding, local $defs refs, and sibling-schema file refs resolved via a loader callable.

learn/contract/_validate.py

advice.jsonAdd advice payload schema +34/-0

Add advice payload schema

• Adds the versioned JSON Schema for the subject 'advice' verb payload.

learn/contract/schemas/advice.json

doctor.jsonAdd doctor payload schema +37/-0

Add doctor payload schema

• Adds the versioned JSON Schema for subject conformance/health reporting used by 'doctor'.

learn/contract/schemas/doctor.json

error.jsonAdd stderr error payload schema +20/-0

Add stderr error payload schema

• Adds the contract schema for structured stderr errors '{code,message,remediation}' expected on non-zero exits.

learn/contract/schemas/error.json

lesson.jsonAdd lesson payload schema +80/-0

Add lesson payload schema

• Adds the JSON Schema for lesson directives/next-step responses returned by subjects.

learn/contract/schemas/lesson.json

overview.jsonAdd overview payload schema +52/-0

Add overview payload schema

• Adds the JSON Schema for subject 'overview' payloads including optional modules metadata used by exports.

learn/contract/schemas/overview.json

practice.jsonAdd practice payload schema +75/-0

Add practice payload schema

• Adds the JSON Schema for practice directives/exercises emitted by subjects.

learn/contract/schemas/practice.json

progress.jsonAdd progress payload schema +85/-0

Add progress payload schema

• Adds the JSON Schema for within-subject progress payloads (mastery maps, totals, next pointers).

learn/contract/schemas/progress.json

record.jsonAdd record schema with derived-number prohibition +74/-0

Add record schema with derived-number prohibition

• Adds the JSON Schema for record requests/acks and structurally forbids derived scoring fields (score/grade/points) so subjects only report raw observations.

learn/contract/schemas/record.json

story.jsonAdd shared story content schema +131/-0

Add shared story content schema

• Adds the shared story JSON Schema used across subjects and by fixtures, enabling contract-level story validation.

learn/contract/schemas/story.json

story_list.jsonAdd story list payload schema +33/-0

Add story list payload schema

• Adds the JSON Schema for 'story list' payloads returned by subjects.

learn/contract/schemas/story_list.json

story_read.jsonAdd story read payload schema +40/-0

Add story read payload schema

• Adds the JSON Schema for 'story read' payloads (directive + full story object).

learn/contract/schemas/story_read.json

catalog.pyAdd explain catalog for learn platform concepts +308/-0

Add explain catalog for learn platform concepts

• Introduces a structured catalog of explanations aligned to the new platform surfaces and concepts, used by the learn CLI’s explain/documentation flows.

learn/explain/catalog.py

__init__.pyAdd agentfront App registry (single source for tools+docs) +175/-0

Add agentfront App registry (single source for tools+docs)

• Defines an agentfront App that exposes tools wrapping subject-driving operations, translating CliError into structured AgentfrontError for MCP. Serves as the shared registry powering CLI/MCP/HTTP and export.

learn/front/init.py

_driver.pyImplement subprocess JSON driver for subject verbs +129/-0

Implement subprocess JSON driver for subject verbs

• Runs subject contract verbs via subprocess, parses JSON, and preserves structured stderr errors when present. Enforces timeouts and surfaces failures as CliError (no tracebacks).

learn/front/_driver.py

_export.pyImplement deterministic static export bundle for the web face +162/-0

Implement deterministic static export bundle for the web face

• Exports meta/subjects/stories/docs into a pinned format consumed by the Astro site, preserving deterministic ordering and skipping unavailable subjects for story export. Excludes 'dev-' story ids from public export.

learn/front/_export.py

__init__.pyIntroduce deterministic motivation layer public API +117/-0

Introduce deterministic motivation layer public API

• Exposes scoring/streaks/decay/review/state/what-next APIs with explicit 'now' injection for determinism and cross-surface consistency.

learn/motivation/init.py

_constants.pyAdd central motivation policy constants +90/-0

Add central motivation policy constants

• Defines the tunable constants controlling scoring weights, decay half-life, review thresholds/backlog, batch sizing, and story recency windows.

learn/motivation/_constants.py

_decay.pyAdd mastery retention decay functions +47/-0

Add mastery retention decay functions

• Implements elapsed-days and exponential half-life retention calculations as pure functions, clamping out-of-order timestamps to avoid negative decay.

learn/motivation/_decay.py

_models.pyAdd immutable models and ledger parsing utilities +267/-0

Add immutable models and ledger parsing utilities

• Introduces frozen dataclasses for ledger entries, learner/item state, progress facts, streak/review/recommendation value types, plus UTC-normalizing parsing helpers.

learn/motivation/_models.py

_review.pyCompute deterministic review queue from decay +47/-0

Compute deterministic review queue from decay

• Produces an ordered list of due/urgent items based on decayed retention, with stable tie-breaks to guarantee consistent output across hosts.

learn/motivation/_review.py

_scoring.pyCompute deterministic 0–100 scores from raw outcomes +72/-0

Compute deterministic 0–100 scores from raw outcomes

• Derives scores from result grade and optional accuracy counts with documented weighting, including half-up rounding and aggregation helpers.

learn/motivation/_scoring.py

_state.pyFold ledger into per-item state with never-regress mastery +113/-0

Fold ledger into per-item state with never-regress mastery

• Groups ledger rows by (subject,item_id), infers highest implied mastery without regression, and accepts subject-reported mastery overrides for consistency with authoritative progress payloads.

learn/motivation/_state.py

_streaks.pyAdd UTC day-boundary streak calculations +103/-0

Add UTC day-boundary streak calculations

• Computes current and longest streaks per subject and overall using UTC midnight boundaries and “alive until a full day missed” semantics.

learn/motivation/_streaks.py

_whatnext.pyAdd never-empty what-next decision engine +180/-0

Add never-empty what-next decision engine

• Implements deterministic recommendation ordering across urgent review, forward progress, backlog, story breadth, and depth repetition, guaranteeing a recommendation even when everything is mastered.

learn/motivation/_whatnext.py

__init__.pyAdd learner profile package (ledger/auth/sync/API client) +101/-0

Add learner profile package (ledger/auth/sync/API client)

• Defines the cross-subject profile module’s public API: local ledger, stored auth, sync cursor, best-effort push sync, and thin API client calls with offline-safe failures.

learn/profile/init.py

_api.pyAdd stdlib-only learn API client with short timeouts +130/-0

Add stdlib-only learn API client with short timeouts

• Implements a minimal HTTP client using urllib, enforcing http(s) URLs, returning parsed JSON, and raising a single ApiError type so CLI commands can degrade cleanly when offline.

learn/profile/_api.py

_auth.pyAdd auth.json persistence with 0600 permissions +93/-0

Add auth.json persistence with 0600 permissions

• Stores device-flow session state locally, treats corrupt/missing files as signed-out (never blocking anonymous usage), and writes the token file with best-effort owner-only permissions.

learn/profile/_auth.py

_ledger.pyAdd append-only JSONL ledger storage +45/-0

Add append-only JSONL ledger storage

• Implements append/read/count for 'ledger.jsonl' with one JSON object per line, serving as the local source of truth for motivation computations.

learn/profile/_ledger.py

_paths.pyAdd XDG-style store directory resolution +66/-0

Add XDG-style store directory resolution

• Resolves the local profile store via LEARN_CLI_HOME override, XDG_DATA_HOME, or ~/.local/share fallback; creates dirs lazily only on write.

learn/profile/_paths.py

_sync.pyAdd push-only v1 sync of pending ledger rows +81/-0

Add push-only v1 sync of pending ledger rows

• Pushes unsynced ledger rows in order to the API, stops on first failure, persists cursor progress, and returns a structured SyncResult instead of raising exceptions.

learn/profile/_sync.py

_syncstate.pyAdd sync cursor file (sync.json) +48/-0

Add sync cursor file (sync.json)

• Persists a row-count cursor for push sync and safely defaults to cursor=0 when unreadable/missing.

learn/profile/_syncstate.py

__init__.pyAdd data-driven subject registry with strict entry validation +200/-0

Add data-driven subject registry with strict entry validation

• Implements a registry.json-driven subject catalog (overrideable via LEARN_SUBJECTS_REGISTRY), validates entries for allowed keys/required fields, and provides executable resolution + availability helpers.

learn/subjects/init.py

conformance.pyAdd subprocess conformance gate for 'learn subject doctor' +278/-0

Add subprocess conformance gate for 'learn subject doctor'

• Runs a safe, read-only conformance suite by spawning subject verbs over '--json' and validating results against shipped schemas, including stderr error-shape and exit-code probing. Produces a doctor payload that is itself schema-valid.

learn/subjects/conformance.py

driver.pyAdd subprocess driver used by learner-facing verbs +119/-0

Add subprocess driver used by learner-facing verbs

• Implements subject verb invocation with timeouts and JSON parsing, surfacing missing executables/drift as environment errors (exit 2) rather than tracebacks.

learn/subjects/driver.py

Footer.astroAdd footer component (org design) +137/-0

Add footer component (org design)

• Introduces the shared Footer component used across pages, aligned to the org design system.

site-astro/src/components/Footer.astro

Header.astroAdd header/nav component (org design) +251/-0

Add header/nav component (org design)

• Introduces the shared Header component and navigation shell, including sign-in affordances used by the client-side hydration layer.

site-astro/src/components/Header.astro

LearnerPanelOverview.astroAdd signed-in learner overview panel shell +152/-0

Add signed-in learner overview panel shell

• Adds the overview panel that remains inert in static HTML and is populated client-side only after auth is confirmed.

site-astro/src/components/LearnerPanelOverview.astro

LearnerPanelSubject.astroAdd per-subject learner panel shell +180/-0

Add per-subject learner panel shell

• Adds the per-subject panel UI for progress/next/record, designed to be hidden/inert until learner.js enables signed-in state.

site-astro/src/components/LearnerPanelSubject.astro

Mark.astroAdd Mark component +27/-0

Add Mark component

• Adds a small reusable mark/badge component used across the site UI.

site-astro/src/components/Mark.astro

PageHero.astroAdd PageHero component +62/-0

Add PageHero component

• Adds a reusable hero/header component used for consistent page structure.

site-astro/src/components/PageHero.astro

stories-culture-guide.jsonAdd culture-guide stories export fixture +311/-0

Add culture-guide stories export fixture

• Adds the culture-guide story/scenario export used to generate story pages.

site-astro/src/content-export/stories-culture-guide.json

stories-french.jsonAdd French stories export fixture +1086/-0

Add French stories export fixture

• Adds the French graded story export used to generate story list and reader pages.

site-astro/src/content-export/stories-french.json

stories-spanish.jsonAdd Spanish stories export fixture +1094/-0

Add Spanish stories export fixture

• Adds the Spanish story export used to generate story list and reader pages.

site-astro/src/content-export/stories-spanish.json

site.tsAdd site metadata module +30/-0

Add site metadata module

• Adds a typed site metadata module used across layouts/pages.

site-astro/src/data/site.ts

Layout.astroAdd global site layout wrapper +104/-0

Add global site layout wrapper

• Defines the shared layout including global styles and shared header/footer wiring.

site-astro/src/layouts/Layout.astro

api.tsAdd API path helpers for client hydration +20/-0

Add API path helpers for client hydration

• Adds helper utilities for constructing API routes used by the client-side learner module.

site-astro/src/lib/api.ts

content.tsAdd typed loader for pinned content-export data +99/-0

Add typed loader for pinned content-export data

• Adds typed loading/parsing for meta/subjects/stories export data to keep page templates consistent across fixture vs generated export inputs.

site-astro/src/lib/content.ts

index.astroAdd per-subject static page template +250/-0

Add per-subject static page template

• Renders each subject’s landing page (modules, stories, and signed-out messaging) with hooks for signed-in hydration panels.

site-astro/src/pages/[subject]/index.astro

index.astroAdd story reader page template +312/-0

Add story reader page template

• Renders story reader pages for each subject/story id from exported story objects, preserving static output while enabling signed-in augmentation.

site-astro/src/pages/[subject]/stories/[id]/index.astro

index.astroAdd /learn landing page template +175/-0

Add /learn landing page template

• Adds the learn landing page that lists subjects and story ladders with a signed-out tour baseline and optional signed-in hydration sections.

site-astro/src/pages/index.astro

learner.jsAdd signed-in hydration module with strict fetch gating +278/-0

Add signed-in hydration module with strict fetch gating

• Implements the single audited client script that checks session via /api/me and only then enables signed-in UI and allowed API calls (/me, /progress, /record, /auth/*). Designed to uphold the signed-out zero-API guarantee.

site-astro/src/scripts/learner.js

global.cssAdd global CSS aligned to org token set +398/-0

Add global CSS aligned to org token set

• Adds the site’s global styles with '.signedin-only' default hidden behavior and token usage intended to be a subset of org’s global.css (verified by launch-gate).

site-astro/src/styles/global.css

auth.jsAdd auth middleware (Bearer/cookie token + KV revocation) +50/-0

Add auth middleware (Bearer/cookie token + KV revocation)

• Implements token extraction, sign**********ry verification, and KV tombstone revocation checks, enforcing auth-before-route-body ordering for spend protection.

workers/learn-api/src/auth.js

db.jsAdd D1 data access layer for learners and records +77/-0

Add D1 data access layer for learners and records

• Implements upsert/read of learner identity and insert/list for append-only record rows via the D1 binding, designed for test stubbing.

workers/learn-api/src/db.js

github.jsImplement GitHub OAuth web + device flows with privacy guardrails +119/-0

Implement GitHub OAuth web + device flows with privacy guardrails

• Adds OAuth URL building, code exchange, device flow start/poll, and user fetch while explicitly persisting only numeric id and display name (no email scope).

workers/learn-api/src/github.js

index.jsImplement Worker router, /learn mount proxy, and tutoring broker gate +330/-0

Implement Worker router, /learn mount proxy, and tutoring broker gate

• Adds the main Worker fetch router, normalizes /learn/api paths for zone mounts, proxies non-API /learn traffic to Pages origin, enforces auth gating on spend routes, and defines OAuth/session/ledger/tutor endpoints.

workers/learn-api/src/index.js

progress.jsDerive progress payload from ledger coverage +82/-0

Derive progress payload from ledger coverage

• Builds a contract-shaped progress response from the server-side ledger, explicitly representing “ledger coverage” vs authoritative subject totals and producing deterministic next pointers.

workers/learn-api/src/progress.js

session.jsAdd stateless HMAC-signed session token implementation +65/-0

Add stateless HMAC-signed session token implementation

• Implements issuance and verification of signed session tokens with TTL and refresh-window logic, supporting both cookie-based web sessions and Bearer tokens for CLI/MCP.

workers/learn-api/src/session.js

util.jsAdd Worker utility helpers (CORS/cookies/crypto/fetch seams) +120/-0

Add Worker utility helpers (CORS/cookies/crypto/fetch seams)

• Provides shared helpers for JSON responses, redirects, cookie parsing/setting, base64url/HMAC signing, constant-time compare, and outbound fetch seams used for testability.

workers/learn-api/src/util.js

validate.jsAdd recorded payload validation mirroring contract constraints +112/-0

Add recorded payload validation mirroring contract constraints

• Validates subject ids and recorded objects (including rejecting score/grade/points), and provides mastery inference used by progress derivation and record ingestion.

workers/learn-api/src/validate.js

Documentation (11) +1350 / -2
CHANGELOG.mdAdd 0.5.0 release entry for learn uplift +18/-0

Add 0.5.0 release entry for learn uplift

• Documents the learn uplift feature set (contract, registry, faces, site, worker, gate) and notes the runtime dependency change. Pins release date and version summary.

CHANGELOG.md

CLAUDE.mdRefresh contributor/operator notes for new surfaces +7/-2

Refresh contributor/operator notes for new surfaces

• Updates the repo guidance to reflect the new web/worker faces and multi-surface architecture landed by the uplift.

CLAUDE.md

hosting.mdDocument hosting split and cost-when-busy model +76/-0

Document hosting split and cost-when-busy model

• Records the Cloudflare Pages + Worker split and explicitly ties cost containment to the signed-in resource gate. Enumerates expected cost drivers and mitigation levers (rate limiting, endpoint choice, caching).

docs/hosting.md

subject-plugin-contract.mdAdd subject-plugin contract v1.0 spec +262/-0

Add subject-plugin contract v1.0 spec

• Adds the written contract defining subject verbs, payload shapes, versioning, error semantics, and the directive pattern to keep subjects LLM-free and portal-driven.

docs/specs/subject-plugin-contract.md

_docs.pyAdd App docs (portal/subjects/contract) +167/-0

Add App docs (portal/subjects/contract)

• Defines the markdown docs registered in the agentfront App and used by the HTTP face and site exporter.

learn/front/_docs.py

README.mdDocument Astro site commands, export format, and auth split gates +335/-0

Document Astro site commands, export format, and auth split gates

• Documents the static web face, the pinned content-export format, routing under /learn, CI deploy behavior, and the signed-out/signed-in split enforced by static checks.

site-astro/README.md

portal.mdAdd portal doc export fixture +39/-0

Add portal doc export fixture

• Adds an exported markdown doc page used by the static docs section.

site-astro/src/content-export/docs/portal.md

subject-plugin-contract.mdAdd contract doc export fixture +46/-0

Add contract doc export fixture

• Adds an exported markdown doc page describing the subject-plugin contract.

site-astro/src/content-export/docs/subject-plugin-contract.md

subjects.mdAdd subjects doc export fixture +27/-0

Add subjects doc export fixture

• Adds an exported markdown doc page describing registered subjects and hosting conventions.

site-astro/src/content-export/docs/subjects.md

README.mdDocument launch gate purpose and run modes +167/-0

Document launch gate purpose and run modes

• Documents the pre-launch gate, its audience checks (CLI/MCP/web), and how to run it locally or against a live origin.

tools/launch-gate/README.md

README.mdDocument Worker routes, invariants, storage, and provisioning steps +206/-0

Document Worker routes, invariants, storage, and provisioning steps

• Documents the Worker’s route table, the “sign-in is the resource gate” invariant, KV/D1 usage, local dev workflow, and operator provisioning steps for OAuth/KV/D1/zone routes.

workers/learn-api/README.md

Other (71) +7856 / -2
deploy-site.ymlDeploy Astro web face to Cloudflare Pages (gated on secrets) +122/-0

Deploy Astro web face to Cloudflare Pages (gated on secrets)

• Adds a GitHub Actions workflow that builds site-astro, runs 'npm run check', wraps dist under '/learn/', and deploys to a dedicated Pages project when Cloudflare secrets are present. Includes a clean skip path that still validates builds on PRs before secrets exist.

.github/workflows/deploy-site.yml

.markdownlint-cli2.yamlAdd markdownlint configuration +9/-0

Add markdownlint configuration

• Introduces markdownlint-cli2 configuration for consistent markdown style across the new docs footprint.

.markdownlint-cli2.yaml

registry.jsonRegister french, spanish, and culture-guide subjects +29/-0

Register french, spanish, and culture-guide subjects

• Adds initial registry entries including argv prefixes and pinned contract_version=1.0 for each subject runtime.

learn/subjects/registry.json

pyproject.tomlBump to 0.5.0 and add agentfront[mcp] runtime dependency +5/-2

Bump to 0.5.0 and add agentfront[mcp] runtime dependency

• Updates project version and adds 'agentfront[mcp]>=0.20' so MCP and HTTP faces can be derived from the App registry while keeping other runtime deps minimal.

pyproject.toml

.nvmrcPin Node version for site builds +1/-0

Pin Node version for site builds

• Adds a Node version pin to align local and CI builds for the Astro site.

site-astro/.nvmrc

astro.config.mjsConfigure Astro for /learn base + static output +20/-0

Configure Astro for /learn base + static output

• Sets 'base: '/learn'' and 'output: 'static'' to ensure the build is pure static output while maintaining canonical URLs for the /learn mount.

site-astro/astro.config.mjs

package.jsonAdd Astro site package and check scripts +23/-0

Add Astro site package and check scripts

• Introduces Astro dependencies and scripts for build/dev/preview plus 'npm run check' which runs export/page consistency and static-auth invariant checks.

site-astro/package.json

check-export-pages.mjsGate that exported content matches generated pages +154/-0

Gate that exported content matches generated pages

• Fails the build if any subject/module/story in the export lacks a built page, or if dist/ contains orphan subject/story directories not backed by export data.

site-astro/scripts/check-export-pages.mjs

check-static-auth.mjsStatic proof: signed-out pages make zero API calls +360/-0

Static proof: signed-out pages make zero API calls

• Statically verifies: Astro remains static (no adapters), built pages lack baked-in auth state, '.signedin-only' is hidden by default, fetch calls are whitelisted and gated behind session confirmation, and '/api/tutor' is never referenced.

site-astro/scripts/check-static-auth.mjs

meta.jsonAdd content-export meta fixture +9/-0

Add content-export meta fixture

• Adds meta.json fixture describing contract/schema versions and subject ordering consumed by the site loader.

site-astro/src/content-export/meta.json

subjects.jsonAdd subjects export fixture +95/-0

Add subjects export fixture

• Adds subject metadata + module summaries used to build per-subject pages.

site-astro/src/content-export/subjects.json

tsconfig.jsonAdd site TypeScript configuration +5/-0

Add site TypeScript configuration

• Adds a minimal TS config to support typed site modules and build tooling.

site-astro/tsconfig.json

conftest.pyAdd pytest fixtures for registry/subjects/profile seams +182/-0

Add pytest fixtures for registry/subjects/profile seams

• Introduces fixtures to install temporary registries, provide subject CLI stubs, and control environment/path seams needed by the new subject/profile/front layers.

tests/conftest.py

__init__.pyAdd e2e test package marker +0/-0

Add e2e test package marker

• Adds an initializer to standardize e2e test package discovery/import behavior.

tests/e2e/init.py

conftest.pyAdd e2e harness configuration +84/-0

Add e2e harness configuration

• Adds configuration and helpers for launch-gate e2e tests covering CLI golden outputs and MCP harness checks.

tests/e2e/conftest.py

test_cli_golden.pyAdd CLI golden JSON e2e test +85/-0

Add CLI golden JSON e2e test

• Verifies stable '--json' outputs for key CLI verbs under the launch gate to prevent machine contract drift.

tests/e2e/test_cli_golden.py

test_mcp_harness.pyAdd MCP harness e2e test +80/-0

Add MCP harness e2e test

• Exercises the MCP server face to ensure tools can be driven over MCP and error payloads remain structured.

tests/e2e/test_mcp_harness.py

completed_track_current.jsonAdd motivation fixture: completed track (no decay) +59/-0

Add motivation fixture: completed track (no decay)

• Adds a frozen history fixture used to prove never-ending what-next behavior in maintenance mode without requiring time decay.

tests/fixtures/motivation/completed_track_current.json

completed_track_decayed.jsonAdd motivation fixture: completed track with decay +137/-0

Add motivation fixture: completed track with decay

• Adds a fixture that induces review backlog/urgency to validate decay and review queue ordering.

tests/fixtures/motivation/completed_track_decayed.json

cross_subject.jsonAdd motivation fixture: cross-subject history +87/-0

Add motivation fixture: cross-subject history

• Adds a multi-subject ledger fixture used to validate scoring/streak/what-next behavior across subjects.

tests/fixtures/motivation/cross_subject.json

single_subject_progression.jsonAdd motivation fixture: single-subject progression +98/-0

Add motivation fixture: single-subject progression

• Adds a history fixture used to validate mastery inference and what-next transitions during forward progress.

tests/fixtures/motivation/single_subject_progression.json

advice.jsonAdd golden advice payload +20/-0

Add golden advice payload

• Adds a schema-valid advice example payload used by contract schema and validator tests.

tests/fixtures/payloads/advice.json

doctor.jsonAdd golden doctor payload +30/-0

Add golden doctor payload

• Adds a schema-valid doctor example payload used by contract schema and validator tests.

tests/fixtures/payloads/doctor.json

error.jsonAdd golden error payload +5/-0

Add golden error payload

• Adds a structured stderr error payload example used by schema and conformance tests.

tests/fixtures/payloads/error.json

lesson.jsonAdd golden lesson payload +42/-0

Add golden lesson payload

• Adds a schema-valid lesson example payload used to lock contract semantics.

tests/fixtures/payloads/lesson.json

overview.jsonAdd golden overview payload +28/-0

Add golden overview payload

• Adds a schema-valid overview payload used by schema inventory and validator tests.

tests/fixtures/payloads/overview.json

practice.jsonAdd golden practice payload +40/-0

Add golden practice payload

• Adds a schema-valid practice payload used by contract validation tests.

tests/fixtures/payloads/practice.json

progress.jsonAdd golden progress payload +42/-0

Add golden progress payload

• Adds a schema-valid progress payload used by contract validation tests.

tests/fixtures/payloads/progress.json

record.jsonAdd golden record payload +28/-0

Add golden record payload

• Adds a schema-valid record ack payload used by schema tests and forbidden-field negative cases.

tests/fixtures/payloads/record.json

story.jsonAdd golden story payload +41/-0

Add golden story payload

• Adds a schema-valid story object used to validate the shared story schema.

tests/fixtures/payloads/story.json

story_list.jsonAdd golden story_list payload +25/-0

Add golden story_list payload

• Adds a schema-valid story list payload used by contract tests.

tests/fixtures/payloads/story_list.json

story_read.jsonAdd golden story_read payload +51/-0

Add golden story_read payload

• Adds a schema-valid story read payload used by contract tests.

tests/fixtures/payloads/story_read.json

culture-guide-scenario-overloaded-agent.jsonAdd culture-guide story acceptance fixture +76/-0

Add culture-guide story acceptance fixture

• Adds a real story fixture validating against the shared story schema to prove cross-subject compatibility.

tests/fixtures/stories/culture-guide-scenario-overloaded-agent.json

french-beginner-le-marche.jsonAdd French story acceptance fixture +86/-0

Add French story acceptance fixture

• Adds a French graded story fixture that validates against story.json.

tests/fixtures/stories/french-beginner-le-marche.json

spanish-beginner-la-panaderia.jsonAdd Spanish story acceptance fixture +86/-0

Add Spanish story acceptance fixture

• Adds a Spanish graded story fixture that validates against story.json.

tests/fixtures/stories/spanish-beginner-la-panaderia.json

conformant_subject.pyAdd conformant subject CLI fixture +363/-0

Add conformant subject CLI fixture

• Implements a fake subject CLI that follows the contract, used to test conformance gating and drivers without external dependencies.

tests/fixtures/subjects/conformant_subject.py

drifted_subject.pyAdd drifted subject CLI fixture +130/-0

Add drifted subject CLI fixture

• Implements a fake subject CLI that violates the contract, used to test remediation and failure reporting paths.

tests/fixtures/subjects/drifted_subject.py

test_auth_cli.pyAdd tests for 'learn auth' commands +256/-0

Add tests for 'learn auth' commands

• Covers device-flow orchestration behavior, persistence semantics, offline-safe logout, and status reporting in both text and JSON modes.

@qodo-code-review

qodo-code-review Bot commented Jul 11, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (3) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 8 rules

Grey Divider


Action required

1. CLI bypasses agentfront registry 📘 Rule violation ⌂ Architecture
Description
The PR explicitly keeps a hand-rolled argparse CLI in parallel to the agentfront App, rather than
deriving CLI commands from the shared registry. This violates the requirement that all UI faces
(CLI, MCP, web) be derived from a single agentfront registry to prevent drift.
Code

learn/front/init.py[R10-15]

+The App is a **parallel** registry, not a replacement for learn-cli's hand-rolled
+argparse CLI (which passes the agent-first rubric and stays as-is). Its tools wrap
+the same underlying operations that CLI uses — the subject registry
+(:mod:`learn.subjects`) plus subprocess driving (:mod:`learn.front._driver`) — so
+an agent over MCP drives the identical subjects the CLI does, never importing
+subject code.
Evidence
PR Compliance ID 1916860 requires all three faces to be derived from a single agentfront registry.
The PR states the App is a parallel registry (not the CLI source of truth) and the CLI continues
to manually import and register commands via argparse, demonstrating the CLI is not derived from the
agentfront registry.

Rule 1916860: Derive all UI faces from a single agentfront registry
learn/front/init.py[10-15]
learn/cli/init.py[64-106]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The CLI command set is registered manually via argparse modules instead of being generated/derived from the agentfront `App` registry, which creates a second independent source of truth.

## Issue Context
Compliance requires CLI, MCP server, and web UI to consume the same agentfront registry entries rather than maintaining separate command/tool lists.

## Fix Focus Areas
- learn/front/__init__.py[10-15]
- learn/cli/__init__.py[64-106]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. agentfront[mcp] in runtime deps 📘 Rule violation § Compliance
Description
pyproject.toml declares a third-party runtime dependency (agentfront[mcp]>=0.20) under
[project].dependencies. This violates the requirement that the runtime package declare no
third-party dependencies in dependencies.
Code

pyproject.toml[R16-19]

+# The three faces (CLI additions, MCP server, HTTP site + static export) are all
+# derived from one agentfront App (learn/front/). The [mcp] extra pulls the mcp
+# SDK that `learn mcp serve` needs; the CLI/HTTP/export faces work without it.
+dependencies = ["agentfront[mcp]>=0.20"]
Evidence
PR Compliance ID 1916796 requires that the runtime package have no third-party entries in
[project].dependencies. The PR adds dependencies = ["agentfront[mcp]>=0.20"], which is a
third-party dependency.

Rule 1916796: Runtime package must declare no third-party dependencies in pyproject.toml
pyproject.toml[16-19]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The runtime package must not declare any third-party dependencies in `[project].dependencies`, but `pyproject.toml` currently includes `agentfront[mcp]>=0.20`.

## Issue Context
Compliance requires `dependencies = []` (or omitted only if explicitly allowed by policy) and any non-runtime dependencies must live under a `dev` optional-dependencies group.

## Fix Focus Areas
- pyproject.toml[16-19]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. register() missing json defaults 📘 Rule violation ≡ Correctness
Description
Several new command modules define register(sub) but only set func defaults and do not also set
json defaults on the subparser(s). This violates the CLI command-module contract and can break
callers that rely on json being configured via parser defaults.
Code

learn/cli/_commands/subjects.py[R45-51]

+def register(sub: argparse._SubParsersAction) -> None:
+    p = sub.add_parser(
+        "subjects",
+        help="List registered subjects and whether each subject CLI is installed.",
+    )
+    p.add_argument("--json", action="store_true", help="Emit structured JSON.")
+    p.set_defaults(func=cmd_subjects)
Evidence
PR Compliance ID 1916807 requires that register(sub) configures both func and json on each
subparser. In multiple added modules, register() sets func but does not set json defaults
(e.g., subjects, next, progress, and the auth/mcp/site noun verb subparsers).

Rule 1916807: Command modules must define a register(sub) that configures func and json on the subparser
learn/cli/_commands/subjects.py[45-51]
learn/cli/_commands/next_cmd.py[82-88]
learn/cli/_commands/auth.py[221-248]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Command modules under `learn/cli/_commands/` must configure both `func` and `json` defaults on the subparser(s) they add. Multiple modules only call `set_defaults(func=...)` (or only set `json` on the noun parser but not the verb subparsers).

## Issue Context
Compliance requires each command module’s `register(sub)` to set both `func` and `json` on the relevant parser(s) it registers.

## Fix Focus Areas
- learn/cli/_commands/subjects.py[45-51]
- learn/cli/_commands/next_cmd.py[82-88]
- learn/cli/_commands/progress.py[181-192]
- learn/cli/_commands/record.py[99-121]
- learn/cli/_commands/auth.py[221-248]
- learn/cli/_commands/mcp.py[97-115]
- learn/cli/_commands/site.py[114-139]
- learn/cli/_commands/subject.py[88-109]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (2)
4. Proxy leaks Cookie header 🐞 Bug ⛨ Security
Description
proxyToPages() forwards request.headers verbatim to env.PAGES_ORIGIN, which includes the incoming
Cookie header (e.g., the signed session cookie) and can leak learner sessions to the Pages origin
when it’s a different host. This also forwards other hop-by-hop/sensitive headers (e.g.,
Authorization/Host) unintentionally.
Code

workers/learn-api/src/index.js[R102-105]

+  requireConfig(env, "PAGES_ORIGIN");
+  const origin = env.PAGES_ORIGIN.replace(/\/+$/, "");
+  const upstream = origin + url.pathname + url.search;
+  return fetch(new Request(upstream, { method: request.method, headers: request.headers }));
Evidence
The proxy path constructs an upstream URL and performs fetch with headers: request.headers, which
will include the inbound Cookie header. The Worker also sets the signed session as a cookie named
session, so that cookie can be present on requests that then get proxied.

workers/learn-api/src/index.js[98-106]
workers/learn-api/src/index.js[129-149]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`proxyToPages()` proxies `/learn/*` traffic to `env.PAGES_ORIGIN` while reusing `request.headers`. This forwards the inbound `Cookie` header (including the `session` cookie) and other sensitive headers to the upstream Pages origin.

## Issue Context
The Worker sets an HttpOnly `session` cookie on OAuth callback; subsequent navigations to `/learn/*` will include `Cookie: session=...` to the Worker. The current proxy implementation forwards that cookie to `PAGES_ORIGIN`.

## Fix Focus Areas
- Strip sensitive headers before proxying:
 - workers/learn-api/src/index.js[98-106]
- (Optional) Use a conservative allowlist of headers to forward (e.g., Accept, Accept-Language, User-Agent, If-None-Match), and explicitly drop `Cookie`, `Authorization`, `Host`, and Cloudflare forwarding headers.
 - workers/learn-api/src/index.js[98-106]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Ledger corruption crashes CLI 🐞 Bug ☼ Reliability
Description
read_ledger() calls json.loads() per line without handling JSONDecodeError, so one malformed line
can crash progress/next/sync flows. This contradicts the sync layer’s explicit contract that
push_pending() “never raises,” since it directly calls read_ledger().
Code

learn/profile/_ledger.py[R35-41]

+    with path.open("r", encoding="utf-8") as handle:
+        for line in handle:
+            stripped = line.strip()
+            if not stripped:
+                continue
+            rows.append(json.loads(stripped))
+    return rows
Evidence
read_ledger appends json.loads(stripped) without any error handling. The sync module explicitly
documents that its functions never raise, but both pending_sync_count() and push_pending() call
read_ledger() directly. progress also calls read_ledger() and would likewise crash on malformed
ledger content.

learn/profile/_ledger.py[29-41]
learn/profile/_sync.py[10-14]
learn/profile/_sync.py[51-59]
learn/cli/_commands/progress.py[121-132]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`read_ledger()` assumes every non-empty line is valid JSON and will raise on the first corrupted line. Several CLI commands call `read_ledger()` directly, and `push_pending()` claims it never raises but can raise via `read_ledger()`.

## Issue Context
The ledger is append-only; partial writes, manual edits, or truncation can plausibly leave a malformed last line. The CLI should degrade gracefully (especially the offline-first path).

## Fix Focus Areas
- Make `read_ledger()` robust: catch `json.JSONDecodeError` per line and either skip invalid lines (optionally collecting diagnostics) or stop parsing and return the rows parsed so far:
 - learn/profile/_ledger.py[29-41]
- Ensure `push_pending()` upholds its “never raises” contract even if the ledger is corrupted (either by relying on hardened `read_ledger()` or adding a try/except around it):
 - learn/profile/_sync.py[10-14]
 - learn/profile/_sync.py[51-59]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

6. Vary header overwritten 🐞 Bug ☼ Reliability
Description
withCors() unconditionally sets Vary: Origin, overwriting any existing Vary value from the
upstream response. This can cause cache correctness/performance issues by dropping other Vary
dimensions (e.g., Accept-Encoding) when responses are cached.
Code

workers/learn-api/src/index.js[R323-329]

+  const headers = new Headers(response.headers);
+  headers.set("Access-Control-Allow-Origin", allow);
+  headers.set("Access-Control-Allow-Credentials", "true");
+  headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
+  headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
+  headers.set("Vary", "Origin");
+  return new Response(response.body, { status: response.status, headers });
Evidence
withCors() clones upstream headers and then unconditionally replaces the Vary header with Origin,
discarding any pre-existing Vary values.

workers/learn-api/src/index.js[316-330]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`withCors()` overwrites the response’s `Vary` header with `Origin`, potentially removing existing `Vary` values that the upstream set.

## Issue Context
The Worker wraps *all* responses (including proxied Pages responses) with CORS headers. If the upstream response already includes `Vary`, overwriting it can lead to incorrect cache keying.

## Fix Focus Areas
- Preserve any existing `Vary` value and append `Origin` only if missing (comma-separated), rather than replacing it:
 - workers/learn-api/src/index.js[316-330]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Weak ledger file permissions 🐞 Bug ⛨ Security
Description
The CLI writes the learner’s local ledger and sync cursor without setting restrictive permissions,
so the resulting files may be readable by other local users depending on umask/defaults. This can
expose learning history and free-text notes stored in ledger rows.
Code

learn/profile/_ledger.py[R21-26]

+def append_ledger(row: dict[str, Any]) -> None:
+    """Append one row to the local ledger, creating the store dir if needed."""
+    ensure_store_dir()
+    with ledger_path().open("a", encoding="utf-8") as handle:
+        handle.write(json.dumps(row, ensure_ascii=False))
+        handle.write("\n")
Evidence
append_ledger writes ledger rows but never chmods the ledger file; ensure_store_dir also does not
set directory permissions. learn record supports --notes and appends subject-returned recorded
payloads into the ledger, so the ledger can contain free-text user data.

learn/profile/_ledger.py[21-26]
learn/profile/_paths.py[50-54]
learn/profile/_syncstate.py[46-48]
learn/cli/_commands/record.py[29-64]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The profile store writes `ledger.jsonl` (and `sync.json`) without explicitly setting secure permissions. Unlike `auth.json` (chmod 0600), these files may end up more broadly readable depending on platform/umask.

## Issue Context
`learn record` can attach free-text notes that are appended into the ledger; the ledger is the offline source of truth for progress/next computations.

## Fix Focus Areas
- Create/ensure the store directory with restrictive permissions (best-effort chmod 0700):
 - learn/profile/_paths.py[50-54]
- Ensure `ledger.jsonl` is created/written with mode 0600 (best-effort chmod after write, or create via `os.open(..., 0o600)`):
 - learn/profile/_ledger.py[21-26]
- Ensure `sync.json` is written with mode 0600 as well:
 - learn/profile/_syncstate.py[46-48]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread pyproject.toml
Comment thread learn/cli/_commands/subjects.py
Comment thread learn/front/__init__.py
Comment thread workers/learn-api/src/index.js
Comment thread workers/learn-api/src/index.js
Comment thread learn/profile/_ledger.py
Comment thread learn/profile/_ledger.py
Cognitive complexity (S3776):
- contract/_validate.py: split _check into one small predicate per JSON-Schema
  keyword (ref/type/enum-const/string/numeric/object/array/anyOf/not), and
  split _walk_keywords's recursion by value-shape (map/list) helpers.
- cli/_commands/auth.py: split cmd_auth_login into _start_device_flow,
  _poll_once/_await_device_confirmation, and _emit_login_success.
- front/_export.py: split _subject_stories's per-item logic into
  _read_full_story and _export_story.

Duplicated literals (S1192):
- cli/_commands/{mcp,site,auth,subject}.py: add a per-module _JSON_HELP
  constant for the repeated "--json" help string.
- contract/__init__.py: add _SUFFIX = ".json".
- contract/_validate.py: add _DEFS = "$defs".

Others:
- front/_driver.py: drop the redundant json.JSONDecodeError from an
  `except (json.JSONDecodeError, ValueError)` — it's already a ValueError.
- profile/_api.py: replace the "http://"/"https://" startswith-tuple scheme
  guard with urlparse(url).scheme in _ALLOWED_SCHEMES, removing the flagged
  hardcoded http:// literal without changing which schemes are accepted.
- motivation/_models.py: merge `endswith("Z") or endswith("z")` into one
  `endswith(("Z", "z"))` call.
- contract/_validate.py: merge the nested `if negated is not None: if not
  ...` into a single `if negated is not None and not ...`.

No behavior change: full suite stays 353 passed / 12 skipped, public APIs
(validate, unsupported_keywords, cmd_auth_login, export_site, drive, etc.)
are unchanged. Verified: pytest -n auto, black --check, isort --check-only,
flake8, bandit, and teken cli doctor . --strict all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…ons + constants)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
@sonarqubecloud

Copy link
Copy Markdown

@OriNachum OriNachum merged commit cb0ad72 into main Jul 11, 2026
9 checks passed
@OriNachum OriNachum deleted the feat/learn-uplift branch July 11, 2026 03:01
@OriNachum

Copy link
Copy Markdown
Contributor Author

PUSHBACK — deliberate, spec-confirmed decision. The 'zero third-party dependencies' rule was the template scaffold's state, not a standing constraint: the converged uplift spec (docs/specs/, claim c23, user-confirmed) makes agentfront>=0.20 THE runtime dependency the three faces derive from, and this PR updated CLAUDE.md's Commands section to state exactly that. Resolving.

  • learn-cli (Claude)

@OriNachum

Copy link
Copy Markdown
Contributor Author

PUSHBACK — defused by the dispatch guard: _dispatch reads json_mode = bool(getattr(args, "json", False)) (learn/cli/init.py:121), so a missing json default can never crash, and every leaf verb adds its own --json argument (argparse then always sets args.json for parsed leaves). Noun-level bare invocations set json=False explicitly (e.g. subject.py:97, auth.py:258). The golden --json tests cover all new verbs. Resolving.

  • learn-cli (Claude)

@OriNachum

Copy link
Copy Markdown
Contributor Author

PUSHBACK — documented architecture, not drift: the hand-rolled argparse CLI predates this PR, is tuned to pass the teken agent-first rubric (a CI gate), and stays; the agentfront App registry backs the MCP/HTTP/export faces, with surfaces_agree in CI proving those faces can't drift. CLAUDE.md's Commands section documents this split explicitly ('the hand-rolled argparse CLI under learn/cli/ stays; agentfront backs the parallel App registry in learn/front/'). Migrating the human CLI onto the App registry is a possible future refactor, not a defect. Resolving.

  • learn-cli (Claude)

@OriNachum

Copy link
Copy Markdown
Contributor Author

FIX — real finding, thanks. The zone-mount proxy now strips Cookie and Authorization before forwarding to PAGES_ORIGIN, with a regression test asserting the upstream request carries neither (other headers preserved). Fixed in #5.

  • learn-cli (Claude)

@OriNachum

Copy link
Copy Markdown
Contributor Author

FIX — withCors now uses headers.append("Vary", "Origin") so an upstream Vary (e.g. Accept-Encoding from the Pages origin) is preserved; regression test included. Fixed in #5.

  • learn-cli (Claude)

@OriNachum

Copy link
Copy Markdown
Contributor Author

FIX — ledger.jsonl is now created 0600, matching auth.json; regression test asserts the mode. Fixed in #5.

  • learn-cli (Claude)

@OriNachum

Copy link
Copy Markdown
Contributor Author

FIX — read_ledger() now skips lines that fail to parse (or parse to a non-object) instead of raising, so one corrupt row can't take down progress/next/sync. Skipping is stable under the append-only ledger, so the sync cursor's count-of-valid-rows arithmetic stays consistent (documented in the module docstring). Regression test covers malformed + non-object lines. Fixed in #5.

  • learn-cli (Claude)

OriNachum added a commit that referenced this pull request Jul 11, 2026
…0600 + corruption tolerance (#5)

Addresses the four real findings from PR #4's review threads
(3562990747, 3562990748, 3562990752, 3562990757).


Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
OriNachum added a commit that referenced this pull request Jul 11, 2026
* chore(worker): GITHUB_CLIENT_ID out of wrangler.toml — deploy-time secret from .env

The GitHub-App client id (technically public) is no longer hardcoded in the
repo: [vars] entry removed; it becomes a wrangler secret sourced from the
repo-root .env GITHUB_APP_CLIENT_ID, alongside GITHUB_CLIENT_SECRET. README
runbook updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* feat(worker): D1 consent schema + db helpers (t2)

Add a consents table (github_user_id, terms_version, granted_at; PK on
the first two) to schema.sql, idempotent and FK-light so consent can be
recorded before a learners row exists (the pending-consent flow, t5).
Add getConsent/recordConsent/deleteLearnerData to db.js, following the
existing prepared-statement/ON CONFLICT/ISO-8601 style, with unit tests
against an extended D1Stub (consents storage + batch()). No route
wiring — index.js/auth.js/session.js untouched, that's t5/t7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* feat(t1): versioned Terms of Use + Privacy Policy pages with shared TERMS_VERSION

Adds /learn/terms/ and /learn/privacy/ (site-astro), both rendering a
visible version + effective date and linked from the /learn footer. The
Privacy Policy names the three processors (GitHub OAuth, Cloudflare
hosting, AWS Bedrock Nova Sonic 2 / Nova Pro for the approved tutoring
tier — with an explicit speech/text-leaves-to-the-model-provider callout),
states what's collected (GitHub id + display name, the learning ledger, a
session cookie; no email, no password), why, retention (kept while the
account exists, hard-deleted on delete/withdraw), and the learner's rights
(access/export/delete/withdraw). Terms of Use covers GitHub-based accounts,
acceptable use, "as is" service, the approved AI-tutoring tier, data
ownership, termination/suspension, changes-to-terms -> version bump ->
re-consent, and a simple contact/governing-law section (GitHub issues,
no published email).

shared/terms-version.mjs is the single TERMS_VERSION/TERMS_EFFECTIVE_DATE
source of truth, started at 1.0.0 / 2026-07-11, re-exported (not copied)
by both site-astro/src/lib/terms.ts and workers/learn-api/src/terms.js —
proven importable under both Vite/astro build and Node's own --test
runner. tests/test_policy_pages.py cross-checks the pages' data claims
against workers/learn-api/schema.sql (no email/password column) and
proves the rendered version can't drift from the shared source.

check-export-pages.mjs and check-static-auth.mjs are updated to treat
terms/ and privacy/ as known non-subject, non-learner-panel pages so the
new routes don't trip the orphan-page or signed-out-invitation checks.

* feat(contract): add pick-the-right-word cloze exercises (t3, c16/h8)

Extends the existing `cloze` exercise type (unchanged since contract 1.0)
with two OPTIONAL fields — `text` (passage with `{{blank_id}}` placeholders)
and `blanks` ({id, options, answer}) — for a multi-blank, closed-option
variant a reader can check without a driver or model call. The legacy
single-blank free-text cloze shape (prompt + answer) is untouched.

- learn/contract/schemas/{practice,story}.json: additive `cloze_blank` $def
  + exercise.text/blanks, open payloads so nothing predating them breaks.
- learn/subjects/conformance.py: new `cloze-items` doctor check — reads
  every declared story and verifies well-formed blanks (options include the
  answer, blank ids unique + match text placeholders 1:1, item_id present),
  passing trivially for subjects that declare none.
- No changes needed to record.json or workers/learn-api/src/validate.js:
  `recorded` carries no exercise-shape field, so a cloze result (its
  multi-blank tally lands in the pre-existing correct/total counters) was
  already contract-valid — proven by new tests, not just asserted.
- site-astro: renders a pick-the-right-word cloze passage as inline button
  groups per blank with instant right/wrong feedback, entirely client-side
  (wireClozeExercises() in learner.js, zero fetch calls, works signed-out).
- tests/test_site_export.py locks the export byte-stable for a subject
  declaring no cloze items (acceptance criterion 5).

docs/specs/subject-plugin-contract.md §3.6.1 documents the shape and the
least-invasive design decisions for the subject-CLI follow-up tasks
(french-cli, spanish-cli, culture-guide) to author against.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* feat(infra): voice-bridge serverless spike + SAM template — Nova Sonic 2 over API GW WebSocket (t4)

Spike verdict (infra/SPIKE.md, run live against us-east-1, read-only):
InvokeModelWithBidirectionalStream is SigV4-only — Bedrock API keys get
403 "This operation does not support API Keys" — but a plain Python 3.12
process holding the stream via the experimental aws_sdk_bedrock_runtime
SDK completed a FULL audio round trip (Polly speech in, transcript +
19 audioOutput events back), so the Lambda relay authenticates with its
execution role and python3.12/arm64 stays (league's convention, no Node).
Bonus t15 intel: the bearer key serves Nova Pro on /converse (200 "Pong.")
but NOT on /openai/v1/chat/completions (model_not_found in us-east-1).

infra/template.yaml mirrors league-of-agents-platform's conventions: SAM,
one arm64 Lambda (router + self-invoked session holder), long-form
intrinsics parseable as plain YAML, capacity caps as Parameters
cross-checked against voice_bridge/config.py by test, an AWS Budgets
alarm pinned to a 20 USD/month ceiling with every sizing choice commented
against it, zero idle cost.

h7 groundwork: the bridge accepts ONLY short-lived voice tokens
(session.js-symmetric HMAC format, scope=voice, approved=true) minted by
learn-api (t16 mints; verification implemented + unit-tested here). No
token, no upstream Bedrock connection — enforced structurally at $connect
and proven by test.

65 new tests (tokens 21, handler 12, relay 6, template 26, incl. sam validate
when the CLI is present); suite 420 passed. TDD: tests written red first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* feat(worker): consent gate on both sign-in paths — pending-consent session, accept/decline endpoints, zero D1 writes pre-consent (t5)

Implements spec c9/h1 + decision c19: an unconsented sign-in (web
/api/auth/callback AND device-flow poll) no longer writes anything to D1.
It mints a short-lived (10 min) pending-consent session — the marker lives
in the stateless signed token, so nothing is persisted — and lands the
user on the consent surface: the web callback 302s to /learn/consent/
with a pending cookie; the device poll returns status "consent_required"
plus a pending Bearer token and the consent requirement so the CLI can
prompt.

Pending sessions can only GET /api/me (reports pending_consent +
consent_required incl. TERMS_VERSION, never sliding-refreshes), use the
consent endpoints, and log out; progress/record/tutor reject them with a
structured 403 consent_required before touching D1 or inference —
extending the resource-gate invariant one level (signed-out AND
pending-consent traffic can never trigger a model call).

New endpoints:
- GET  /api/consent          public: terms_version, effective_date, policy links
- POST /api/consent/accept   records consent (exact shared TERMS_VERSION)
                             FIRST, THEN upserts the learner (the consents
                             table's missing FK makes that order possible),
                             revokes the pending token, upgrades to a full
                             session (Set-Cookie + body Bearer, symmetric
                             across faces)
- POST /api/consent/decline  pending only: revokes the session, clears the
                             cookie, zero rows ever written; a full session
                             gets 409 already_consented (withdrawal =
                             deletion is t7)

The consent-satisfaction check is factored into
consent.js#consentSatisfiesCurrentTerms for t6 to extend with the
version-mismatch (re-consent) rule; re-consent itself is deliberately NOT
implemented here.

TDD: test/consent.test.js was written red-first — the h1 pair (fresh
sign-in on BOTH paths asserts zero D1 writes) failed against the previous
Worker with a logged learners insert, then the gate made it green. The D1
test stub now logs every write statement so "zero writes" and write ORDER
(consents before learners before records) are asserted literally. The two
pre-existing sign-in tests now seed a consented user, preserving their
intent (post-consent sign-in still upserts + issues a full session).

Tests: workers/learn-api 74/74 (was 58); pytest 458 passed, 12 skipped;
markdownlint clean on the README (route table + t10/t12 endpoint shapes
documented).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* feat(t14): re-export content from released subjects — french 0.6.0, spanish 0.7.0, culture-guide 0.10.0 (cloze in, dev- excluded)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* feat(worker): re-consent on TERMS_VERSION bump — stale-consent D1 check, structured 403, /api/me additive field (t6)

Extends t5's consentSatisfiesCurrentTerms (t5 scope: consent != null) to
require an exact terms_version match against the currently published
version (consent.js#currentTermsVersion, an env.TERMS_VERSION_OVERRIDE test
seam mirroring util.js#outboundFetch — production never sets it).

- requireConsented (auth.js) adds a getConsent D1 read for full sessions and
  rejects a stale match with 403 consent_required { reason: "stale_version",
  consent_required }, alongside the existing pending-session 403 (now
  { reason: "pending", ... } for the same shape). HttpError gained an
  additive `extra` body-fields param to carry this.
- Both sign-in paths (handleCallback, handleDevice) and handleConsentAccept
  now resolve the version through currentTermsVersion(env) instead of a bare
  TERMS_VERSION import, so a bump reroutes a previously-consented learner's
  next sign-in to pending-consent (zero new D1 writes) and re-accept always
  stamps the current version.
- /api/me never 403s on stale consent — it reports an additive
  reconsent_required field (+ consent_required when true), keeping identity
  checks reachable while other routes reject.

Design: D1-read-per-request over a claims-based (session-payload) cache —
simple and always correct now; documented tradeoff in README "Storage".

Tests: new test/reconsent.test.js (10 tests: predicate/seam, h2 stamping,
both sign-in paths re-routing on a simulated bump, the live-token 403 wall
with zero inference calls, the full v1->bump->403->re-accept->200 cycle,
/api/me's additive shape). 5 existing worker.test.js tests updated to seed a
matching consent row now that requireConsented checks D1, not just the
token's pending marker. 74 baseline + 10 new = 84/84 green; 458 Python tests
green; README route table + endpoint shapes updated additively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* feat(site): consent UI page wired to the pending-consent API (t10)

Add site-astro/src/pages/consent/ (spec c9/c19) presenting the WHAT/WHY/
RETENTION/WHO-CAN-SEE-IT notice and a dedicated src/scripts/consent.js
that drives it against workers/learn-api's pending-consent contract:
GET /api/me + GET /api/consent to render live state, POST
/api/consent/accept to upgrade into the signed-in hub, POST
/api/consent/decline to confirm nothing was stored. Handles pending,
expired (401, 10-min TTL), already-signed-in, declined, and error states.

Extend check-export-pages.mjs's KNOWN_NON_SUBJECT_DIRS and
check-static-auth.mjs's NOT_A_LEARNER_PANEL_PAGE for the new /consent/
route, and add a precise (not blanket) CONSENT_ALLOWED_SUFFIX_RE fetch
whitelist for consent.js, separate from learner.js's own. Fix the
built-bundle whitelist check to collect API_BASE aliases globally across
dist/ instead of per-file, since Vite now code-splits src/lib/api.ts into
its own shared chunk now that two scripts import it.

tests/test_consent_page.py locks the page's key claims (no email/no
password, imports TERMS_VERSION rather than hardcoding it, links both
policy pages, all five states present) the same way test_policy_pages.py
does for Terms/Privacy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* fix(worker): deterministic getConsent on granted_at ties — rowid tiebreak (flaky reconsent full-cycle test)

Two consent rows inserted in the same millisecond made ORDER BY granted_at
DESC ambiguous; the newest insertion now wins (rowid DESC), stub mirrors it,
regression test pins the tie case. Found by the t6 merge gate (2/5 runs red).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* feat(worker): self-serve export + delete — consent withdrawal = whole-learner erasure (t7)

GET /api/export (requireConsented) returns the learner's complete data as
self-describing JSON: identity row, every recorded result across every
subject (db.js#listAllRecords), and full consent history (db.js#listConsents).
POST /api/delete (requireAuth — deliberately not requireConsented, so a
stale-consent learner can still erase without re-accepting terms first)
requires a { confirm: "<github_user_id>" } body, calls the existing
deleteLearnerData batch, revokes the current session (KV tombstone), and
clears the cookie — h3 verbatim: no row survives in learners/records/consents
and the old token 401s on the next call. c18 holds: the ledger stays
append-only for everyone else (isolation-tested), whole-learner erasure is
the only deletion path, and a deleted learner signing in again is
indistinguishable from a brand-new one (full-cycle tested).

No site-astro change: no existing account/profile surface exists to hang a
destructive, confirmation-guarded control on without inventing new UI — both
routes are CLI/agent-ready today; the web affordance is deferred to t8
(roles + visibility), which already needs a real account-scoped surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* feat(t8): roles + visibility — ADMIN_GITHUB_IDS allow-list, admin learner list, self-serve visibility

Adds spec c12/h4 to the consent-first uplift: server-side-only admin
enforcement (src/admin.js#isAdmin/requireAdmin, backed by the new
ADMIN_GITHUB_IDS Worker var), GET /api/admin/learners (a three-query,
no-N+1 roster with per-subject record counts), default-private
learner visibility living in the existing state blob (no schema
change) with a POST /api/me/visibility toggle, and additive is_admin/
visibility fields on GET /api/me. h4 is proven with a forged
client-side admin claim test and a cross-learner isolation audit.

The site-astro account panel (LearnerPanelOverview's signed-in hub)
gains the visibility toggle plus the "Export my data"/"Delete my
data" web affordance t7 deferred here, and an admin-only learner
list gated on is_admin. The CLI gets `learn admin learners` following
the existing learn.profile authenticated-API pattern (learn auth),
with an explain-catalog entry and a green agent-first rubric.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* feat(t9): the approval gate — signed-out < signed-in < consented < APPROVED

The tutoring tier is now admin-granted per learner (spec c13/h5, decision
c20 enforced in code):

- Worker: `approved` flag in learners.state (no schema change, absent ==
  not approved), POST /api/admin/approve + POST /api/admin/revoke behind
  t8's requireAdmin; handleTutor 403s approval_required BEFORE the
  INFERENCE_URL check with zero outbound inference (h5, proven by extending
  the ordering test one level in test/approval.test.js); approve 409s
  consent_stale unless the target's consent covers the CURRENT terms (c20);
  a terms bump blocks tutoring even for an approved learner (both gates
  independent); deletion erases the flag — a re-signup is not approved.
  Additive: learner.approved on GET /api/me, approved on the admin roster.
- CLI: learn admin approve/revoke <github_user_id> (profile API client +
  catalog entries + rubric green); admin learners renders tutoring status.
- Site: account panel shows the caller's own tier; the admin list gains
  per-learner approve/revoke buttons; check-static-auth's admin whitelist
  is now exactly enumerated (learners|approve|revoke).

Worker tests 136 -> 151; pytest 500 green; teken doctor --strict green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* feat(t16): voice session end-to-end — approval-gated token mint, monthly voice budget, site voice UI (c15/h7)

The learn-api half of the voice approval gate plus the web face; the live
bridge deploy stays a supervised launch-gate step (t4 handoff #4).

Worker (POST /api/voice/token):
- Same learner-side gates as /api/tutor, same order: requireConsented ->
  the SAME approvedOf check (reused in place per t9's hook-point note, not
  duplicated) -> config 503 not_configured (VOICE_BRIDGE_URL +
  VOICE_TOKEN_SECRET, mirroring INFERENCE_URL) -> budget 429. h7: no token
  in any non-200 body, ever — signed-out/pending/stale/unapproved proven
  401/403/403/403 with the approval 403 firing before the config 503.
- src/voice.js mints the session.js-shaped token infra/voice_bridge/tokens.py
  verifies: {v, scope:"voice", uid, approved:true, iat, exp, sid}, unpadded
  base64url, HMAC-SHA256 over the encoded body, TTL 120s (mint->connect
  window; session length is the bridge's own cap).
- Cross-language pin: tests/fixtures/voice_token_cross_language.json is a
  committed JS-minted token — test/voice.test.js asserts the minter still
  reproduces it byte-for-byte, tests/test_voice_token_cross_language.py
  asserts tokens.py still verifies it. Neither side can drift alone.
- Per-learner monthly budget (t4 handoff #3): each mint books the full
  bridge session cap (300s) against learners.state.voice_usage; the mint
  that would exceed VOICE_MONTHLY_SECONDS_CAP (default 1800s = 30 min,
  sized against the $20 Budgets ceiling in a src/voice.js comment) 429s
  voice_budget_exhausted and books nothing; month rollover is free (stale
  month reads as zero). Honest scope documented in README: caps MINTED
  seconds, not actual streamed seconds — bridge caps + Budgets are the
  cost backstops; tightening needs bridge->worker reporting (follow-up).

Site (own page dir + own script, disjoint from t15's tutor/learner files):
- site-astro/src/pages/voice/index.astro: five gate states (signed-out
  visible with no JS, consent-needed / not-approved / ready / error), mic
  start/stop, session timer, granted limits, transcript.
- site-astro/src/scripts/voice.js: t4's client audio contract — mic ->
  downsample -> 16kHz/16-bit/mono LPCM -> {"seq", "audio"} frames (seq
  ordering-authoritative); 24kHz LPCM downstream via WebAudio; 4001
  unauthorized + connect-failure + budget/consent/approval mint errors all
  surfaced; client-side stop at max_session_seconds.
- h7 client half is structural and audited: exactly ONE new WebSocket(),
  inside the injectable openBridgeSocket seam, whose only call site sits
  after the token mint's non-ok early return — no token, no WebSocket.
  check-static-auth.mjs gains a precise VOICE_ALLOWED_SUFFIX_RE (/me,
  /voice/token) + the documented WebSocket allowance; check-export-pages
  learns the voice page; tests/test_voice_page.py locks all of it with
  pure file reads.

Verified end-to-end without AWS: a token minted by the real Worker route
passes the real bridge $connect gate (handler.lambda_handler) and a forged
token is refused 401. Deferred to launch gate: the live Nova Sonic
exchange — operator runbook (sam deploy VoiceTokenSecretValue=same secret
as the Worker's VOICE_TOKEN_SECRET, BudgetAlertEmail; then VOICE_BRIDGE_URL
+ secret on the Worker) recorded in workers/learn-api/README.md.

Tests: worker 151 -> 166; pytest 500 -> 533 (+ cross-language contract,
voice page); site build + check green; black/isort/flake8/bandit/
markdownlint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* feat(t15): Nova Pro tutoring through the existing broker — Converse wiring (config only), approved-tier tutor surface, personalized cloze to the ledger

The Worker diff is ZERO code lines (h11 verbatim): t15 ships as config +
docs + a client surface, with the four-level gate and the forward-verbatim
broker untouched.

Wiring (c24, corrected target): Bedrock's OpenAI-compat surface does NOT
serve Nova Pro in any probed region (model_not_found in us-east-1/us-west-2/
eu-west-1/eu-central-1) — the live-verified target is the native Converse
API. wrangler.toml records the exact URL commented out (model
us.amazon.nova-pro-v1:0, region us-east-1, INFERENCE_TOKEN = Bedrock API key
via wrangler secret put, sourced from .env AWS_BEDROCK_API_KEY_SECRET;
cost-when-busy: per-token only, zero idle). README "For t15" documents the
NO-GO/GO probe results incl. the sanitized live re-verification (HTTP 200,
latencyMs 491). test/tutor-converse.test.js (additive, worker suite now 153)
proves the unchanged broker forwards a Converse payload verbatim + the
learner stamp and relays responses/errors untranslated.

Tutor surface (site-astro, approved learners only): TutorPanel.astro on
every subject page + src/scripts/tutor.js (DOM) over src/scripts/
tutor-core.js (pure: prompts, Converse builders, defensive parsers, §3.6.1
validator, record builder). Three flows through POST /api/tutor — GRADE
(strict rubric + strict-JSON pinned in the system prompt, pass/partial/fail
+ explanation), NEXT-STEP (adaptive from /api/progress weak items,
weakest-first), CLOZE-GEN (a personalized pick-the-right-word story,
validated client-side against §3.6.1 INCLUDING item_id reuse from an
existing weak item as the stable join key; played inline with the story
reader's interaction pattern; tally recorded via the existing POST
/api/record with correct/total — no new record fields).

Gate honored end-to-end: the panel is signedin-only (signed-out sees
nothing, zero fetches); non-approved learners see the "tutoring is
admin-approved" note and tutor.js returns before wiring anything;
learner.js gains only the two-line learn:me hook. check-static-auth.mjs
extended precisely: an anchored TUTOR_ALLOWED_SUFFIX_RE (/tutor$ |
/progress/ | /record$), the built-bundle union, a hydrateTutorPanel gate
check, and the pre-t15 blanket "no /tutor anywhere" rule replaced by
"referenced ONLY through whitelisted fetch templates" (fetch templates,
quoted tutor-script paths, and // comments stripped; anything else still
fails) — all three mutation-tested.

Tests: tests/test_tutor_page.py (25, consent-page pattern) locks the gate
note, whitelist precision, prompt pinning, and the config-only wiring;
scripts/check-tutor-logic.mjs (16 checks, wired into npm run check)
executes tutor-core.js's pure logic — builders, fence/prose-tolerant
parsers, every §3.6.1 violation, the join-key rule, tally-to-record shape.
Deliberately untestable without live inference: real Nova Pro output
quality (t17's gate drives that live).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* feat(t17): extended launch gate — consent/approval/deletion/tutor/voice/cloze success signals + boundary invariants

The uplift's success signals (spec c8) as an extended launch gate that
FAILS against pre-uplift prod and passes post-deploy (h17), plus the
mechanical boundary invariants (h16).

New:
- tools/launch-gate/consent_walk.mjs — the consent -> approval ->
  deletion -> tutoring/voice signals as HTTP flows over a real origin.
  LOCAL mode drives the FULL authed flows against the in-process
  topology (10/10): zero D1 writes until consent (consent row before
  learner row), re-consent on version bump, self-serve delete + revoke,
  tutor 403 with zero outbound inference, admin approve/revoke, voice
  gate order (approval before config), policy/consent/voice pages +
  cloze content served. LIVE mode probes prod's unauthenticated surface
  (routes must 401 not 404, pages must 200 with markers).
- tests/test_launch_gate_invariants.py — always-on mechanical invariants
  (h16): no email/password column, no provider SDK in the Worker
  (dep or import), static-auth check stays wired, no subject prose
  authored in learn-cli, privacy policy names every processor and its
  data claims match the schema.
- tools/launch-gate/BASELINE-2026-07-11.md — the recorded pre-uplift
  LIVE baseline: 9/10 consent_walk LIVE checks fail against today's prod
  (routes/pages 404), the h17 evidence.

Changed:
- run.sh — two new steps: the Worker unit suite (168, the authoritative
  authed-flow proof) and consent_walk.mjs (honors LIVE_ORIGIN).
- api-server.mjs — fix ERR_HTTP_HEADERS_SENT on Set-Cookie responses
  (setHeader after writeHead); the harness could not serve consent
  accept/login/logout/delete e2e. Surfaced by the new consent walk.
- report.py — labels for the new audiences.

Gates: pytest 568 passed / 12 e2e-skipped; worker 168/168; site build +
npm run check green; consent_walk LOCAL 10/10, LIVE 9/10 (baseline);
black/isort/flake8/bandit clean; rubric doctor PASS; markdownlint 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* chore: bump 0.6.0, changelog, and record the c24 Converse correction (pending user confirmation)

- version-bump minor 0.5.4 -> 0.6.0 with the full uplift changelog
- spec: post-convergence correction note on c24 (OpenAI-compat ->
  native Converse), annotating not rewriting the converged claim;
  captured as q1 in the frame questions store for user confirmation
  at this PR gate. h11 (config-only, no provider SDK) still holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* chore: gitignore .devague/questions (devague working-state, auto-added)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* chore: scope GitGuardian ignore to the t16 voice-token test fixture

The cross-language contract fixture (tests/fixtures/voice_token_cross_
language.json) is a deterministic HMAC of a PUBLIC payload with a fake,
committed test secret — it pins the JS minter / Python verifier
agreement byte-for-byte and cannot change. GitGuardian's JWT detector
matches its shape; this narrowly-scoped ignore (that one path + the
known fake secret string) records it as a documented false positive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* spec: apply the user-confirmed c24 correction (OpenAI-compat -> native Converse)

User confirmed 2026-07-11. c24 and the Model-access decision now name the
native Bedrock Converse API (POST /model/us.amazon.nova-pro-v1:0/converse,
Bearer Bedrock API key); the addendum is retained as the evidence trail
and frame question q1 is resolved. h11 holds — config only, no provider SDK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* fix: reduce cognitive complexity of cloze conformance checks (S3776)

SonarCloud flagged _validate_cloze_exercise (28) and _probe_cloze_items
(37) as CRITICAL cognitive-complexity issues, both over the allowed 15
(python:S3776). Extract each distinct validation/probing concern into a
small, docstringed helper so both top-level functions become short
orchestrators:

- _validate_cloze_exercise: _validate_cloze_top_level_fields (text/item_id
  checks), _validate_cloze_blank_id (id presence/uniqueness/placeholder
  match), _validate_cloze_blank_answer (options/answer checks),
  _validate_cloze_blank (per-blank shape + id + answer), and
  _validate_cloze_orphan_placeholders (text placeholders with no blank).
- _probe_cloze_items: _read_story_exercises (one story's story-read +
  parse), _check_cloze_exercise_id_reuse (cross-story id dedup),
  _validate_cloze_in_story (one exercise's validation + dedup),
  _probe_story_cloze_items (one story's exercise loop), and
  _summarize_cloze_check (final check assembly).

Behavior-preserving: identical error strings, error ordering, and return
shapes throughout — no test needed to change. Full suite stays green
(568 passed, 12 skipped) plus black/isort/flake8/bandit and the
`teken cli doctor . --strict` rubric gate all pass.

* fix(voice-bridge): Query the concurrency gate instead of Scan-ing the table

Qodo finding (BUG 2, performance): DynamoSessionStore.count_active() used
table.scan(FilterExpression=...) to count active sessions for the $connect
concurrency gate. Sessions and inbound audio frames share one DynamoDB
table, and frames are the hot path (thousands of writes per session), so a
Scan read every frame item before filtering — $connect latency/cost grew
with frame volume instead of staying bounded by the (small, cap-limited)
session count.

Fix: move session-metadata items to a constant partition key (PK="ACTIVE",
SK=<connectionId>) instead of PK=SESSION#<connection>/SK=METADATA. Frame
items keep their existing PK=SESSION#<connection> shape untouched.
count_active() now does table.query(Select="COUNT",
KeyConditionExpression="PK = :active", ...) against that one partition,
which never touches frame items. put_session/get_session/delete_session
updated to the new key shape; no template.yaml schema change needed (PK/SK
stay String HASH/RANGE on the base table — no GSI required).

Test-first: added FakeDynamoTable (a boto3 Table-resource stand-in that
records scan()/query() calls) and four new tests against
DynamoSessionStore directly, including
test_count_active_queries_the_constant_pk_and_ignores_frame_items, which
seeds 3 metadata items alongside 1000 frame items and asserts the count is
exactly 3, scan() is never called, and query() is called once keyed on
PK="ACTIVE".

568 -> 572 passed, 12 skipped. black/isort/flake8 clean; bandit findings
unchanged (4 pre-existing low-severity, none introduced by this change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* fix(worker): close two Qodo review findings — delete session leak + voice budget race

BUG 1 (security): POST /api/delete only revoked the calling session's own
sid tombstone, so a learner's other still-valid sessions (another tab, a
CLI token, a session /api/me had refreshed) survived a self-delete.
handleDelete now also stamps a per-uid `revoked_uid:<uid>` KV marker with
the delete epoch second; auth.js#requireAuth rejects any token for that uid
whose `iat` predates it (strict `<`, not `<=`, so a resignup landing in the
same epoch-second as the delete is never false-revoked — see that
function's comment for why). session.js#issueSession gained an optional
`opts.now` override (mirroring voice.js#mintVoiceToken's own test-only
override) so tests can pin `iat` deterministically instead of relying on
wall-clock luck.

BUG 3 (reliability): handleVoiceToken's monthly voice-budget cap was a
plain read-check-write — two concurrent mints could both read the same
`used`, both pass the cap check, and both write, minting two sessions'
worth of budget for one booking. db.js#setLearnerVoiceUsage is now a
compare-and-swap on the learner row's `updated_at`
(`WHERE github_user_id = ? AND updated_at = ?`); index.js#bookVoiceUsage
retries (bounded, 3 attempts) against a fresh read on a lost CAS and
refuses the mint — without leaking the already-minted token — rather than
risk exceeding VOICE_MONTHLY_SECONDS_CAP. test/helpers.js's D1 stub models
the conditional UPDATE so the race is provably closed without wrangler or
real concurrency.

New tests: test/auth.test.js (deterministic requireAuth revocation-boundary
unit tests), test/export-delete.test.js (multi-session delete revocation +
cross-learner isolation), test/db.test.js (setLearnerVoiceUsage CAS unit
tests), test/voice.test.js (real concurrent-mint Promise.all test — verified
to fail against the pre-fix code, both mints landing 200 and overshooting
the cap). 168 -> 183 worker tests, all green; pytest stays 568 passed / 12
skipped. README.md and tools/launch-gate/ docs updated to match (test
count, revocation model, atomic-booking behavior).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

* docs: log the four Qodo/Sonar review fixes under 0.6.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant