From 5d9c4800349777254009ba3536c0450ee61d9da6 Mon Sep 17 00:00:00 2001 From: NetDevAutomate Date: Fri, 18 Sep 2026 19:09:10 +0100 Subject: [PATCH 1/8] test(plan): the two pins council review 6 deferred (F8), mutation-proved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 6 accepted two behaviours without a dedicated pin and recorded them as cheap follow-ons: `plan repair` exits 0 on a non-active unready plan (the behaviour was pinned only implicitly by the ready-plan sibling), and the duplicate-record short-circuit in `_revise` treats a mission edit as a change (exercised by item 3b's tests, never pinned on its own). test_plan_repair_nonactive_unready_is_noop_with_pointer: a plain draft is not a husk — nothing refuses its writes — so `plan repair` has nothing to unblock: exit 0, the "is draft, so nothing blocks it" sentence and the `studyloop plan architect` pointer, no launch (the launch chain is patched and records zero calls), no write (documents byte-equal before and after), status unchanged, and NOT the ready plan's "Nothing to repair" sentence. test_duplicate_learning_record_with_mission_revision_still_saves_once: a retried record beside a sharpened `why` is one save — not zero (the `why` would be dropped while reporting "already recorded") and not two. Discrimination proved by mutation, sources restored byte-identical: with `if s.status != "active":` mutated to fall through, the repair pin fails and the ready-plan sibling stays green; with `not mission_updates` dropped from `duplicate_record_only`, the record pin fails and the field-change sibling stays green. Both modules 78/78; ruff/pyright clean. --- .../studyloop/tests/test_cli_plan_seam.py | 33 +++++++++++++++++++ .../tests/test_plan_application_mutations.py | 24 ++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/packages/studyloop/tests/test_cli_plan_seam.py b/packages/studyloop/tests/test_cli_plan_seam.py index da3bd97f..6508eddc 100644 --- a/packages/studyloop/tests/test_cli_plan_seam.py +++ b/packages/studyloop/tests/test_cli_plan_seam.py @@ -680,6 +680,39 @@ def test_plan_repair_on_a_ready_plan_says_nothing_to_repair(runner, tmp_path, mo assert calls == [] # no launch +def test_plan_repair_nonactive_unready_is_noop_with_pointer( + runner, isolated_plans_dir, tmp_path, monkeypatch +) -> None: + """Council review 6, F8 (deferred pin): a plan that is not active is not a + husk — nothing refuses its writes — so ``plan repair`` on an unready draft + has nothing to unblock. It exits 0, says why, points at ``plan architect`` + to finish the plan, prints the readiness so the learner sees what is + missing, launches nothing and writes nothing. The other branch — active + and unready — is the launch; this pin keeps the two from being confused.""" + from contextlib import ExitStack + + runner.invoke(cli, ["plan", "new", "--title", "Vague Draft"]) # draft, unready + assert store.load_plan("vague-draft").status == "draft" + assert not ReadinessView.from_plan(store.load_plan("vague-draft")).ready + before = _documents(isolated_plans_dir) + + calls: list = [] + with ExitStack() as stack: + for p in _launch_patches(tmp_path, {}, calls): + stack.enter_context(p) + monkeypatch.setenv("TMUX", "/tmp/tmux") + result = runner.invoke(cli, ["plan", "repair", "vague-draft"]) + + assert result.exit_code == 0, result.output + clean = _ANSI.sub("", result.output) + assert "'vague-draft' is draft, so nothing blocks it" in clean + assert "studyloop plan architect" in clean + assert "Nothing to repair" not in clean # that sentence is the ready plan's, not this one's + assert calls == [] # no launch + assert _documents(isolated_plans_dir) == before # no write + assert store.load_plan("vague-draft").status == "draft" + + def test_plan_repair_unknown_id_is_the_seams_not_found(runner) -> None: result = runner.invoke(cli, ["plan", "repair", "nope"]) diff --git a/packages/studyloop/tests/test_plan_application_mutations.py b/packages/studyloop/tests/test_plan_application_mutations.py index 2e4db03b..3f6d35e5 100644 --- a/packages/studyloop/tests/test_plan_application_mutations.py +++ b/packages/studyloop/tests/test_plan_application_mutations.py @@ -282,6 +282,30 @@ def test_duplicate_record_beside_a_field_change_saves_once( assert len(detail.learning_records) == 1 +def test_duplicate_learning_record_with_mission_revision_still_saves_once( + app: PlanApplication, monkeypatch +) -> None: + """Council review 6, F8 (deferred pin): item 3b made the mission revisable + through ``RevisePlan``. The duplicate-record short-circuit must see a + mission edit as a change — ``not mission_updates`` is its own clause — or + a wind-down that re-sends yesterday's record beside a sharpened ``why`` + would drop the ``why`` and report "already recorded (no change)". One + save, the mission applied, the record still single.""" + _plan("demo") + spec = LearningRecordSpec(title="Once", body="only") + app.apply(RevisePlan(plan_id="demo", learning_record=spec)) + saves = _count_saves(monkeypatch) + + detail = app.apply( + RevisePlan(plan_id="demo", learning_record=spec, why="Own the nightly pipeline unaided") + ) + + assert len(saves) == 1, "a duplicate record beside a mission edit is one write, not zero" + assert detail.mission.why == "Own the nightly pipeline unaided" + assert len(detail.learning_records) == 1 + assert store.load_plan("demo").mission.why == "Own the nightly pipeline unaided" + + def test_empty_revision_is_still_a_touch(app: PlanApplication, monkeypatch) -> None: """The Phase-1 contract stands: an empty PATCH body has always been a save that bumps ``updated``. Only a duplicate-record-only revision is exempt.""" From 34eb537c01dad29e4debf2ba402732e0adfdcbfa Mon Sep 17 00:00:00 2001 From: NetDevAutomate Date: Fri, 18 Sep 2026 19:09:43 +0100 Subject: [PATCH 2/8] fix(verify): the early protected-files base must exist on origin; name the check by role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROTECTED_EARLY_BASE was 3a4f6b01, the programme's first RED commit. The history consolidation rewrote the early commits: that sha survives only as an unreachable object in this one clone (`git fetch origin 3a4f6b01` finds no such ref; `git for-each-ref --contains` lists nothing), so the `protected-files-3a4f6b01` check — `git diff --quiet 3a4f6b01 -- <3 files>` — would fail on every fresh checkout while passing here. Found while re-verifying the issue-closeout draft's shas against main before posting. The base moves to d7f568bf, the same commit on main ("RED — pin the two plan bugs issue #7 named as must-fix-first", found by exact subject); the three protected files are byte-identical between the two (`git diff --stat` empty) and the check exits 0 against the new base. The check is renamed `protected-files-early-base` to match `protected-files-late-base` (0be141bf did the same when the late base moved): a base is a moving pin by design, and the name should not have to move with it. The registry test drops the sha from the check name and pins the property the old base lacked: test_protected_file_bases_are_reachable_from_main asserts both bases are ancestors of main (proved discriminating: with the old sha restored it fails "3a4f6b01 is not an ancestor of main"). 27/27; ruff/pyright clean. --- .../test_verify_plan_integration_script.py | 31 +++++++++++++++---- scripts/verify/plan_integration.py | 10 ++++-- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/studyloop/tests/test_verify_plan_integration_script.py b/packages/studyloop/tests/test_verify_plan_integration_script.py index 5509059d..db14c4b6 100644 --- a/packages/studyloop/tests/test_verify_plan_integration_script.py +++ b/packages/studyloop/tests/test_verify_plan_integration_script.py @@ -63,7 +63,7 @@ def script(): "inventory-in-process", "plan-suites", "docs-contract", - "protected-files-3a4f6b01", + "protected-files-early-base", "protected-files-late-base", "rg-plan-application-cli", "rg-plan-application-web-routes", @@ -149,20 +149,39 @@ def test_zero_hit_rg_invariants_expect_exit_one(self, script) -> None: def test_protected_file_checks_name_the_ten_files_against_their_bases(self, script) -> None: by_name = {check.name: check for check in script.build_checks(REPO_ROOT)} - early = by_name["protected-files-3a4f6b01"].command + early = by_name["protected-files-early-base"].command late = by_name["protected-files-late-base"].command assert not callable(early) and not callable(late) - assert "3a4f6b01" in early and script.PROTECTED_LATE_BASE in late - # The late base is a moving pin by design: it advances only when a + assert script.PROTECTED_EARLY_BASE in early and script.PROTECTED_LATE_BASE in late + # Both bases are moving pins by design: a base advances only when a # protected file legitimately changes and the diff has been read - # (recorded next to the constant). It must never regress to the seam base. - assert script.PROTECTED_LATE_BASE != "3a4f6b01" + # (recorded next to the constant), and the check is named by role so + # the name never has to move with it. The late base must never regress + # to the early one. + assert script.PROTECTED_LATE_BASE != script.PROTECTED_EARLY_BASE early_files = [part for part in early if part.endswith(".py")] late_files = [part for part in late if part.endswith(".py")] assert len(early_files) == 3 and len(late_files) == 7 for rel in (*early_files, *late_files): assert (REPO_ROOT / rel).exists(), rel + def test_protected_file_bases_are_reachable_from_main(self, script) -> None: + """A base that exists only as an unreachable object in one clone makes the + check fail on every fresh checkout (2026-09-18: ``3a4f6b01`` survived the + history consolidation as a dangling object here and nowhere else). Both + bases must be ancestors of the local ``main`` -- the property a rewritten + SHA loses.""" + import subprocess + + for base in (script.PROTECTED_EARLY_BASE, script.PROTECTED_LATE_BASE): + result = subprocess.run( + ["git", "merge-base", "--is-ancestor", base, "main"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"{base} is not an ancestor of main: {result.stderr}" + def test_architect_grants_check_derives_the_ten_names_from_the_inventory(self, script) -> None: """Design §6 (follow-on item 1, D-A): the Kiro and Claude architect definitions carry exactly the nine plan tools + ``record_plan_learning`` diff --git a/scripts/verify/plan_integration.py b/scripts/verify/plan_integration.py index aff7940b..7de99fc3 100644 --- a/scripts/verify/plan_integration.py +++ b/scripts/verify/plan_integration.py @@ -67,7 +67,13 @@ CORE_TOOLS = frozenset({"list_courses", "get_study_backlog", "end_session"}) #: Protected test files: byte-identical to their base since the programme began. -PROTECTED_EARLY_BASE = "3a4f6b01" +# Moved 3a4f6b01 -> d7f568bf on 2026-09-18: the history consolidation rewrote +# the programme's early commits, and 3a4f6b01 survived only as an unreachable +# object in one clone (`git fetch origin 3a4f6b01` finds no such ref), so the +# check would fail on any fresh checkout. d7f568bf is the same commit ("RED -- +# pin the two plan bugs issue #7 named as must-fix-first") on main; the three +# protected files are byte-identical between the two (git diff --stat empty). +PROTECTED_EARLY_BASE = "d7f568bf" PROTECTED_EARLY = ( "packages/studyloop/tests/test_web_plans.py", "packages/studyloop/tests/test_cli_plan.py", @@ -354,7 +360,7 @@ def build_checks(repo_root: Path) -> list[Check]: ), # --- protected files: byte-identical to their bases ------------------- Check( - "protected-files-3a4f6b01", + "protected-files-early-base", ["git", "diff", "--quiet", PROTECTED_EARLY_BASE, "--", *PROTECTED_EARLY], ), Check( From 87d673907da4c7756f7e8c0b0da3672c4d335ca2 Mon Sep 17 00:00:00 2001 From: NetDevAutomate Date: Fri, 18 Sep 2026 19:10:52 +0100 Subject: [PATCH 3/8] =?UTF-8?q?docs(plan-integration):=20record=20the=20#8?= =?UTF-8?q?=E2=80=93#15=20closeout=20as=20posted;=20item=207=20state;=20st?= =?UTF-8?q?age-9=20header=20corrected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 7 step 3 ran on 2026-09-18: the closeout draft's per-issue tables were posted as status comments on #8–#15 after re-verification against main at a5b9f903 — every T: node id against the collected suite, every C: sha against main. Seven pre-consolidation shas were rewritten and replaced in the comments by their main equivalents (found by exact subject), one test had moved files, and four rows the follow-on programme had overtaken (#11's Markdown-only mission, #13's "partly", #14/#15/#7's brain-dump and cancellation gaps, #10's rubric state) were restated as they stand today. #8, #9, #11, #12, #13, #14 closed as completed; #10 and #15 stay open on rubric row 3b (item 5); #7 — auto-closed at PR #20's merge by the body's "Closes the two bugs" phrase — reopened with the parent mapping so it closes after its children, as the handover intends. The draft's status paragraph records all of this above the unchanged original text. tasks.md: the two F8 pins are landed (5d9c4800); T7.1 carries the per-step state of HANDOFF §3 item 7 — steps 1–3 done, 4–7 open, three of them owner-only (tag, support ticket, D-J token revocation). The stage-9 script's header said agents cannot delete remote refs by platform policy. Wrong, and now disproved on 2026-09-17 and 2026-09-18: the harness allows a remote branch deletion that names its branch literally; the "Default" ruleset is what refuses it, for everyone. The header says so. --- .../issue-closeout-draft-2026-09-16.md | 18 +++++++++++++++++- .../plan-integration-followons/tasks.md | 17 +++++++++++++++-- .../maintenance/stage9-owner-remote-cleanup.sh | 8 ++++++-- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/docs/architecture/plan-integration/receipts/issue-closeout-draft-2026-09-16.md b/docs/architecture/plan-integration/receipts/issue-closeout-draft-2026-09-16.md index 3a4c3bc3..2d42855f 100644 --- a/docs/architecture/plan-integration/receipts/issue-closeout-draft-2026-09-16.md +++ b/docs/architecture/plan-integration/receipts/issue-closeout-draft-2026-09-16.md @@ -1,6 +1,22 @@ # Plan integration (#7–#15) — issue close-out DRAFT · 2026-09-16 -**Status: DRAFT, not posted.** Written unattended by the Phase-6 agent for the owner to post in the +**Status: POSTED 2026-09-18** (was: DRAFT, not posted). The per-issue tables below were posted as +status comments on #8–#15 after re-verification against `main` at `a5b9f903`: every `T:` node id +checked against the collected suite, every `C:` sha against `main` — seven pre-consolidation shas +(`1d071758`, `3a4f6b01`, `705ba58b`, `c16ffa35`, `daf46c81`, `dc7de0be`, `e16340ca`) were rewritten +at the history consolidation and were replaced in the comments by their `main` equivalents, found by +exact subject (`38f6f41d`, `d7f568bf`, `e50106af`, `e74c2a63`, `bfe0695c`, `3d9476d5`, `1aed49f2`); +`test_malformed_plan_browse_matches_store_list` had moved to `test_plan_application_mutations.py`. +Rows the follow-on programme had overtaken were restated in the comments, not here: #11's mission row +(revisable since item 3b), #13's "partly" (closed by item 1), #14/#15/#7's brain-dump and cancellation +gaps (closed by item 2 and the 2026-09-18 abandonment decision), #10's rubric state (rows scored except +3b). **Closed as completed:** #8, #9, #11, #12, #13, #14. **Open:** #10 (row 3b, item 5), #15 (closes +with #10), and #7 — which GitHub had auto-closed at PR #20's merge on the body's "Closes the two bugs" +phrase and which was reopened with the parent mapping so it closes after its children, as §3 item 7 +of the handover intends. PR #20's body was not replaced: the PR is merged. The original draft text +follows unchanged as the record of what was known on 2026-09-16. + +**Original status (2026-09-16): DRAFT, not posted.** Written unattended by the Phase-6 agent for the owner to post in the morning, and refreshed after council review 5 (`GATE: ACCEPT`, `council/review-5-arbitration-2026-09-16.md`), the verification receipt and the archive. Nothing here has been sent to GitHub: no issue closed, no comment left, PR #20 untouched. Every claim below names the diff --git a/openspec/changes/plan-integration-followons/tasks.md b/openspec/changes/plan-integration-followons/tasks.md index c0cf4814..e51d06c7 100644 --- a/openspec/changes/plan-integration-followons/tasks.md +++ b/openspec/changes/plan-integration-followons/tasks.md @@ -174,7 +174,9 @@ writer, through the existing gate, closes that. Kept out of item 3 so item 3's f mutation-proved. Refuted with a named test: qwen's empty-agent claim. Carried to the owner (arbitration §"Still open"): the abandonment contract (F3b), `plan close` on checked non-active plans, the `session-db` prompt probe. Two cheap pins deferred: `test_plan_repair_nonactive_unready_is_noop_with_pointer`, - `test_duplicate_learning_record_with_mission_revision_still_saves_once`.) Reproduce every 🔴/🟡 by probe or + `test_duplicate_learning_record_with_mission_revision_still_saves_once` — **landed 2026-09-18**, each proved + discriminating by mutating the branch it guards: the repair pin fails when the non-active branch falls through + to the launch, the record pin fails when `not mission_updates` is dropped from the short-circuit.) Reproduce every 🔴/🟡 by probe or RED test before accepting; arbitration `council/review-6-arbitration-2026-09-16.md` ends `GATE: ACCEPT|FAIL`; corrections one commit per finding. - [x] **T6.3** (`receipts/verify-d0251fd1.json`, tree clean, 31 checks — 29 + `architect-grants` (in-process, @@ -208,4 +210,15 @@ writer, through the existing gate, closes that. Kept out of item 3 so item 3's f ## Item 7 — push step (owner present; HANDOFF §3 item 7) -- [ ] **T7.1** Not run unattended. Ruleset D-I; tokens D-J. +- [ ] **T7.1** Not run unattended. Ruleset D-I; tokens D-J. **State 2026-09-18** (HANDOFF §3 item 7's seven + steps): (1) pushes — done, owner pushed `main` three times (#20 `46262d23`, #22 `4bba58b6`, #23 `a5b9f903`), + each a fast-forward after CI green on the PR; (2) ruleset D-I — done three times (2026-09-17 + `feat/knowledge-proof` + `fix/plan-integration-bugs`; 2026-09-18 `feat/plan-close` + + `fix/seam-test-db-bootstrap`), each a ≤ 6 s window with the GH013 refuse-proof first and the ruleset re-read + byte-equal afterwards; single `main` on both sides. (3) closeout comments — done: posted on #8–#15 from the + re-verified draft (`receipts/issue-closeout-draft-2026-09-16.md`, status paragraph records the shas rewritten + and the rows overtaken); #8, #9, #11, #12, #13, #14 closed as completed; #10 and #15 open (row 3b); #7 + reopened (auto-closed by PR #20's body) with the parent mapping. PR #20's body left as merged. **Open:** + (4) the two item-6 issues (after T6.4) and #21's closing comment or decision; (5) the local tag + `archive/feat-clean-start-2026-09-15` — owner: push or discard; (6) the GitHub Support ticket text — owner; + (7) revoke both tokens and delete `~/tmp/.env` — owner (D-J). diff --git a/scripts/maintenance/stage9-owner-remote-cleanup.sh b/scripts/maintenance/stage9-owner-remote-cleanup.sh index 35a0c8c3..5224a617 100755 --- a/scripts/maintenance/stage9-owner-remote-cleanup.sh +++ b/scripts/maintenance/stage9-owner-remote-cleanup.sh @@ -1,7 +1,11 @@ #!/usr/bin/env bash # Stage 9 owner script — the remote half of the 2026-09-10 branch cleanup. -# Agents cannot push or delete remote refs on this repo (platform policy), so the -# owner runs this. Every step is idempotent, so re-running after a partial run is +# The owner runs this because it pushes main and asks before switching a +# repository ruleset off. (An earlier header said agents cannot delete remote +# refs by platform policy; that was wrong — the harness allows +# `git push origin --delete `, proven on 2026-09-17 and +# 2026-09-18. What refuses the deletion is the "Default" ruleset below, for +# everyone.) Every step is idempotent, so re-running after a partial run is # safe: already-pushed refs report "Everything up-to-date". # # First run (2026-09-10 23:47): steps 1-2 succeeded; step 3 was rejected for all From 7208eb67cfe3ae7d729b67af143ee112b609de88 Mon Sep 17 00:00:00 2001 From: NetDevAutomate Date: Fri, 18 Sep 2026 19:17:26 +0100 Subject: [PATCH 4/8] =?UTF-8?q?docs(plan-integration):=20item=206=20?= =?UTF-8?q?=E2=80=94=20the=20two=20written=20proposals=20(D-D,=20D-E);=20t?= =?UTF-8?q?ick=20T6.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two proposals under docs/architecture/plan-integration/proposals/, no code (HANDOFF §3 item 6). Each quotes the owner's decision verbatim, states what the engine and store do today from the tree at a5b9f903 rather than from the design text, then the proposal, its constraints, what is out of scope, and the acceptance the issue will carry. 2026-09-16-context-derived-plan-bias.md (D-D): replace the single PLAN_RELATED_BIAS = 12 with a derived, bounded bias from three deterministic inputs — today's plan relevance (5a), prerequisite order from list_dependencies' `relation_type == "prerequisite"` edges, which weak_links_for_topic already reads (5b), and per-item energy demand, taking item 5's definition rather than inventing a second (5c). No model call in ranking (unauditable; defeats D-16); an absent edge is no signal; the golden and the three rule-5 pins stay green; the concept-store read is budgeted and measured on a real sessions.db before it ships, as item 4's preview read was. 2026-09-16-overdue-nudge-and-retire.md (D-E): a due row is a study_progress row whose last_seen has reached a REVIEW_INTERVALS step; there is no next_review column and no "not due" state, so a row stays a candidate until studied again, and at 100 + min(days_ago, 30) an unrelated overdue item silently overtakes the +12 plan bias after ~12 days. Proposed: an age-aware nudge line on an unrelated due candidate past a threshold derived from the constants (so it moves with D-D), and learner-issued retire/snooze states on the row through the seam (CLI verb, Today control, one MCP tool — the mirror of record_topic_progress(confidence="resolved"), which today resolves only parked topics). History kept; never inferred from age; excluded from every consumer of spaced_repetition_due (now, recap, `studyloop review`, the plan evaluation); flashcards' SM-2 store is a separate ticket. mkdocs --strict exit 0; the six docs guard modules 211/211 with the new files in place. tasks.md T6.4 ticked; the two issues are item 7 step 4. .gitignore: docs/architecture/plan-integration/* is an allowlist (council/, receipts/, the archify spec, top-level .md); proposals/ was never on it, so the first `git add` was refused. Added in the same shape, Markdown only. --- .gitignore | 2 + .../2026-09-16-context-derived-plan-bias.md | 106 ++++++++++++++++ .../2026-09-16-overdue-nudge-and-retire.md | 114 ++++++++++++++++++ .../plan-integration-followons/tasks.md | 9 +- 4 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 docs/architecture/plan-integration/proposals/2026-09-16-context-derived-plan-bias.md create mode 100644 docs/architecture/plan-integration/proposals/2026-09-16-overdue-nudge-and-retire.md diff --git a/.gitignore b/.gitignore index 29b6af6f..3dc9c6aa 100644 --- a/.gitignore +++ b/.gitignore @@ -530,6 +530,8 @@ docs/architecture/session-memory/* docs/architecture/plan-integration/* !docs/architecture/plan-integration/council/ !docs/architecture/plan-integration/receipts/ +# Un-ignored 2026-09-18 — item 6's written proposals (D-D, D-E), Markdown only. +!docs/architecture/plan-integration/proposals/ !docs/architecture/plan-integration/*.architecture.json !docs/architecture/plan-integration/*.md diff --git a/docs/architecture/plan-integration/proposals/2026-09-16-context-derived-plan-bias.md b/docs/architecture/plan-integration/proposals/2026-09-16-context-derived-plan-bias.md new file mode 100644 index 00000000..251922c4 --- /dev/null +++ b/docs/architecture/plan-integration/proposals/2026-09-16-context-derived-plan-bias.md @@ -0,0 +1,106 @@ +# Proposal: a context-derived plan bias (D-D) + +**Status:** written proposal, no code (plan-integration follow-on item 6, T6.4). Becomes a +`ready-for-agent` issue at the push step (HANDOFF §3 item 7, step 4). Written 2026-09-18 against +`main` at `a5b9f903`; every code fact below was read from that tree. + +## The owner's decision, verbatim + +> **D-D** | **F2 → open a ticket, don't park:** a context-derived plan bias (prerequisite edges from the +> concept store via `get_concept_context`, milestone order; per-item energy demand from struggle state) +> — deterministic and rubric-testable. Not an LLM tie-break (unauditable; defeats D-16). Scenario 1's +> "the logical step before" was the first evidence. +> +> — `HANDOFF-2026-09-16.md` §2, owner, 2026-09-16 + +The evidence it names: rubric row 1 (`receipts/now-rubric-2026-09-16.md`), owner's verdict **yes** with +the line *"window function is the logical step before decorating it"* — a prerequisite-order argument the +engine did not make. The engine ranked the plan-matching due item first because it carried the plan +bias; the owner ranked it first because of what depends on it. Same answer, different reason, and the +reason is the one that generalises. + +## What the engine does today (`studyloop/learning/decision.py`) + +- **Rule 5, one constant.** `PLAN_RELATED_BIAS = 12`, added to a candidate's score when it carries a + `plan_ref` or its concept/topic keys intersect a matchable plan's keys. The constant is sized to decide + a near-tie *inside one urgency class* and to lose to a clearly more-urgent unrelated candidate + (a struggling repair at +35, an overdue review whose base is `100 + min(days_ago, 30)`): a bias, + never a filter. Review 3 F2 asked whether `now` should grow explicit urgency classes; the council kept + the bias and pinned the constant to today's bands. +- **Rule 3, one floor per plan.** `ENERGY_CAPABILITY = {"low": 3, "medium": 6, "high": 10}` is compared + with the plan's `energy_floor` (1–10); below the floor *new* milestone work is deferred while + plan-related due recall and struggle repair stay eligible, "because repair is cheaper than encoding". + Rubric row 3 (owner: **no**) is the counter-example — a live-struggle repair on a low-energy day — + and is item 5's subject, not this proposal's. +- **Rule 6, milestone order by position.** A synthesised next-milestone candidate is the plan's first + open milestone, base `MILESTONE_BASE_SCORE = 48` plus a target-urgency bonus. Milestone order is the + document's order; nothing reads what a milestone's concepts require. +- **What the concept store already knows.** `learning/mastery.py::list_dependencies(topic)` returns + edges with `source_concept`, `target_concept`, `relation_type`; `weak_links_for_topic` already + consumes `relation_type == "prerequisite"` to name struggling concepts that block downstream ones. + `get_concept_context` (MCP) returns `mastery_graph_json`'s `edges` for a topic, with the same fields, + capped at 32 KiB and explicit that omitted edges cannot support a claim that no alternative exists. + +So the ingredients exist and are already read for another purpose; what is missing is a rule that turns +them into a ranking signal for `now`. + +## The proposal + +Replace the single constant with a **derived bias** computed from three deterministic inputs, each a +rule with its own rubric row. The total stays bounded so the "bias, never a filter" invariant holds: +a clearly more-urgent unrelated candidate must still win. + +| Rule | Input | Signal | Bound | +|---|---|---|---| +| 5a — plan relevance (today's rule 5) | plan refs / key intersection | the existing `+12` | as today | +| 5b — prerequisite order | `list_dependencies(topic)` edges with `relation_type == "prerequisite"` | a candidate whose concept is a **prerequisite of an open milestone's concept** in a matchable plan gains a small bonus; a candidate whose concept **depends on** a concept the learner is `struggling` with loses the same amount ("the logical step before" comes first) | ± a value below the urgency-class gap, pinned like `PLAN_RELATED_BIAS` | +| 5c — per-item energy demand | struggle state (`confidence`, `last_teachback_score`) and action type | a **demand** per candidate (repair of a live struggle is high demand; a recall of a `learning` concept is low) compared with `ENERGY_CAPABILITY[energy]`, so rule 3's floor stops being the plan's only energy fact | shared with item 5 (D-F); this proposal takes item 5's definition when it lands rather than inventing a second | + +Design constraints, from the decision: + +1. **Deterministic.** Every input is a stored fact (an edge, a confidence, a score); the bias for a + fixed world is a pure function. No model call anywhere in ranking — an LLM tie-break is + unauditable and defeats the D-16 rubric, which asks a human "would you do the primary?" and needs + the answer to follow from stated rules. +2. **Rubric-testable.** Each rule gets a D-16 rubric row with a planted world and an expected primary, + scored by the owner before it ships: row 1 re-run under 5b should still be **yes** and now for the + engine's reason; a new row plants a struggling prerequisite and expects the dependent milestone + *not* to be primary. +3. **Still a bias.** The golden `now_plan_no_active.json` stays byte-identical (no plan → no bias); the + existing rule-5 pins (`test_matching_due_concept_outranks_unrelated_same_urgency`, + `test_unrelated_more_urgent_due_outranks_new_milestone`, `test_weak_due_still_beats_overdue_synthesised_milestone`) + stay green with the derived value in place of the constant. +4. **Partial knowledge is not knowledge.** `get_concept_context` is explicit that omitted edges cannot + support "no alternative exists"; 5b must treat an absent edge as *no signal*, never as "not a + prerequisite". +5. **Read once, through the seam.** Edges are read in `_PlanContext.build` beside the plan read, so the + rule-1 pin (no per-consumer plan reads, no checkpoint-history reads) holds; the concept store is a + second read, budgeted and measured on the live database before it ships (the item-4 preview read was + measured at ~320 ms on an 877 MB `sessions.db` and recorded; this must be too). + +## Out of scope + +- Item 5 (D-F) owns the energy-demand *definition* and the body-doubling floor; this proposal consumes + it and must not define a second one. +- No change to what a plan document stores. Prerequisite knowledge lives in the concept store; the + plan keeps naming concepts per milestone. +- No filter, no reordering of urgency classes (review 3 F2 stands). + +## Acceptance (for the issue) + +- `PLAN_RELATED_BIAS` is replaced by a derived value with a pinned upper bound; the three rule-5 pins + above and the golden byte-identity stay green unchanged. +- RED tests, one per rule: a prerequisite-of-open-milestone candidate outranks a same-class sibling; + a candidate dependent on a struggling concept loses to that concept's repair; an absent edge changes + no score. +- A measured read cost on a real `sessions.db`, recorded beside the constant. +- Rubric rows added and scored by the owner (D-16), row 1 re-run. +- Docs: `docs/study-plans.md` "Plan-aware now" describes the derived bias in the eligibility terms the + contract test pins. + +## References + +`HANDOFF-2026-09-16.md` §2 D-D; `receipts/now-rubric-2026-09-16.md` rows 1–3; `council/review-3-arbitration-2026-09-16.md` +F2; `studyloop/learning/decision.py` (`PLAN_RELATED_BIAS`, `ENERGY_CAPABILITY`, `MILESTONE_BASE_SCORE`, +rule 5 application site); `studyloop/learning/mastery.py` (`list_dependencies`, `weak_links_for_topic`, +`agent_concept_context`). diff --git a/docs/architecture/plan-integration/proposals/2026-09-16-overdue-nudge-and-retire.md b/docs/architecture/plan-integration/proposals/2026-09-16-overdue-nudge-and-retire.md new file mode 100644 index 00000000..08f334ec --- /dev/null +++ b/docs/architecture/plan-integration/proposals/2026-09-16-overdue-nudge-and-retire.md @@ -0,0 +1,114 @@ +# Proposal: an age-aware nudge and a retire/snooze door for an overdue item (D-E) + +**Status:** written proposal, no code (plan-integration follow-on item 6, T6.4). Becomes a +`ready-for-agent` issue at the push step (HANDOFF §3 item 7, step 4). Written 2026-09-18 against +`main` at `a5b9f903`; every code fact below was read from that tree. + +## The owner's decision, verbatim + +> **D-E** | Scenario 2 note: an overdue item **unrelated** to the plan must not sit as an alternate +> indefinitely. Fact: due score already grows `+1/day` (cap +30), so it overtakes the +12 bias in +> ~2 weeks; missing are an **age-aware nudge line** and a **retire/snooze** action for a due card (only +> backlog topics can be `resolved` today). +> +> — `HANDOFF-2026-09-16.md` §2, owner, 2026-09-16 + +The evidence it names: rubric row 2 (`receipts/now-rubric-2026-09-16.md`), owner's verdict **yes** — +clear the overdue review first — with the note: *"an overdue item unrelated to the plan must not sit as +an alternate indefinitely — propose it explicitly (age-aware nudge) or let the learner retire it."* + +## What the engine and the store do today + +- **A due item is a `study_progress` row whose `last_seen` is old enough.** `history/progress.py` + computes `days_ago` from `last_seen` and marks the row due when it reaches an interval in + `REVIEW_INTERVALS` — 1, 3, 7, 14, 30 days, each with a review type ("5-min recall quiz" … + "Teach-back session"); a `struggling` row is always due as "Guided repair + tiny practice". There is + **no `next_review` column** and no state that says "not due": a `mastered` row keeps coming back on + the same intervals, and the only thing that moves `last_seen` is studying it again + (`record_progress`). A row therefore stays due — and stays a `now` candidate — until it is studied + or deleted. +- **The score arithmetic the decision cites is right.** `learning/decision.py` scores a due candidate + `100 + min(days_ago, 30)` (+35 if `struggling`, +15 if `learning`). A plan-related candidate adds + `PLAN_RELATED_BIAS = 12` (rule 5). So an unrelated due item at *d* days beats a plan-related one at + *d′* days once `d > d′ + 12`: roughly two weeks of sitting as an alternate, then it wins the primary + on age alone — silently. The learner sees it move up; nothing tells them why, and nothing offers a + way to say "I have moved on from this". +- **`resolved` exists only for parked topics.** `record_topic_progress(confidence="resolved")` (MCP) + calls `parking.resolve_parked_topic(topic_id)` — a *backlog* row, not a `study_progress` row. The + Today card and CLI `now` have no control on a due item at all. + +## The proposal + +Two additions, both small, both on the existing candidate — no new ranking rule. + +### 1. An age-aware nudge line + +When an eligible due candidate is **unrelated to every matchable plan** and has been due long enough +to have overtaken the plan bias — i.e. `days_ago ≥ threshold`, with the threshold **derived from the +constants**, not a second number: the smallest `days_ago` at which `min(days_ago, 30) ≥ PLAN_RELATED_BIAS` +plus the plan-related candidate's own age — the candidate carries a `nudge` line that says so in plain +words: *"Overdue 16 days and unrelated to your plan — do it, or retire it: `studyloop review retire + `."* Rendered where the completion review's evidence lines are rendered today (CLI +`now` beneath the action, Today card beside it, MCP payload as an additive key). The line is a fact +about age, not a proposal to rank differently. + +### 2. A retire/snooze door for a due item + +A `study_progress` row gains one of two learner-issued states, through the seam every surface uses: + +| Door | Meaning | Mechanism | Undo | +|---|---|---|---| +| **retire** | "I am done with this concept for now" | a `retired_at` timestamp on the row (or a `confidence` value the due predicate excludes — decided at RED with the migration); the row is no longer due, keeps its history, and disappears from every consumer of `spaced_repetition_due` — `now` (CLI, Today, MCP `get_next_action`), recap, `studyloop review`, and the plan evaluation's due count | studying it again (`record_progress`) clears the state | +| **snooze** | "not this week" | a `snoozed_until` date; the row is not due before it and returns to the normal intervals after | expiry, or an explicit un-snooze | + +Exposed the same way the plan lifecycle is: a CLI verb (`studyloop review retire|snooze`), a Today-card +control on the due item, and one MCP tool beside `record_study_progress` — the mirror of +`record_topic_progress(confidence="resolved")` for cards that the decision asks for. The store writes +one row; retire is never inferred from age (the decision says *let the learner* retire it). + +Design constraints: + +1. **Learner-issued only.** Nothing retires or snoozes on the learner's behalf — the RSD-safe framing + the project keeps: the nudge proposes, the learner decides (same shape as item 4's consensual + close). +2. **History kept.** A retired row is excluded from the due set, not deleted; `get_study_history` and + the plan evaluation's `unverified_milestones` still see it. +3. **Plan-aware, not plan-bound.** The nudge fires only for items unrelated to every matchable plan; + a plan-related overdue item is the plan's business (item 4's closing review already counts it). +4. **Golden unchanged.** The no-plan golden holds a `source=starter` primary with no due items, so it + is byte-identical by construction; the nudge is an additive key on a due candidate. +5. **The threshold is derived, not a new constant** — so when D-D replaces `PLAN_RELATED_BIAS` with a + derived bias, the nudge moves with it. + +## Out of scope + +- Any change to `REVIEW_INTERVALS` or to how `days_ago` is computed. +- Reranking. The overtaking-on-age behaviour is correct (row 2: clear the overdue review first); what + is missing is the *explanation* and the *exit*, not a different order. +- Flashcard/quiz `card_reviews` — a different store with its own scheduler (a simplified SM-2 in + `review_db.py`, read by `get_due_cards` through `review_service.due_cards`); a retire door for those + is a separate ticket if wanted. + +## Acceptance (for the issue) + +- RED: a due unrelated item past the derived threshold carries the nudge line on CLI, Today and MCP; + a plan-related one does not; one below the threshold does not. +- RED: `retire` removes the row from the due set and from `now` candidates, keeps its history row, + and `record_progress` on the same concept clears it; `snooze` excludes it until the date and not + after. +- The migration is in `agent_session_tools.migrations` and the clean-rebuild closure classifies the + new column/state (the 2026-09-12 rebuild caught two tables the migrations did not create; do not + repeat that). +- Golden `now_plan_no_active.json` byte-identical; `test_docs_plan_integration_contract.py` green + with the doc sentence added to `docs/study-plans.md` "Plan-aware now" and `docs/cli-reference.md`. +- A D-16 rubric row: row 2's world advanced 16 days, the owner asked whether the nudge line is one + they would act on. + +## References + +`HANDOFF-2026-09-16.md` §2 D-E; `receipts/now-rubric-2026-09-16.md` row 2; `studyloop/history/progress.py` +(`REVIEW_INTERVALS`, `_review_type_for`, `_progress_review_due`, `spaced_repetition_due`, +`record_progress`); `studyloop/learning/decision.py` (due scoring `100 + min(days_ago, 30)`, +`PLAN_RELATED_BIAS`); `studyloop/mcp/tools.py` (`record_topic_progress`, `resolve_parked_topic`); +`agent_session_tools/migrations.py` (`study_progress` columns: `id, topic, concept, confidence, +first_seen, last_seen, session_count, notes, created_at, updated_at`). diff --git a/openspec/changes/plan-integration-followons/tasks.md b/openspec/changes/plan-integration-followons/tasks.md index e51d06c7..92dfbea4 100644 --- a/openspec/changes/plan-integration-followons/tasks.md +++ b/openspec/changes/plan-integration-followons/tasks.md @@ -205,8 +205,13 @@ writer, through the existing gate, closes that. Kept out of item 3 so item 3's f ## Item 6 — proposals (no code) -- [ ] **T6.4** `docs/architecture/plan-integration/proposals/2026-09-16-context-derived-plan-bias.md` (D-D) and - `…-overdue-nudge-and-retire.md` (D-E); both become issues at the push step. +- [x] **T6.4** `docs/architecture/plan-integration/proposals/2026-09-16-context-derived-plan-bias.md` (D-D) and + `…-overdue-nudge-and-retire.md` (D-E); both become issues at the push step. (Written 2026-09-18 against + `main` `a5b9f903`: each quotes the owner's decision verbatim, states what the engine and store do today from + the code (`PLAN_RELATED_BIAS = 12`, `ENERGY_CAPABILITY`, due score `100 + min(days_ago, 30)`, + `REVIEW_INTERVALS`, `list_dependencies` prerequisite edges, `resolve_parked_topic` as the only `resolved` + path), then the proposal, constraints, out-of-scope and issue acceptance. D-D consumes item 5's energy-demand + definition rather than defining a second. The two issues are item 7 step 4.) ## Item 7 — push step (owner present; HANDOFF §3 item 7) From 71e643ad0965aa1780146acf4157a92ff80eeb99 Mon Sep 17 00:00:00 2001 From: NetDevAutomate Date: Fri, 18 Sep 2026 19:24:31 +0100 Subject: [PATCH 5/8] =?UTF-8?q?docs(plan-integration):=20T5.1=20=E2=80=94?= =?UTF-8?q?=20design=20=C2=A75=20reviewed=20against=20the=20code;=20three?= =?UTF-8?q?=20amendments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 5 (D-F) was designed on 2026-09-16 before its RED. Read against learning/decision.py at 7208eb67, three of its sentences do not fit the tree; each is amended under §5 with the source it rests on, so T5.2's RED is written against a design the code can carry. 1. "recovered / gentle review -> low" names a row _struggle_candidates never emits: it selects only confidence in ("struggling", "learning") or last_teachback_score < 14. The low-demand class is the `learning` row; fresh `struggling` (last_seen <= 14 days) is high, old `struggling` or a weak-teach-back-only row is medium. Demand is derived once in the collector and carried in candidate metadata. 2. "listed in energy_deferred" cannot hold a repair: DeferredMilestone has a mandatory milestone_index and all three renderers (cli/_now.py, learning/recap.py, today-panel.js deferredNotes) print `milestone {index + 1} "{title}"`. A deferred repair gets its own frozen DeferredRepair in a new additive key, energy_deferred_repairs, and each renderer gains one line for it. 3. "opens the existing body-double session route (web/routes/body_double.py)" names the read-only focus reader. The session door is `studyloop study "" --mode co-study` on the CLI and the Body Double view's session start on the Web; _evidence_command has no conversation branch and would fall through to `studyloop progress … -c learning` (a write), so the body-double candidate sets its evidence_command explicitly. tasks.md T5.1 ticked with the receipt. Full suite on this branch: 30 failed / 5120 passed / 14 errors, the 44 failed+errored ids byte-identical to the item-4 control's committed environmental set (run - control = empty). --- .../plan-integration-followons/design.md | 30 +++++++++++++++++++ .../plan-integration-followons/tasks.md | 9 ++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/openspec/changes/plan-integration-followons/design.md b/openspec/changes/plan-integration-followons/design.md index 0d3a4c92..c847d696 100644 --- a/openspec/changes/plan-integration-followons/design.md +++ b/openspec/changes/plan-integration-followons/design.md @@ -238,6 +238,36 @@ among what MCP revises and the row names every schema property. - **Rubric row 3b** (owner scores): scenario 3's fixture at low energy now yields the deferred repair named in `energy_deferred` and a body-double primary (or the due recall if one exists). +**T5.1 review against the code (2026-09-18, tree `7208eb67`) — three amendments, each from reading +`learning/decision.py`, not the text above:** + +1. **Demand classes are the struggle collector's classes.** `_struggle_candidates` emits a row only when + `confidence in ("struggling", "learning")` or `last_teachback_score < 14`; "recovered / gentle review" is not a + row it produces. So: `struggling` with `last_seen` ≤ 14 days → `high`; `struggling` older than 14 days, or any + row whose only signal is a weak teach-back → `medium`; `learning` → `low`. Demand is derived once, in the + collector, and carried in the candidate's `metadata` beside `confidence` so the scorer and the renderers read + one value. Required capability `high → 6`, `medium → 4`, `low → 0` stands (the `low` class is what "repair is + cheaper than encoding" was always about). +2. **`energy_deferred` is milestone-shaped and cannot carry a repair as it is.** `DeferredMilestone` has a + mandatory `milestone_index`, and all three renderers (`cli/_now.py`, `learning/recap.py`, + `today-panel.js::deferredNotes`) print `milestone {index + 1} "{title}" needs energy {floor}/10`. A deferred + repair gets its own frozen `DeferredRepair` (`plan_id`/`plan_title` when plan-related, else `None`, `concept`, + `topic`, `confidence`, `energy_demand`, `required_capability`, `energy_capability`, `reason` naming the + struggle), carried in a **new additive key `energy_deferred_repairs`** — not folded into `energy_deferred`, + whose consumers would print "milestone None". Same "readable off the top" rule as the closing review's + evidence lines: each renderer gains one line per deferred repair. +3. **The body-double door is a session start, not `web/routes/body_double.py`.** That route is the read-only focus + reader (`GET /api/body-double/focus`). The session door is `studyloop study "" --mode co-study` on the + CLI and a session start from the Body Double view (origin `body-double`) on the Web. `_evidence_command` has + no branch for a `conversation` candidate and would fall through to `studyloop progress … -c learning`, which is + a write, not a door — so the body-double candidate carries `evidence_command = 'studyloop study "" + --mode co-study'` set explicitly, and `_evidence_command` is not asked to guess. `source="body_double"`, + `action_type="conversation"`, base score below `MILESTONE_BASE_SCORE` (48) so any real candidate outranks it. + +Rule 3's *deferral* of repair is the change; rule 3's *eligibility* of plan-related due recall is untouched. The +no-plan golden stays byte-identical because a body-double candidate requires an active plan and the golden world +has none; `INTERLEAVE_RATIOS["low"]` unchanged. + ## 6. Verification `scripts/verify/plan_integration.py` gains registered checks for: the two architect grants (the ten names in diff --git a/openspec/changes/plan-integration-followons/tasks.md b/openspec/changes/plan-integration-followons/tasks.md index 92dfbea4..a8f912e7 100644 --- a/openspec/changes/plan-integration-followons/tasks.md +++ b/openspec/changes/plan-integration-followons/tasks.md @@ -192,8 +192,13 @@ writer, through the existing gate, closes that. Kept out of item 3 so item 3's f ## Item 5 — energy demand + body-doubling floor (D-F) · own round -- [ ] **T5.1** Design §5 reviewed against the code (`_struggle_candidates`, `_score_candidates`, rule 3) — amend if - the code contradicts it. +- [x] **T5.1** Design §5 reviewed against the code (`_struggle_candidates`, `_score_candidates`, rule 3) — amend if + the code contradicts it. (2026-09-18: three amendments recorded under §5 — the demand classes are the struggle + collector's own (`struggling` fresh/old, weak teach-back, `learning`; no "recovered" row exists); a deferred + repair needs its own `DeferredRepair` in a new additive `energy_deferred_repairs` key because `DeferredMilestone` + and its three renderers are milestone-shaped; the body-double door is `studyloop study … --mode co-study` / + the Body Double view's session start, not the read-only `body_double.py` focus route, so the candidate sets + its `evidence_command` explicitly. T5.2's RED names hold; a sixth test pins the new key's rendering.) - [ ] **T5.2** RED `tests/test_now_plan_guidance.py`: `test_live_struggle_repair_defers_at_low_energy_like_new_work`, `test_recovered_repair_stays_eligible_at_low_energy`, `test_body_double_candidate_is_synthesised_when_nothing_plan_related_fits`, `test_body_double_is_a_proposal_not_a_filter`, `test_body_double_never_appears_without_an_active_plan`, From fc66514f49ad90afdf78a47ab075c2b519183fea Mon Sep 17 00:00:00 2001 From: NetDevAutomate Date: Fri, 18 Sep 2026 19:40:11 +0100 Subject: [PATCH 6/8] chore(deps): anyio 4.12.1 -> 4.15.1 for CVE-2026-63374 / CVE-2026-64847 (audit red) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's `audit` and `audit-full` jobs went red on PR #24 (run 35380095596) while the same commands were green on main at a5b9f903 an hour earlier: pip-audit now reports two advisories against anyio 4.12.1, both fixed in 4.14.2. Nothing on this branch touches a dependency; the advisories were published in between. Reproduced locally with the workflow's exact command (`uv export … | pip-audit --strict --no-deps --disable-pip`). anyio is transitive (no workspace member pins it; six lock dependents, none with a specifier), so `uv lock --upgrade-package anyio` is the whole change: anyio 4.15.1 and its own typing-extensions 4.15.0 -> 4.16.0. Both audit commands now report "No known vulnerabilities found"; the modules that exercise anyio at runtime (MCP stdio smoke, session start on both transports, web plan routes, the combined journey, LAN auth) pass 306/306 on the synced lock. --- uv.lock | 68 ++++++++++++++++++++++++++++----------------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/uv.lock b/uv.lock index 56d18580..698eef57 100644 --- a/uv.lock +++ b/uv.lock @@ -148,15 +148,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.12.1" +version = "4.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] [[package]] @@ -600,7 +600,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder" }, + { name = "cuda-pathfinder", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, @@ -631,43 +631,43 @@ wheels = [ [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cudart = [ - { name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cufft = [ - { name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cufile = [ - { name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] curand = [ - { name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cusolver = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] [[package]] @@ -1642,7 +1642,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc" }, + { name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -1681,7 +1681,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, + { name = "nvidia-cublas", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -1693,7 +1693,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -1723,9 +1723,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, - { name = "nvidia-cusparse" }, - { name = "nvidia-nvjitlink" }, + { name = "nvidia-cublas", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cusparse", marker = "sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -1737,7 +1737,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -2871,8 +2871,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "sys_platform != 'win32'" }, + { name = "jeepney", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -3412,11 +3412,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] From ad598fb410fe048683ce5b82d98946bf5a124b5f Mon Sep 17 00:00:00 2001 From: NetDevAutomate Date: Fri, 18 Sep 2026 19:52:28 +0100 Subject: [PATCH 7/8] test(verify): the base-reachability pin skips where it cannot be judged (CI shallow checkout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 35380095596 failed the 3.12 and 3.13 matrix on PR #24 while every other job passed and main was green an hour earlier. The cause is the new test_protected_file_bases_are_reachable_from_main from 34eb537c: it runs `git merge-base --is-ancestor main`, and CI's actions/checkout is a depth-1 clone of the PR ref with no `main` at all — there the base is not even an object ("fatal: Not a valid object name d7f568bf"). Reproduced locally in a `--depth 1` clone of the branch: same failure. The property the test pins — both protected-files bases reachable on origin — cannot be judged in that checkout, so the test now skips with the reason when the repository is shallow (`rev-parse --is-shallow-repository`) or has no `main` ref, and judges it everywhere else. Proved three ways: passes in the full clone; still fails there with the old 3a4f6b01 restored ("not an ancestor of main"); skips in the shallow clone with "shallow checkout: the bases' reachability cannot be judged here". Module 27/27; ruff/pyright clean. --- .../test_verify_plan_integration_script.py | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/studyloop/tests/test_verify_plan_integration_script.py b/packages/studyloop/tests/test_verify_plan_integration_script.py index db14c4b6..6e8b7ab7 100644 --- a/packages/studyloop/tests/test_verify_plan_integration_script.py +++ b/packages/studyloop/tests/test_verify_plan_integration_script.py @@ -170,16 +170,25 @@ def test_protected_file_bases_are_reachable_from_main(self, script) -> None: check fail on every fresh checkout (2026-09-18: ``3a4f6b01`` survived the history consolidation as a dangling object here and nowhere else). Both bases must be ancestors of the local ``main`` -- the property a rewritten - SHA loses.""" + SHA loses. + + Judged only where it can be: CI's ``actions/checkout`` is a depth-1 + clone of the PR ref with no ``main``, where neither base is even an + object (run 35380095596 failed the 3.12 and 3.13 matrix on exactly + this). There the test skips and says why, rather than reporting a + failure the checkout cannot distinguish from the real one.""" import subprocess + def git(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run(["git", *args], cwd=REPO_ROOT, capture_output=True, text=True) + + if git("rev-parse", "--is-shallow-repository").stdout.strip() == "true": + pytest.skip("shallow checkout: the bases' reachability cannot be judged here") + if git("rev-parse", "--verify", "-q", "main^{commit}").returncode != 0: + pytest.skip("no local `main` ref: the bases' reachability cannot be judged here") + for base in (script.PROTECTED_EARLY_BASE, script.PROTECTED_LATE_BASE): - result = subprocess.run( - ["git", "merge-base", "--is-ancestor", base, "main"], - cwd=REPO_ROOT, - capture_output=True, - text=True, - ) + result = git("merge-base", "--is-ancestor", base, "main") assert result.returncode == 0, f"{base} is not an ancestor of main: {result.stderr}" def test_architect_grants_check_derives_the_ten_names_from_the_inventory(self, script) -> None: From a03fc9bd40d993333018ca5ae9c600ba90d98f6f Mon Sep 17 00:00:00 2001 From: NetDevAutomate Date: Fri, 18 Sep 2026 20:01:34 +0100 Subject: [PATCH 8/8] test(e2e): capture the picker's state when the 409 journey's click times out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_409_from_a_second_tab_offers_reattach_that_adopts_the_session failed on PR #24's first run (35380095596, e2e 568/569) with the same symptom c8b832f4 addressed on 2026-09-18: Locator.click on the Start button timed out, "element is not visible", after the settled wait it added had passed. That fix rested on init()'s state fetch landing late; this recurrence says that was not the whole mechanism. Read against the second failure: the timer has no periodic state poll, its only click-free adopt path is init()'s single fetch (which the settled wait covers — `topic` starts as 'Loading...'), the console's own load-time adoption writes nothing into the timer, and no nav.go fires without a click. Nothing found explains a hidden picker. The failure diagnostics artifact held nothing for this test either: the timeout fires before its own _diag hook. So the click now records what a third occurrence needs — the nav view, sessionActive/starting/topic/agent/resolvedTopic, the held conflict id, the picker's computed display, the button's disabled state and the URL — to a JSON beside a screenshot and DOM dump, then re-raises. No behaviour or timing changed: the fix must rest on that evidence, not on a guess. Class 7/7 locally in natural order; ruff/pyright clean. --- .../e2e/test_session_recovery_journey.py | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/studyloop/tests/e2e/test_session_recovery_journey.py b/packages/studyloop/tests/e2e/test_session_recovery_journey.py index d6a6901a..7ee1c31a 100644 --- a/packages/studyloop/tests/e2e/test_session_recovery_journey.py +++ b/packages/studyloop/tests/e2e/test_session_recovery_journey.py @@ -347,7 +347,43 @@ def test_409_from_a_second_tab_offers_reattach_that_adopts_the_session( d.agent = 'codex'; }""" ) - page.locator("[data-testid='study-start-session']").click() + # Twice on CI this click has timed out with "element is not visible" + # (runs 35341660469 and 35380095596) after the settled wait above passed, + # and neither run produced evidence of WHY the picker was hidden: the + # timeout fires before this test's own _diag hook, so the artifact held + # nothing for it. Every adopt path in the timer was read against the + # second failure and none explains it without a click. Capture the + # state at the click so a third occurrence names the mechanism instead + # of the symptom -- the fix must rest on that, not on a guess. + try: + page.locator("[data-testid='study-start-session']").click() + except Exception: # pragma: no cover - diagnostics only + state = page.evaluate( + """() => { + const root = document.querySelector('[x-data="sessionTimer()"]'); + const d = root && window.Alpine.$data(root); + const picker = document.querySelector('.study-start-picker'); + const button = document.querySelector("[data-testid='study-start-session']"); + return { + view: window.Alpine.store('nav').current, + sessionActive: d && d.sessionActive, + starting: d && d.starting, + topic: d && d.topic, + agent: d && d.agent, + resolvedTopic: d && d.resolvedTopic(), + conflict: d && d.conflictSession ? d.conflictSession.study_session_id : null, + pickerDisplay: picker && getComputedStyle(picker).display, + buttonDisabled: button && button.disabled, + url: location.href, + }; + }""" + ) + RESULTS.mkdir(parents=True, exist_ok=True) + (RESULTS / "session-recovery-409-click-timeout.json").write_text( + json.dumps(state, indent=2), encoding="utf-8" + ) + _diag(page, "409-click-timeout") + raise error = page.locator(".study-start-picker .picker-error") try: