chore: land PRs #36, #40, #42, #41 on main in one move - #43
Conversation
… waits for 0.5.x Decision 1 of the plan §7 queue (#34). The owner accepted the council D13 split and went further: feat/jev-struggle-eval is not cut until milestone 0.5.x is closed, because item 1 (the mentor writes to the learning tier) is the MVP blocker and item 2 is a post-MVP measurement. The reasoning is recorded beside the verdict rather than in chat: the repository history shows 17 archive tags whose tips never reached main and six parallel branches opened 7-8 Sept and archived on the 10th, which is what one shared vehicle risks. Consequence recorded: decision 4 (Jev spend cap) is deferred to the day S2 starts. Decision 5 (merge order) is marked resolved by the housekeeping PR #35 cherry-pick, as issue #34 already states. §2 cut-point sentence updated from the stale 4f8e3e0.
…provals, noticing episode by rule Decision 2: no pre-approval of W_auto on Claude (prompt per call), OpenCode wildcard untouched this branch; consequence taken: no new pre-approval on any harness, kiro keeps its existing log_topic entry as the one recorded asymmetry. Evidence recorded beside the verdict: kiro and opencode already had grants and still logged zero writer calls, so the defect is the instruction, not the prompt. Naming is distinguished from approving: socratic-mentor.md must name each W_auto tool because its tools: line names none today. The §1 paragraph, S1-0 bullets and the parity test-table row are rewritten to match. Decision 3: the noticing episode is scheduled by rule — the first real study session after S1-GREEN reaches main — with the date recorded in the S1-SIM receipt.
Reproduces the live line from 2026-09-23 startup: "Task exception was never retrieved ... StopAsyncIteration". The WS pump pulls each transport event as its own future; when a newer socket takes the consumer slot at the moment the stream drains, the pump raises _SupersededError before reading the drained future and its finally leaves a done future neither cancelled nor read, so asyncio logs the exception from the finalizer. The test drives the shared-portal TestClient, sets the takeover flag and drains the gated stub in one loop turn, pins the supersede poll long so the pump wakes only because the pull completed, then forces gc and asserts no "never retrieved" record on the asyncio logger.
The WS pump pulls each transport event as its own future. Two exits left a done future unread: a takeover (_SupersededError raised before the read) landing beside the drain, and a client close cancelling the pump after the pull had completed. The finally only cancelled a pending future, so a done one kept its StopAsyncIteration and asyncio logged "Task exception was never retrieved" from the finalizer -- the line seen at web startup on 2026-09-23 when a PWA tab reattached to a dead session. The finally now reads the outcome of a done, non-cancelled future and cancels only a pending one. A drained stream ending there is expected, not an error, so nothing is logged. RED 8bcf7d9 flips; WS, grace, slot-reconcile and live-session suites 67/67.
…s from source, writer sets frozen Item 1 (#38) stage S1-0. Records, with file:line on main c1a28de, how each of the six harnesses grants tool use to the mentor today and freezes W_auto and W_srs. Written to the owner decisions on PR #36: W_auto is named in every definition and prompt-per-call everywhere, no new pre-approval on any harness; kiro keeps its log_topic entry as the one recorded asymmetry; Claude names none of the studyloop MCP tools today so naming is what makes the writers reachable; opencode wildcard recorded as known and out of scope. Also pins the record_teachback MCP contract to the CLI validator it must mirror.
…names, not grants) The stage list still said kiro allowedTools += W_auto and claude settings.json += W_auto, contradicting the §7 verdict recorded two commits earlier. Now: names in every definition, both grant files unchanged.
…erywhere, one recording protocol Item 1 (#38) stage S1-RED, per plan §5 and the S1-0 receipt. 22 red, each for its stated reason on main c1a28de: - test_mcp_teachback.py (new, 13 red): the tool is absent from the MCP registry. Pins the contract: validates exactly as cli/_teachback.py (five ints 1-4, review_type enum), lands one row through the real migrated schema (CHECK present), no row on failure, session_id bound from session state and refused from the caller, a repeated call is two rows, a missing connection is a ToolError. - test_adapter_parity.py (2 red, 4 guards green): no definition names the four W_auto writers; Claude tools: line names none. Guards pin decision 2 - kiro allowedTools keeps exactly log_topic, Claude settings.json has no permissions block, opencode block as recorded. - test_docs_harness_tier_contract.py (7 red): agents/shared/recording-protocol.md absent, so no trigger table, no references, no manifest hash; persona.md:32 still routes "record progress" to tutor-checkpoint. test_writer_isolation.py is deferred to GREEN (it needs record_teachback to exist to run each W_auto writer; plan §5 says it is a guard, not RED, if it passes).
…ares Item 1 (#38) S1-GREEN, part 1. The mentor had no MCP writer for a teach-back score; the only writer was the CLI. record_teachback now validates through one shared implementation (history.teachback.coerce_scores / coerce_review_type, extracted from cli/_teachback.py with the CLI messages kept verbatim; the CLI delegates to it), lands one row through history.record_teachback, raises ToolError on any rejection or an unavailable database, and treats a repeated call as a second row. Finding N1, corrected from the RED as first written: the RED assumed the live study session id would land in teach_back_scores.session_id. The ownership layer forbids it - records.bind treats session_id as a native harness session that must exist in `sessions` and be visible in scope, and links study sessions only for parked_topics and study_notes - so the write failed and the tool reported "not recorded". The row is therefore owned by scope exactly as the CLI rows are, and the study session id travels in the reply. The two RED expectations are rewritten to that, with the finding stated in the test. 13/13 in test_mcp_teachback; CLI, history and the other MCP suites 100/100.
…o persona stops routing to tutor-checkpoint Item 1 (#38) S1-GREEN, part 2. agents/shared/recording-protocol.md is the one instruction that says WHEN the mentor writes: a fenced YAML trigger table (trigger, when, writer, required_ids, consent) naming exactly the four W_auto writers, one line of prose per trigger, and the rule that the SRS mutators never fire from it. Every mentor definition now references it with an identical sentence (codex and pi AGENTS.md, opencode and claude mentors; grok reads the codex file). Claude tools: line names the studyloop readers and the four writers - naming, not approval: no permissions block is added, per owner decision 2. Kiro persona.md no longer routes "record progress" to uv run tutor-checkpoint (a different tool, left to its own skill); it names the MCP writers and keeps the CLI as the human path. teach-back-protocol.md Recording section puts the MCP tool first with the consent rule; contributing.md tells a harness author what the parity tests will pin. agents/manifest.json regenerated: hashes change for exactly the six touched files. test_writer_isolation.py added as the plan §5 guard (passes first run, so not RED): a child process with decoy HOME/XDG runs all four writers and nothing lands outside the sandbox. S1-RED f439ab8 flips: 84/84 across the RED files, install contracts, isolation and CLI.
Four deliberate exact-count pins guard the MCP registry against an accidental or duplicate registration: test_mcp_plan_tools, test_mcp_stdio_smoke, the verify script and its test, plus the MCP README table and its count sentence (docs contract). Each names the new tool in its arithmetic comment rather than just bumping the number. Found by the full-suite set comparison against the committed environmental ids.
…ilt from personas/*.md, not the installed definitions Council review 9, coordinator finding while verifying grok Y2: studyloop study renders agents/shared/personas/<mode>.md through build_canonical_persona and hands THAT to every adapter (Kiro prompt, Claude flag file, session-dir AGENTS.md for Codex/pi/Grok). The six installed definitions GREEN named the writers in are what a learner gets when opening the harness directly. study.md told the mentor to show the learner a studyloop topic command and never to write - the defect in its live form - and named no writer. study.md gains a Recording section (four triggers, the protocol, the unsuccessful-write line) and lists the writers under Progress & Review; co-study.md gets the same four with its student-drives rule. test_adapter_parity pins the BUILT persona for study and co-study, not a file; the docs contract adds both live personas to DEFINITIONS. Also landed here, same files: grok Y2 (pin that Grok projects the Codex definition and that agents/grok/ does not exist), astra Y2 (kiro guard also refuses wildcard grants; opencode block parsed and compared whole, not substring-matched). S1-0 receipt §5 records both corrections as an addendum.
…failure honestly Council review 9. grok Y5: NOT NULL does not stop "", so a blank concept or topic became a row nothing can find again - refused at the boundary before any write, three parametrised tests. grok Y1 (partially accepted): the writer returns False for no-database, lock/timeout and refused-row alike and does not say which, so the ToolError now states the three rather than claiming "unavailable". Splitting them properly needs the writer to distinguish - the same collapse its CLI caller inherited - and is not done here. grok claimed ScopeError was also swallowed; verified false: record_teachback catches only IntegrityError and OperationalError, a ScopeError propagates.
…licit unsuccessful-write line Council review 9. qwen R2 (partially accepted, not as a blocker): the teach_back_agreed when-clause now names what is observable - five scores proposed in one sentence, the learner next reply accepts them - instead of "the learner agreed them". astra Y5: a section says what to do when the write does not happen (refusal, declined prompt, database unavailable): one line, continue, never retry silently, never claim a record that did not land. Manifest hash and secrets baseline regenerated for the one changed file.
Council review 9, astra Y4: "each writer landed" was asserted by a file existing. Now the sandbox sessions.db is opened and teach_back_scores and parked_topics are counted (one row each), and the topics file must carry the logged concept.
…ipts, arbitration (GATE ACCEPT at 616fd1e) Three seats (openai.gpt-6-astra ACCEPT-WITH-CORRECTIONS, grok-4.6 ACCEPT, qwen3-coder REJECT) on the item-1 tree 3c175f7. Every finding verified against the tree before action; nine landed across ad6d1f9, 019624c, 2a4d4d1, 616fd1e; four refuted with evidence (qwen R1, qwen Y, astra Y1, grok Y4 mechanism); qwen REJECT not sustained. The coordinator own finding - the live persona is built from personas/*.md and named no writer - is recorded as a gap none of the seats saw. Seat copies: git diff -w against the originals is empty; originals sha256 prefixes recorded in the arbitration. grok (e) ten-item list adopted as the S1-SIM checklist.
test_gate_fails_when_a_new_route_is_untested scans every test file twice: 26 s locally, and on 2026-09-23 it crossed the 60 s unit ceiling under --cov on the python 3.13 CI lane (3.12 passed; green on rerun; PR #41 run 35850516033). pyproject.toml already states the rule: a module whose honest cost sits near the ceiling carries an explicit pytest.mark.timeout rather than flaking against the global bound. 180 s, module-level, reason recorded in the file.
… every WS pump exit Merge commit, not a fast-forward, by necessity: #36, #40, #42 and #41 were each cut from main @ c1a28de in parallel, so once #36 landed no other tip descends from main's head. A rebase would change the SHAs the PR body cites (RED 8bcf7d9, GREEN b33ac04) and the repository ruleset refuses the force-push a rebased branch needs. The merge keeps every cited commit intact. Files touched are disjoint from the other three.
…0 through council review 9) Merge commit for the same reason as #40 and #42: cut from c1a28de in parallel, so no fast-forward exists after #36; the PR body, the S1-0 receipt and the review-9 arbitration cite fd70817, f439ab8, 7354ea3, 0a22aab, 3c175f7, 616fd1e and 5670f94 by name, and a rebase would orphan every one of those citations. Lands S1-0, S1-RED, S1-GREEN and the council record (GATE ACCEPT at 616fd1e). S1-SIM and the noticing episode stay open on #38: decision 3 already defined the noticing episode as the first real session AFTER S1-GREEN reaches main, and holding reviewed, CI-green work on a branch until owner-gated harness logins happen is the exact shape the branch census of 2026-09-23 found finished work lost in. The PR body's "Closes #38" is changed to "Part of #38" before this reaches main so the issue stays open for its two remaining stages.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Unresolved moderate findings remain in MCP error handling, contract coverage, OpenSpec documentation, and recording guidance.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 2
Open (4)
What changed in this PR
Integrates PRs #36, #40, #42, and #41, adding teach-back recording support alongside WebSocket cleanup, coverage safeguards, and documentation updates.
Changes:
- Adds
record_teachback, shared validation, recording protocols, and related tests. - Fixes drained WebSocket pull cleanup and adds coverage timeout handling.
- Updates inventories, manifests, receipts, agent guidance, council records, and changelog.
| File | Summary |
|---|---|
scripts/verify/plan_integration.py |
Updates tool inventory to 33. |
scripts/update-agent-manifest.py |
Tracks the recording protocol. |
packages/studyloop/tests/test_writer_isolation.py |
Tests writer filesystem isolation. |
packages/studyloop/tests/test_web_session_ws.py |
Tests drained pull cleanup. |
packages/studyloop/tests/test_verify_plan_integration_script.py |
Updates inventory assertions. |
packages/studyloop/tests/test_mcp_teachback.py |
Tests the MCP writer; includes nit (4 votes) on database fixture reuse and moderate finding (1 vote) on bypassing MCP boundary validation. |
packages/studyloop/tests/test_mcp_stdio_smoke.py |
Updates MCP count; moderate finding (1 vote) notes the active OpenSpec contract remains stale. |
packages/studyloop/tests/test_mcp_plan_tools.py |
Updates inventory assertions. |
packages/studyloop/tests/test_e2e_coverage_gate_selftest.py |
Adds a module timeout. |
packages/studyloop/tests/test_docs_harness_tier_contract.py |
Tests recording contracts; moderate finding (3 votes) requires when in required keys. |
packages/studyloop/tests/test_adapter_parity.py |
Validates writer naming and grants. |
packages/studyloop/src/studyloop/web/routes/session/_ws.py |
Retrieves completed pull-future outcomes. |
packages/studyloop/src/studyloop/mcp/tools.py |
Implements record_teachback; moderate finding (4 votes) concerns locked-database error translation. |
packages/studyloop/src/studyloop/history/teachback.py |
Adds shared validation. |
packages/studyloop/src/studyloop/cli/_teachback.py |
Reuses shared validation. |
docs/contributing.md |
Documents harness recording requirements. |
docs/architecture/learning-tier/receipts/s1-0-capability-lock.md |
Records capability decisions; nit (1 vote) identifies stale session-ID semantics. |
docs/architecture/learning-tier/plan-2026-09-19.md |
Records owner decisions and stages. |
docs/architecture/learning-tier/council/review9/seat-qwen3-coder.md |
Stores council review evidence. |
docs/architecture/learning-tier/council/review9/seat-openai.gpt-6-astra.md |
Stores council review evidence. |
docs/architecture/learning-tier/council/review9/seat-grok-4.6.md |
Stores council review evidence. |
docs/architecture/learning-tier/council/review9/manifest.json |
Records council metadata. |
docs/architecture/learning-tier/council/review-9-arbitration-2026-09-23.md |
Records arbitration outcomes. |
docs/architecture/learning-tier/council/brief-review9-2026-09-23.md |
Stores the review brief. |
CHANGELOG.md |
Documents learning-tier and WebSocket changes; nit (1 vote) requests “sandbox” instead of “sandbox database.” |
agents/shared/teach-back-protocol.md |
Routes teach-back recording through MCP. |
agents/shared/recording-protocol.md |
Defines recording triggers; nit (2 votes) requests an observable when condition. |
agents/shared/personas/study.md |
Adds live study recording guidance; moderate finding (1 vote) notes conflicting CLI and MCP instructions. |
agents/shared/personas/co-study.md |
Adds co-study recording guidance. |
agents/pi/AGENTS.md |
References the recording protocol. |
agents/opencode/study-mentor.md |
References the recording protocol. |
agents/mcp/README.md |
Documents the new MCP tool. |
agents/manifest.json |
Updates agent hashes. |
agents/kiro/study-mentor/persona.md |
Replaces outdated recording guidance. |
agents/codex/AGENTS.md |
References the recording protocol. |
agents/claude/socratic-mentor.md |
Names MCP readers and writers. |
.secrets.baseline |
Refreshes generated secret-scan hashes. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| study_session_id = read_session_state().get("study_session_id") or None | ||
| recorded = write_teachback( | ||
| concept=concept, | ||
| topic=topic, | ||
| scores=five, | ||
| review_type=kind, | ||
| angle=angle or None, | ||
| notes=notes or None, | ||
| ) | ||
| if not recorded: | ||
| # The writer returns False for three different failures -- no database, | ||
| # a lock/timeout, a refused row -- and does not say which (its CLI caller | ||
| # inherited the same collapse). Say that, rather than claim one cause. | ||
| raise ToolError( | ||
| "teach-back not recorded: the sessions database could not take the write " | ||
| "(unavailable, locked, or it refused the row) -- run `studyloop doctor`" | ||
| ) |
| REQUIRED_KEYS: ClassVar[frozenset[str]] = frozenset( | ||
| {"trigger", "writer", "required_ids", "consent"} | ||
| ) |
| required_ids: [concept, topic, scores, review_type] | ||
| consent: learner_agreed | ||
| - trigger: stuck_two_rounds | ||
| when: "two Socratic rounds on the same point without a breakthrough" |
| def scratch_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: | ||
| """A per-test sessions.db that the real connection resolver migrates.""" | ||
| db = tmp_path / "sessions.db" | ||
| monkeypatch.setenv("STUDYLOOP_DB", str(db)) | ||
| return db |
|
Full unit suite on the merged tree ( The first attempt's 319 failures were the harness fault now filed as #44 (an explicit CI on the merged tree: 16/16 green, |


Lands the four open PRs on one line so
mainmoves once: #36 → #40 → #42 → #41, then one CHANGELOG commit.Why an integration branch, and why merge commits
The push instruction said "each a pure fast-forward". That was wrong: all four were cut from
main@c1a28de1in parallel, so once #36 is onmainnone of the other three tips descends frommain's head — only the first can fast-forward. The alternatives were rebasing each remaining branch (changes every SHA the PR bodies, the S1-0 receipt and the review-9 arbitration cite by name, and the repository ruleset refuses the force-push a rebased branch needs) or merge commits. Merge commits keep every cited commit intact; the branch's--first-parenthistory stays linear.3b8a7537— docs(learning-tier): owner decisions 1–5 recorded (closes #34) #36 fast-forwarded (owner decisions 1–5,Closes #34).2883588f— merge fix(web): read a done pull future on every WS pump exit #40 (WS pump reads a done pull future on every exit).8d31f1ec— merge test(gate): coverage-gate self-test carries its own timeout #42 (coverage-gate self-test timeout).6b6a35bd— merge feat(learning-tier): item 1 — the mentor writes (S1-0 receipt) #41 (learning-tier item 1: S1-0, S1-RED, S1-GREEN, council review 9 GATE ACCEPT).a46d7367— CHANGELOG[Unreleased]entries for fix(web): read a done pull future on every WS pump exit #40 and feat(learning-tier): item 1 — the mentor writes (S1-0 receipt) #41 (docs(learning-tier): owner decisions 1–5 recorded (closes #34) #36 and test(gate): coverage-gate self-test carries its own timeout #42 are docs/test-only).The four branches touch disjoint files, so no merge had a conflict; each merge commit message records the reason above.
#41 no longer closes #38
Its body said
Closes #38; it now says Part of #38. S1-SIM (owner-gated harness logins) and the noticing episode stay open on the issue. Decision 3 defines the noticing episode as the first real session after S1-GREEN reachesmain, so holding the reviewed tree on a branch until S1-SIM would block the stage it gates — and the 2026-09-23 branch census found ~244 finished commits that never landed for exactly that reason.Verification on the merged tree (not the four in isolation)
ruff,
ruff format --check(1046 files), pyright 0/0/0,agents/manifest.jsonregenerates with no drift, mkdocs--strict, openspec 23/23 + archived 8/8; every test file the four branches touched run together: 229/229; JS 164/164. Full unit suite: pending — the first attempt used an explicit-m 'not e2e', which replacedaddopts' marker expression (pyproject.toml:98) and so ran the acceptance/integration/live/uat lanes; the acceptance lane's session-scoped Playwright fixture then left a running event loop that failed 178 later async tests withRunner.run() cannot be called from a running event loop. Reproduced identically on a cleanorigin/mainworktree and bisected totests/acceptance/test_kiro_web_acp_lane.py(deselected → clean; skipped-under--m→ poisoned). Not a defect in this tree. The bare-pytestrun is in progress and its result will be posted here before the fast-forward is requested.Merging closes #36, #40, #42 and #41 (GitHub marks them merged when
maincontains their heads).