Skip to content

feat: learn subject-plugin contract implementation + French story ladder (learn uplift)#3

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

feat: learn subject-plugin contract implementation + French story ladder (learn uplift)#3
OriNachum merged 7 commits into
mainfrom
feat/learn-uplift

Conversation

@OriNachum

Copy link
Copy Markdown
Contributor

What

french-cli becomes the reference subject-plugin implementation for the learn platform (closes #2):

  • Eight tutor verbsoverview, progress, advice, story list/read, lesson start/next/repeat, practice, record, doctor — all schema-valid --json, exit codes 0/1/2, directive pattern (the CLI is LLM-free; the driving agent tutors and records back).
  • Tutor engine: 3-module curriculum (5 lessons / 9 items / 18 exercises), per-learner XDG state (atomic writes, cross-session resume, never-regressing mastery inference).
  • 11 graded-reader stories (A1→C1) with glossaries + comprehension exercises; CI validates every story against the cited schema.
  • Cited contract v1.0 schemas + validator from learn-cli (cite-don't-import; docs/contract-provenance.md).

Conformance: learn subject doctor frenchhealthy, 8/8 checks (verified against learn-cli's gate).

Tests 23 → 113; coverage 88.6%; all gates green (pytest, lint, markdownlint, teken cli doctor --strict).

Built by the learn-uplift workforce (tasks t4 + t7a), TDD-gated merges. Spec: agentculture/learn-cli docs/specs/.

Note for maintainer: local branch docs/init-claude-md predates this work and remains unmerged (see #2).

  • learn-cli (Claude)

🤖 Generated with Claude Code

https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze

OriNachum and others added 7 commits July 11, 2026 03:44
Four beginner-rung stories for the French graded-reader ladder (t7a):
le marché (A1), la rentrée (A1), un café entre amis (A2), un week-end
à la montagne (A2). Each validates against learn-cli's story schema
(schema_version 1.0): 150-220 words, 3-5 glossary entries per 100
words, 4 comprehension exercises mixing multiple_choice with an
open-ended type.
Four B1-rung stories: la recherche d'appartement, le covoiturage, la
fête des voisins, un entretien d'embauche. Longer (230-270 words) and
grammatically richer than the A1/A2 rung (passé composé, imparfait,
subjunctive, subordinate clauses); same schema and exercise-mix
requirements as the rest of the ladder.
Three advanced-rung stories completing the graded-reader ladder: le
télétravail (B2, essay-style), la transition écologique (B2, village
profile), le vieux libraire (C1, literary short story) — 275-370
words, subjunctive/idiomatic register, still self-contained and
answerable from the text alone.

Adds docs/stories.md: the ladder table, the flat filename/id
convention, the reproducible authoring pipeline (intended cloudai-cli
batch command, marked pending since cloudai isn't installed in this
environment; every story here was hand-authored to the same schema
contract instead), the validation snippet against a learn-cli
checkout, how to add a story, and the human-review checklist
(level-appropriateness, glossary accuracy, exercise answerability,
cultural naturalness, originality).
Build the full tutor domain on the template chassis: the eight contract
verbs, XDG learner state, and committed story content — french-cli is now
the reference subject implementation that spanish-cli ports mechanically.

Verbs (all top-level via register(sub), schema-valid --json, exit 0/1/2):
- overview  — reconciled: emits contract subject_overview AND keeps the
  agent-first `sections` key (open payload; subject id is now `french`).
- progress  — per-item mastery ladder, counters, weak items, next step.
- advice    — deterministic study advice from stored state.
- story     — list + read; read wraps the committed story in a directive
  and sets the learner's position; unknown id -> exit 1.
- lesson    — start / next / repeat (--harder bumps the difficulty rung).
- practice  — item/module/lesson scope, or review (weakest touched items).
- record    — the write-back; updates mastery (fail->introduced,
  partial->practiced, pass->mastered, never regressing); recorded object
  structurally forbids score/grade/points.
- doctor    — reconciled: keeps identity checks, adds contract_version +
  content/state/schema checks; exit 0 healthy, 2 unhealthy.

Engine (LLM-free, subject-agnostic except subject.py + curriculum + stories):
- french/tutor/{subject,curriculum,stories,state,engine}.py
- state persists one JSON per learner under $FRENCH_CLI_LEARN_HOME >
  $XDG_DATA_HOME/french_cli/learn > ~/.local/share/french_cli/learn,
  atomic writes, resumes across sessions.

Content: 3 dev stories at content/stories/dev-*.json (glob-discovered, no
hardcoded names/counts) + a 3-module / 5-lesson / 9-item curriculum.

Cite-don't-import: french/contract_cite/ (11 schemas + stdlib validator,
verbatim from learn-cli ed81b87, contract v1.0); docs/contract-provenance.md.

Tests: +57 (23 -> 80): contract conformance per verb, state round-trip +
mastery inference, story content validation (globs content/stories),
behavioural CLI. Coverage 88.6%.

Gates: pytest, black, isort, flake8, bandit, markdownlint, and
`teken cli doctor . --strict` all green. Wheel force-includes content so
the published CLI resolves stories without the repo checked out.

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

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
@sonarqubecloud

Copy link
Copy Markdown

@OriNachum OriNachum merged commit 19b61f8 into main Jul 11, 2026
8 checks passed
@OriNachum OriNachum deleted the feat/learn-uplift branch July 11, 2026 02:04
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Implement learn subject-plugin contract + French graded-reader story ladder

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Adds eight tutor verbs (overview, progress, advice, story list/read, `lesson
 start/next/repeat, practice, record, doctor`) implementing the learn subject-plugin contract
 v1.0.
• Introduces an LLM-free tutor engine: a 3-module French curriculum, per-learner XDG state with
 atomic writes and never-regressing mastery inference, and deterministic directive payload builders.
• Cites contract v1.0 schemas + a stdlib JSON-Schema validator from learn-cli and enforces
 schema-valid --json outputs in CI.
• Ships an 11-story CEFR ladder (A1→C1) plus dev fixtures; CI validates every story and `filename ==
 id`.
• Reconciles overview/doctor with contract-required payloads and bumps package to 0.5.0.
Diagram

graph TD
  Driver([Driving agent / human]) -->|"invokes --json verbs"| CLI["french CLI verbs"] --> Tutor["_tutor.py helpers"] --> Engine["tutor/engine.py"]
  Engine --> Curriculum["tutor/curriculum.py"] --> Exercises["Exercises data"]
  Engine --> Stories[("content/stories/*.json")]
  Engine --> State[("Learner state (XDG)")]
  Engine --> Contract["contract_cite (schemas + validator)"]
  Contract -."cited from".-> LearnCli{{"learn-cli (upstream contract)"}}
  subgraph Legend
    direction LR
    _svc(["Service/Module"]) ~~~ _db[("Data store")] ~~~ _ext{{"External"}}
  end
Loading
High-Level Assessment

The cite-don't-import approach (vendor schemas + a minimal stdlib validator) fits the explicit constraint of zero runtime deps and never importing learn-cli. Alternative approaches like depending on learn-cli or adding jsonschema would violate the intended plugin-host relationship or the dependency policy; the provenance ledger + doctor pin-check mitigate drift risk.

Files changed (57) +5658 / -145

Enhancement (26) +3097 / -36
fr-a1-la-rentree.jsonAdd A1 graded-reader story: La rentrée de Nina +81/-0

Add A1 graded-reader story: La rentrée de Nina

• Adds a beginner (A1) story with glossary entries and comprehension exercises conforming to the story schema.

content/stories/fr-a1-la-rentree.json

fr-a1-le-marche.jsonAdd A1 graded-reader story: Le marché du mercredi +83/-0

Add A1 graded-reader story: Le marché du mercredi

• Adds a beginner (A1) story with glossary entries and comprehension exercises conforming to the story schema.

content/stories/fr-a1-le-marche.json

fr-a2-le-cafe-du-coin.jsonAdd A2 graded-reader story: Un café entre amis +84/-0

Add A2 graded-reader story: Un café entre amis

• Adds a beginner (A2) story with glossary entries and comprehension exercises conforming to the story schema.

content/stories/fr-a2-le-cafe-du-coin.json

fr-a2-le-weekend-a-la-montagne.jsonAdd A2 graded-reader story: Un week-end à la montagne +84/-0

Add A2 graded-reader story: Un week-end à la montagne

• Adds a beginner (A2) story with glossary entries and comprehension exercises conforming to the story schema.

content/stories/fr-a2-le-weekend-a-la-montagne.json

fr-b1-entretien-embauche.jsonAdd B1 graded-reader story: Un entretien d'embauche +96/-0

Add B1 graded-reader story: Un entretien d'embauche

• Adds an intermediate (B1) story with glossary entries and comprehension exercises conforming to the story schema.

content/stories/fr-b1-entretien-embauche.json

fr-b1-la-fete-des-voisins.jsonAdd B1 graded-reader story: La fête des voisins +93/-0

Add B1 graded-reader story: La fête des voisins

• Adds an intermediate (B1) story with glossary entries and comprehension exercises conforming to the story schema.

content/stories/fr-b1-la-fete-des-voisins.json

fr-b1-la-recherche-appartement.jsonAdd B1 graded-reader story: La recherche d'appartement +100/-0

Add B1 graded-reader story: La recherche d'appartement

• Adds an intermediate (B1) story with glossary entries and comprehension exercises conforming to the story schema.

content/stories/fr-b1-la-recherche-appartement.json

fr-b1-le-covoiturage.jsonAdd B1 graded-reader story: Le covoiturage +96/-0

Add B1 graded-reader story: Le covoiturage

• Adds an intermediate (B1) story with glossary entries and comprehension exercises conforming to the story schema.

content/stories/fr-b1-le-covoiturage.json

fr-b2-la-transition-ecologique.jsonAdd B2 graded-reader story: La transition écologique +104/-0

Add B2 graded-reader story: La transition écologique

• Adds an advanced (B2) story with glossary entries and comprehension exercises conforming to the story schema.

content/stories/fr-b2-la-transition-ecologique.json

fr-b2-le-teletravail.jsonAdd B2 graded-reader story: Le télétravail +100/-0

Add B2 graded-reader story: Le télétravail

• Adds an advanced (B2) story with glossary entries and comprehension exercises conforming to the story schema.

content/stories/fr-b2-le-teletravail.json

fr-c1-le-vieux-libraire.jsonAdd C1 graded-reader story: Le vieux libraire +104/-0

Add C1 graded-reader story: Le vieux libraire

• Adds an advanced (C1) story with glossary entries and comprehension exercises conforming to the story schema.

content/stories/fr-c1-le-vieux-libraire.json

__init__.pyRegister tutor verbs in the top-level CLI parser +15/-4

Register tutor verbs in the top-level CLI parser

• Adds the new tutor verbs (progress/advice/story/lesson/practice/record) to the argparse subcommand tree and reorders registration to separate agent-first vs tutor verbs.

french/cli/init.py

_tutor.pyAdd shared tutor-verb helpers for learner/state/emit plumbing +56/-0

Add shared tutor-verb helpers for learner/state/emit plumbing

• Provides reusable helpers to add --json/--learner flags, resolve learner ids, load state with environment-error mapping, and emit JSON/text consistently across tutor verbs.

french/cli/_commands/_tutor.py

advice.pyAdd 'french advice' verb +46/-0

Add 'french advice' verb

• Implements deterministic, read-only advice derived from learner state and emits schema-valid 'advice' payloads with runnable suggested commands.

french/cli/_commands/advice.py

lesson.pyAdd 'french lesson start|next|repeat' verbs +157/-0

Add 'french lesson start|next|repeat' verbs

• Implements lesson directives with target resolution by lesson/module/item id, state effects (touch + current position), and repeat difficulty bumping via --harder.

french/cli/_commands/lesson.py

overview.pyReconcile 'overview' with subject_overview contract payload +53/-32

Reconcile 'overview' with subject_overview contract payload

• Makes overview emit a contract-conformant subject_overview payload (modules + content counts) while retaining the existing rubric 'sections' key as open-payload extension.

french/cli/_commands/overview.py

practice.pyAdd 'french practice [scope]' verb +78/-0

Add 'french practice [scope]' verb

• Implements practice directives for a resolved scope (item/module/lesson) or default review mode selecting weak touched items, returning schema-valid practice_directive payloads.

french/cli/_commands/practice.py

progress.pyAdd 'french progress' verb +47/-0

Add 'french progress' verb

• Emits per-item mastery, counters, weak items, and a deterministic within-subject next-step recommendation from stored state.

french/cli/_commands/progress.py

record.pyAdd 'french record' write-back verb +101/-0

Add 'french record' write-back verb

• Accepts pass|partial|fail outcomes, appends raw history, updates mastery without regression, and emits schema-valid record_ack payloads (raw observations only).

french/cli/_commands/record.py

story.pyAdd 'french story list|read' verbs +105/-0

Add 'french story list|read' verbs

• Implements learner-independent story listing and learner-scoped story reading that sets current position and emits story_read directives; unknown ids return structured user errors (exit 1).

french/cli/_commands/story.py

__init__.pyAdd tutor package exports +23/-0

Add tutor package exports

• Creates the tutor package and exposes tutor submodules for use by CLI and doctor checks.

french/tutor/init.py

curriculum.pyAdd structured French curriculum data model and lookups +508/-0

Add structured French curriculum data model and lookups

• Introduces Module/Lesson/Item/Exercise models plus ordered French course content used to generate lesson/practice directives and interpret item ids.

french/tutor/curriculum.py

engine.pyAdd deterministic contract payload builders (LLM-free engine core) +367/-0

Add deterministic contract payload builders (LLM-free engine core)

• Implements payload construction for all tutor verbs, recommendation logic, and directive record_with examples so drivers can tutor and write results back consistently.

french/tutor/engine.py

state.pyAdd per-learner state store (XDG, atomic writes, typed loads) +343/-0

Add per-learner state store (XDG, atomic writes, typed loads)

• Stores learner mastery/history/current position and repeat difficulty; supports safe learner id mapping to filenames and enforces never-regressing mastery inference.

french/tutor/state.py

stories.pyAdd story discovery/loading/validation module +122/-0

Add story discovery/loading/validation module

• Resolves story content directories (override/repo/packaged), globs story files, validates story objects, and provides summaries + read-by-id helpers.

french/tutor/stories.py

subject.pyAdd French subject identity constants (porting seam) +51/-0

Add French subject identity constants (porting seam)

• Defines the subject id, command name, language tag, display metadata, and persona text used across directive payloads.

french/tutor/subject.py

Bug fix (1) +136 / -53
doctor.pyExpand 'doctor' into contract subject_doctor + additional health checks +136/-53

Expand 'doctor' into contract subject_doctor + additional health checks

• Keeps identity checks and adds content validation, state-dir writability, and schema-pin checks; emits the contract-required subject_doctor payload and exits 2 when unhealthy.

french/cli/_commands/doctor.py

Tests (10) +862 / -3
dev-cafe.jsonAdd dev fixture story: dev-cafe +66/-0

Add dev fixture story: dev-cafe

• Adds a dev-prefixed story fixture used by tests and conformance checks.

content/stories/dev-cafe.json

dev-directions.jsonAdd dev fixture story: dev-directions +66/-0

Add dev fixture story: dev-directions

• Adds a dev-prefixed story fixture used by tests and conformance checks.

content/stories/dev-directions.json

dev-marche.jsonAdd dev fixture story: dev-marche +78/-0

Add dev fixture story: dev-marche

• Adds a dev-prefixed story fixture used by tests and conformance checks.

content/stories/dev-marche.json

conftest.pyAdd autouse fixture to isolate learner state and JSON runner helper +36/-0

Add autouse fixture to isolate learner state and JSON runner helper

• Redirects learner state to a per-test temp directory and provides a helper to run CLI verbs and parse JSON output.

tests/conftest.py

test_cli.pyEnforce explain catalog coverage for every registered CLI path +25/-0

Enforce explain catalog coverage for every registered CLI path

• Walks the argparse tree to find all registered command paths and asserts each has an explain catalog entry.

tests/test_cli.py

test_cli_introspection.pyUpdate overview/doctor introspection tests for reconciled payloads +6/-3

Update overview/doctor introspection tests for reconciled payloads

• Adjusts expectations for subject id/kind fields and doctor exit code semantics (0 healthy, 2 unhealthy).

tests/test_cli_introspection.py

test_contract_conformance.pyAdd schema conformance suite mirroring learn-cli’s subject gate +210/-0

Add schema conformance suite mirroring learn-cli’s subject gate

• Executes each tutor verb, validates stdout JSON against cited schemas, checks schema/contract pins, and verifies exit codes and structured error payloads.

tests/test_contract_conformance.py

test_story_content.pyValidate all committed stories against the cited story schema +52/-0

Validate all committed stories against the cited story schema

• Globs content/stories/*.json, validates each file’s JSON against the story schema, and enforces 'filename == id' plus schema_version/subject fields.

tests/test_story_content.py

test_tutor_cli.pyAdd behavioural tests for tutor verb state interactions +133/-0

Add behavioural tests for tutor verb state interactions

• Verifies record→progress updates, lesson start lifting, advice targeting weak items, story read setting current position, and correct stdout/stderr behavior.

tests/test_tutor_cli.py

test_tutor_state.pyAdd state-store tests: XDG precedence, persistence, and mastery inference +190/-0

Add state-store tests: XDG precedence, persistence, and mastery inference

• Covers path resolution, atomic save/load, future-version and learner-mismatch refusal, never-regressing mastery inference, and repeat difficulty tracking.

tests/test_tutor_state.py

Documentation (6) +548 / -52
CHANGELOG.mdAdd 0.5.0 release notes for learn uplift +9/-0

Add 0.5.0 release notes for learn uplift

• Documents the new tutor verbs, tutor engine, story ladder, and cited contract materials under a new 0.5.0 entry.

CHANGELOG.md

README.mdDocument contract tutor surface, learner state, and content model +42/-8

Document contract tutor surface, learner state, and content model

• Adds an overview of the learn subject-plugin contract, a table of the eight tutor verbs and payload kinds, learner state persistence details, and story/curriculum content notes.

README.md

contract-provenance.mdAdd contract provenance ledger and re-sync procedure +59/-0

Add contract provenance ledger and re-sync procedure

• Documents exactly what was cited from learn-cli, provenance stamps, and the deterministic steps to re-sync when the upstream contract version changes.

docs/contract-provenance.md

stories.mdAdd story ladder and authoring/validation pipeline documentation +167/-0

Add story ladder and authoring/validation pipeline documentation

• Introduces the 11-story ladder table, naming conventions, validation steps, and a human review checklist for new story content.

docs/stories.md

learn.pyUpdate 'learn' prompt to describe tutor contract surface and state model +54/-23

Update 'learn' prompt to describe tutor contract surface and state model

• Rewrites the self-teaching prompt to document the eight tutor verbs, directive pattern, learner/state model, and updated exit code policy.

french/cli/_commands/learn.py

catalog.pyExpand explain catalog to cover the new tutor contract surface +217/-21

Expand explain catalog to cover the new tutor contract surface

• Updates the root docs to describe the tutor contract and adds entries for progress/advice/story/lesson/practice/record; also enforces full command-path coverage via tests.

french/explain/catalog.py

Other (14) +1015 / -1
__init__.pyAdd cited contract wrapper module (constants + schema loader + validate) +101/-0

Add cited contract wrapper module (constants + schema loader + validate)

• Ports the upstream contract API shape (CONTRACT_VERSION/SCHEMA_NAMES/etc.) and wires it to vendored schemas and the stdlib validator, while keeping learn-cli out of runtime deps.

french/contract_cite/init.py

_validate.pyVendor minimal stdlib JSON-Schema validator (verbatim citation) +245/-0

Vendor minimal stdlib JSON-Schema validator (verbatim citation)

• Adds a copied-in validator implementing the subset of JSON Schema draft 2020-12 used by the cited contract schemas, enabling dependency-free validation.

french/contract_cite/_validate.py

advice.jsonAdd cited schema: advice +34/-0

Add cited schema: advice

• Adds the contract v1.0 advice schema JSON file as a verbatim citation.

french/contract_cite/schemas/advice.json

doctor.jsonAdd cited schema: doctor +37/-0

Add cited schema: doctor

• Adds the contract v1.0 doctor schema JSON file as a verbatim citation.

french/contract_cite/schemas/doctor.json

error.jsonAdd cited schema: error +20/-0

Add cited schema: error

• Adds the contract v1.0 error schema JSON file as a verbatim citation.

french/contract_cite/schemas/error.json

lesson.jsonAdd cited schema: lesson_directive +80/-0

Add cited schema: lesson_directive

• Adds the contract v1.0 lesson schema JSON file as a verbatim citation.

french/contract_cite/schemas/lesson.json

overview.jsonAdd cited schema: subject_overview +52/-0

Add cited schema: subject_overview

• Adds the contract v1.0 overview schema JSON file as a verbatim citation.

french/contract_cite/schemas/overview.json

practice.jsonAdd cited schema: practice_directive +75/-0

Add cited schema: practice_directive

• Adds the contract v1.0 practice schema JSON file as a verbatim citation.

french/contract_cite/schemas/practice.json

progress.jsonAdd cited schema: progress +85/-0

Add cited schema: progress

• Adds the contract v1.0 progress schema JSON file as a verbatim citation.

french/contract_cite/schemas/progress.json

record.jsonAdd cited schema: record_ack +74/-0

Add cited schema: record_ack

• Adds the contract v1.0 record schema JSON file as a verbatim citation, including the structural prohibition on score/grade/points.

french/contract_cite/schemas/record.json

story.jsonAdd cited schema: story content +131/-0

Add cited schema: story content

• Adds the contract v1.0 story schema JSON file as a verbatim citation.

french/contract_cite/schemas/story.json

story_list.jsonAdd cited schema: story_list +33/-0

Add cited schema: story_list

• Adds the contract v1.0 story_list schema JSON file as a verbatim citation.

french/contract_cite/schemas/story_list.json

story_read.jsonAdd cited schema: story_read +40/-0

Add cited schema: story_read

• Adds the contract v1.0 story_read schema JSON file as a verbatim citation.

french/contract_cite/schemas/story_read.json

pyproject.tomlBump version to 0.5.0 and include story content in wheel builds +8/-1

Bump version to 0.5.0 and include story content in wheel builds

• Updates package version and configures hatch to force-include the 'content/' directory inside the wheel under 'french/_content/' for installed-story availability.

pyproject.toml

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (1) 📎 Requirement gaps (0) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 7 rules
✅ Skills: doc-test-alignment

Grey Divider


Action required

1. Changelog entries lack rationale 📘 Rule violation ⚙ Maintainability
Description
The newly added 0.5.0 CHANGELOG entries are single-sentence/fragments and do not include a second
sentence explaining why the change was made. This violates the requirement that each new changelog
entry include a multi-sentence rationale.
Code

CHANGELOG.md[R12-15]

+- Learn subject-plugin contract v1.0: eight tutor verbs (overview, progress, advice, story, lesson, practice, record, doctor) with schema-valid --json and contractual exit codes
+- Tutor engine: 3-module French curriculum (5 lessons, 9 items, 18 exercises), per-learner XDG state with atomic writes and resume
+- Story ladder: 11 graded-reader stories (A1-C1) with glossaries and comprehension exercises, plus dev fixtures; CI validates all content against the cited story schema
+- Cited contract schemas + validator from learn-cli (contract v1.0, provenance ledger in docs/contract-provenance.md)
Evidence
PR Compliance ID 1864313 requires each new/modified changelog entry to have at least two sentences
including an explicit rationale. The added 0.5.0 bullets are single-line entries without a second
sentence explaining why the change was made.

Rule 1864313: Require multi-sentence rationale entries in CHANGELOG.md
CHANGELOG.md[8-16]

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

## Issue description
New `CHANGELOG.md` entries must contain at least two sentences: one describing what changed and a second explaining why.

## Issue Context
PR Compliance ID 1864313 requires multi-sentence rationale entries for each new or modified changelog bullet.

## Fix Focus Areas
- CHANGELOG.md[8-16]

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


2. Story path traversal read 🐞 Bug ⛨ Security
Description
french.tutor.stories.read_story() builds a filesystem path from unvalidated story_id, so inputs
containing .. or path separators can escape the stories directory and read arbitrary readable
.json files. french story read <id> passes user input directly to this function, bypassing the
contract’s restricted story-id pattern.
Code

french/tutor/stories.py[R108-113]

+    root = content_dir()
+    if root is None:
+        return None
+    path = root / f"{story_id}.json"
+    if not path.is_file():
+        return None
Evidence
read_story() directly concatenates story_id into a path and checks is_file()/loads it, and
cmd_story_read forwards the positional CLI arg to that function. The contract schema’s story id
pattern forbids path separators, but no validation is applied before file access.

french/tutor/stories.py[102-117]
french/cli/_commands/story.py[39-46]
french/contract_cite/schemas/story.json[21-25]

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_story(story_id)` joins user-controlled `story_id` into a `Path` (`root / f"{story_id}.json"`) without validating the ID or ensuring the resulting path stays inside the intended content directory. This enables `../` traversal to read other `.json` files.

## Issue Context
The contract’s story ids are restricted to `^[a-z0-9][a-z0-9._-]*$` (no slashes), but the CLI does not enforce this before filesystem access.

## Fix Focus Areas
- Validate `story_id` against the contract regex and reject early (return `None` or raise a `CliError` upstream).
- Ensure the resolved path is inside `content_dir()` (e.g., `resolved.is_relative_to(root.resolve())` or equivalent guard).
- Consider adding the same validation in the CLI argument layer for faster feedback.

### Fix Focus Areas (code)
- french/tutor/stories.py[84-117]
- french/cli/_commands/story.py[39-46]

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


3. Negative duration breaks schema 🐞 Bug ≡ Correctness
Description
state.record_result() stores duration_seconds without enforcing the contract schema’s `minimum:
0, so record_ack` can become schema-invalid while still exiting success. This violates the
“schema-valid --json” contract and can break downstream consumers/conformance.
Code

french/tutor/state.py[R303-331]

+    if result not in RESULTS:
+        raise ValueError(f"result must be one of {', '.join(RESULTS)}")
+    if activity not in ACTIVITIES:
+        raise ValueError(f"activity must be one of {', '.join(ACTIVITIES)}")
+    if mastery is not None and mastery not in MASTERY_LEVELS:
+        raise ValueError(f"mastery must be one of {', '.join(MASTERY_LEVELS)}")
+    if total is not None and total < 1:
+        raise ValueError("total must be >= 1 when given")
+    if correct is not None and correct < 0:
+        raise ValueError("correct must be >= 0 when given")
+
+    recorded: dict[str, Any] = {
+        "item_id": item_id,
+        "activity": activity,
+        "result": result,
+        "at": now_iso(now),
+    }
+    if exercise_id:
+        recorded["exercise_id"] = exercise_id
+    if story_id:
+        recorded["story_id"] = story_id
+    if lesson_id:
+        recorded["lesson_id"] = lesson_id
+    if correct is not None:
+        recorded["correct"] = correct
+    if total is not None:
+        recorded["total"] = total
+    if duration_seconds is not None:
+        recorded["duration_seconds"] = duration_seconds
Evidence
The contract schema sets duration_seconds to a non-negative number, but the implementation accepts
any float and blindly writes it into the recorded object, which is returned in record_ack.
Therefore a negative input yields a schema-invalid payload.

french/contract_cite/schemas/record.json[31-46]
french/tutor/state.py[281-333]
french/cli/_commands/record.py[64-92]

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 contract schema requires `recorded.duration_seconds >= 0`, but `state.record_result()` accepts and persists negative values. This can produce schema-invalid `record_ack` output while still returning exit code 0.

## Issue Context
The CLI accepts `--duration-seconds` as a float with no bounds checks and passes it through to `state.record_result()`.

## Fix Focus Areas
- Add validation in `state.record_result()`:
 - if `duration_seconds is not None and duration_seconds < 0: raise ValueError(...)`
- (Optional) add an argparse type/validator for `--duration-seconds` so user error is caught at parse time.

### Fix Focus Areas (code)
- french/tutor/state.py[281-333]
- french/cli/_commands/record.py[64-101]
- french/contract_cite/schemas/record.json[31-46]

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



Remediation recommended

4. Story list emits invalid 🐞 Bug ≡ Correctness
Description
stories.list_summaries() does not schema-validate parseable story JSON and _summary() fills
missing required fields with empty strings, which can produce story_list payloads that violate
story_list.json while still exiting 0. This breaks the contract guarantee of schema-valid --json
when story content is schema-invalid-but-parseable (including via $FRENCH_CLI_CONTENT_DIR).
Code

french/tutor/stories.py[R84-106]

+def list_summaries(level: str | None = None) -> list[dict[str, Any]]:
+    """Story summaries for the catalog, optionally filtered by coarse ``level``.
+
+    Malformed files are skipped rather than crashing the whole listing; the
+    content-validation CI test is what fails the build on a bad file.
+    """
+    out: list[dict[str, Any]] = []
+    for path in story_files():
+        try:
+            story = load_story(path)
+        except (json.JSONDecodeError, ValueError, OSError):
+            continue
+        if level is not None and story.get("level") != level:
+            continue
+        out.append(_summary(story))
+    return out
+
+
+def read_story(story_id: str) -> dict[str, Any] | None:
+    """Return the full committed story object for ``story_id``, or ``None``.
+
+    Lookup is by filename stem (the pinned ``filename == id`` rule), so it is a
+    direct file open, not a directory scan.
Evidence
The story-list schema requires non-empty/enum-constrained id/title/level, but _summary() uses
story.get(..., "") and list_summaries() never validates the story object, so
schema-invalid-but-parseable story files can flow into schema-invalid story_list output.

french/tutor/stories.py[71-99]
french/contract_cite/schemas/story_list.json[14-30]

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

## Issue description
`list_summaries()` only skips JSON parse/IO errors; it does not run `validate_story()`. `_summary()` then defaults missing required fields (`id`, `title`, `level`) to `""`, which can violate the `story_list` schema.

## Issue Context
The repo has CI validation for committed stories, but runtime also supports `$FRENCH_CLI_CONTENT_DIR` overrides and can encounter corrupted/modified content; the CLI should still avoid emitting contract-invalid JSON.

## Fix Focus Areas
- In `list_summaries()`, run `validate_story(story)` and skip entries with validation errors.
- Avoid defaulting required fields to empty strings; either skip invalid stories or raise a clean `CliError` in strict modes.
- Consider validating `read_story()` outputs too (return `None` / error on schema-invalid story object).

### Fix Focus Areas (code)
- french/tutor/stories.py[71-117]
- french/contract_cite/schemas/story_list.json[14-30]

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


5. State file not refused 🐞 Bug ☼ Reliability
Description
state.load() only rejects future state_version when it’s an int, and _coerce() overwrites
state_version/subject without validating them or nested value types. Malformed state can pass
load() and later crash commands (e.g., lesson repeat calling int() on repeats values)
instead of raising StateError and returning a clean environment error.
Code

french/tutor/state.py[R157-194]

+def _coerce(raw: dict[str, Any], learner: str) -> dict[str, Any]:
+    """Typed, identity-safe projection of ``raw`` onto the current schema."""
+    embedded = raw.get("learner")
+    if embedded is not None and embedded != learner:
+        raise StateError(f"state file for '{learner}' carries a different learner ({embedded!r})")
+    base = new_state(learner)
+    for key, expected in _FIELD_TYPES.items():
+        if key in raw:
+            if not isinstance(raw[key], expected):
+                raise StateError(f"state field '{key}' has the wrong type")
+            base[key] = raw[key]
+    base["learner"] = learner
+    base["subject"] = subject.SUBJECT_ID
+    base["state_version"] = STATE_VERSION
+    return base
+
+
+def load(learner: str) -> dict[str, Any]:
+    """Load a learner's state, or a fresh default if none exists.
+
+    Raises :class:`StateError` on a corrupt file or a *newer* state version.
+    """
+    path = state_path(learner)
+    if not path.is_file():
+        return new_state(learner)
+    try:
+        raw = json.loads(path.read_text(encoding="utf-8"))
+    except (json.JSONDecodeError, OSError) as exc:
+        raise StateError(f"cannot read state file {path}: {exc}") from exc
+    if not isinstance(raw, dict):
+        raise StateError(f"state file {path} is not a JSON object")
+    version = raw.get("state_version", STATE_VERSION)
+    if isinstance(version, int) and version > STATE_VERSION:
+        raise StateError(
+            f"state file {path} has state_version {version}, "
+            f"newer than this CLI supports ({STATE_VERSION})"
+        )
+    return _coerce(raw, learner)
Evidence
load() bypasses version refusal when state_version is not an int and _coerce() does not
validate subject/version/nested value types. repeat_difficulty() and bump_repeat() call
int(...) on state['repeats'][lesson_id], and lesson repeat calls those functions, so malformed
repeats content can crash user-facing commands instead of being mapped to a clean env error.

french/tutor/state.py[145-194]
french/tutor/state.py[266-275]
french/cli/_commands/lesson.py[70-82]

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 state loader intends “tolerant-but-typed loads”, but it does not enforce:
- `state_version` type (non-int values bypass the future-version refusal)
- `subject` consistency
- nested invariants/types for `repeats` (and other nested structures)
This allows malformed state to reach code paths that assume numeric values and crash.

## Issue Context
`_tutor.load_state()` converts `StateError` into a clean `EXIT_ENV_ERROR`, but permissive loading means truly-bad state can still crash later (e.g., `repeat_difficulty()` calling `int()` on a non-numeric string).

## Fix Focus Areas
- Enforce `state_version` must be an `int`; if present and not int, raise `StateError`.
- Validate embedded `subject` (if present) matches `subject.SUBJECT_ID`.
- Validate `repeats` values are ints (or coercible) during load/coerce; otherwise raise `StateError`.
- Consider validating `mastery` values are within `MASTERY_LEVELS` on load to prevent later schema-invalid `progress` output.

### Fix Focus Areas (code)
- french/tutor/state.py[145-195]
- french/tutor/state.py[266-275]
- french/cli/_commands/lesson.py[70-82]

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


Grey Divider

Qodo Logo

Comment thread CHANGELOG.md
Comment on lines +12 to +15
- Learn subject-plugin contract v1.0: eight tutor verbs (overview, progress, advice, story, lesson, practice, record, doctor) with schema-valid --json and contractual exit codes
- Tutor engine: 3-module French curriculum (5 lessons, 9 items, 18 exercises), per-learner XDG state with atomic writes and resume
- Story ladder: 11 graded-reader stories (A1-C1) with glossaries and comprehension exercises, plus dev fixtures; CI validates all content against the cited story schema
- Cited contract schemas + validator from learn-cli (contract v1.0, provenance ledger in docs/contract-provenance.md)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Changelog entries lack rationale 📘 Rule violation ⚙ Maintainability

The newly added 0.5.0 CHANGELOG entries are single-sentence/fragments and do not include a second
sentence explaining why the change was made. This violates the requirement that each new changelog
entry include a multi-sentence rationale.
Agent Prompt
## Issue description
New `CHANGELOG.md` entries must contain at least two sentences: one describing what changed and a second explaining why.

## Issue Context
PR Compliance ID 1864313 requires multi-sentence rationale entries for each new or modified changelog bullet.

## Fix Focus Areas
- CHANGELOG.md[8-16]

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

Comment thread french/tutor/stories.py
Comment on lines +108 to +113
root = content_dir()
if root is None:
return None
path = root / f"{story_id}.json"
if not path.is_file():
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Story path traversal read 🐞 Bug ⛨ Security

french.tutor.stories.read_story() builds a filesystem path from unvalidated story_id, so inputs
containing .. or path separators can escape the stories directory and read arbitrary readable
.json files. french story read <id> passes user input directly to this function, bypassing the
contract’s restricted story-id pattern.
Agent Prompt
## Issue description
`read_story(story_id)` joins user-controlled `story_id` into a `Path` (`root / f"{story_id}.json"`) without validating the ID or ensuring the resulting path stays inside the intended content directory. This enables `../` traversal to read other `.json` files.

## Issue Context
The contract’s story ids are restricted to `^[a-z0-9][a-z0-9._-]*$` (no slashes), but the CLI does not enforce this before filesystem access.

## Fix Focus Areas
- Validate `story_id` against the contract regex and reject early (return `None` or raise a `CliError` upstream).
- Ensure the resolved path is inside `content_dir()` (e.g., `resolved.is_relative_to(root.resolve())` or equivalent guard).
- Consider adding the same validation in the CLI argument layer for faster feedback.

### Fix Focus Areas (code)
- french/tutor/stories.py[84-117]
- french/cli/_commands/story.py[39-46]

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

Comment thread french/tutor/state.py
Comment on lines +303 to +331
if result not in RESULTS:
raise ValueError(f"result must be one of {', '.join(RESULTS)}")
if activity not in ACTIVITIES:
raise ValueError(f"activity must be one of {', '.join(ACTIVITIES)}")
if mastery is not None and mastery not in MASTERY_LEVELS:
raise ValueError(f"mastery must be one of {', '.join(MASTERY_LEVELS)}")
if total is not None and total < 1:
raise ValueError("total must be >= 1 when given")
if correct is not None and correct < 0:
raise ValueError("correct must be >= 0 when given")

recorded: dict[str, Any] = {
"item_id": item_id,
"activity": activity,
"result": result,
"at": now_iso(now),
}
if exercise_id:
recorded["exercise_id"] = exercise_id
if story_id:
recorded["story_id"] = story_id
if lesson_id:
recorded["lesson_id"] = lesson_id
if correct is not None:
recorded["correct"] = correct
if total is not None:
recorded["total"] = total
if duration_seconds is not None:
recorded["duration_seconds"] = duration_seconds

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Negative duration breaks schema 🐞 Bug ≡ Correctness

state.record_result() stores duration_seconds without enforcing the contract schema’s `minimum:
0, so record_ack` can become schema-invalid while still exiting success. This violates the
“schema-valid --json” contract and can break downstream consumers/conformance.
Agent Prompt
## Issue description
The contract schema requires `recorded.duration_seconds >= 0`, but `state.record_result()` accepts and persists negative values. This can produce schema-invalid `record_ack` output while still returning exit code 0.

## Issue Context
The CLI accepts `--duration-seconds` as a float with no bounds checks and passes it through to `state.record_result()`.

## Fix Focus Areas
- Add validation in `state.record_result()`:
  - if `duration_seconds is not None and duration_seconds < 0: raise ValueError(...)`
- (Optional) add an argparse type/validator for `--duration-seconds` so user error is caught at parse time.

### Fix Focus Areas (code)
- french/tutor/state.py[281-333]
- french/cli/_commands/record.py[64-101]
- french/contract_cite/schemas/record.json[31-46]

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

Comment thread french/tutor/stories.py
Comment on lines +84 to +106
def list_summaries(level: str | None = None) -> list[dict[str, Any]]:
"""Story summaries for the catalog, optionally filtered by coarse ``level``.

Malformed files are skipped rather than crashing the whole listing; the
content-validation CI test is what fails the build on a bad file.
"""
out: list[dict[str, Any]] = []
for path in story_files():
try:
story = load_story(path)
except (json.JSONDecodeError, ValueError, OSError):
continue
if level is not None and story.get("level") != level:
continue
out.append(_summary(story))
return out


def read_story(story_id: str) -> dict[str, Any] | None:
"""Return the full committed story object for ``story_id``, or ``None``.

Lookup is by filename stem (the pinned ``filename == id`` rule), so it is a
direct file open, not a directory scan.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. Story list emits invalid 🐞 Bug ≡ Correctness

stories.list_summaries() does not schema-validate parseable story JSON and _summary() fills
missing required fields with empty strings, which can produce story_list payloads that violate
story_list.json while still exiting 0. This breaks the contract guarantee of schema-valid --json
when story content is schema-invalid-but-parseable (including via $FRENCH_CLI_CONTENT_DIR).
Agent Prompt
## Issue description
`list_summaries()` only skips JSON parse/IO errors; it does not run `validate_story()`. `_summary()` then defaults missing required fields (`id`, `title`, `level`) to `""`, which can violate the `story_list` schema.

## Issue Context
The repo has CI validation for committed stories, but runtime also supports `$FRENCH_CLI_CONTENT_DIR` overrides and can encounter corrupted/modified content; the CLI should still avoid emitting contract-invalid JSON.

## Fix Focus Areas
- In `list_summaries()`, run `validate_story(story)` and skip entries with validation errors.
- Avoid defaulting required fields to empty strings; either skip invalid stories or raise a clean `CliError` in strict modes.
- Consider validating `read_story()` outputs too (return `None` / error on schema-invalid story object).

### Fix Focus Areas (code)
- french/tutor/stories.py[71-117]
- french/contract_cite/schemas/story_list.json[14-30]

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

Comment thread french/tutor/state.py
Comment on lines +157 to +194
def _coerce(raw: dict[str, Any], learner: str) -> dict[str, Any]:
"""Typed, identity-safe projection of ``raw`` onto the current schema."""
embedded = raw.get("learner")
if embedded is not None and embedded != learner:
raise StateError(f"state file for '{learner}' carries a different learner ({embedded!r})")
base = new_state(learner)
for key, expected in _FIELD_TYPES.items():
if key in raw:
if not isinstance(raw[key], expected):
raise StateError(f"state field '{key}' has the wrong type")
base[key] = raw[key]
base["learner"] = learner
base["subject"] = subject.SUBJECT_ID
base["state_version"] = STATE_VERSION
return base


def load(learner: str) -> dict[str, Any]:
"""Load a learner's state, or a fresh default if none exists.

Raises :class:`StateError` on a corrupt file or a *newer* state version.
"""
path = state_path(learner)
if not path.is_file():
return new_state(learner)
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
raise StateError(f"cannot read state file {path}: {exc}") from exc
if not isinstance(raw, dict):
raise StateError(f"state file {path} is not a JSON object")
version = raw.get("state_version", STATE_VERSION)
if isinstance(version, int) and version > STATE_VERSION:
raise StateError(
f"state file {path} has state_version {version}, "
f"newer than this CLI supports ({STATE_VERSION})"
)
return _coerce(raw, learner)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

5. State file not refused 🐞 Bug ☼ Reliability

state.load() only rejects future state_version when it’s an int, and _coerce() overwrites
state_version/subject without validating them or nested value types. Malformed state can pass
load() and later crash commands (e.g., lesson repeat calling int() on repeats values)
instead of raising StateError and returning a clean environment error.
Agent Prompt
## Issue description
The state loader intends “tolerant-but-typed loads”, but it does not enforce:
- `state_version` type (non-int values bypass the future-version refusal)
- `subject` consistency
- nested invariants/types for `repeats` (and other nested structures)
This allows malformed state to reach code paths that assume numeric values and crash.

## Issue Context
`_tutor.load_state()` converts `StateError` into a clean `EXIT_ENV_ERROR`, but permissive loading means truly-bad state can still crash later (e.g., `repeat_difficulty()` calling `int()` on a non-numeric string).

## Fix Focus Areas
- Enforce `state_version` must be an `int`; if present and not int, raise `StateError`.
- Validate embedded `subject` (if present) matches `subject.SUBJECT_ID`.
- Validate `repeats` values are ints (or coercible) during load/coerce; otherwise raise `StateError`.
- Consider validating `mastery` values are within `MASTERY_LEVELS` on load to prevent later schema-invalid `progress` output.

### Fix Focus Areas (code)
- french/tutor/state.py[145-195]
- french/tutor/state.py[266-275]
- french/cli/_commands/lesson.py[70-82]

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

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.

Implement the learn subject-plugin contract + story ladder (unblocks agentculture.org/learn)

1 participant