From f43f7eb232c3ffc0757e51b34f1519dc104dfba7 Mon Sep 17 00:00:00 2001 From: NetDevAutomate Date: Mon, 21 Sep 2026 10:34:42 +0100 Subject: [PATCH 01/39] =?UTF-8?q?test(now):=20RED=20=E2=80=94=20the=20body?= =?UTF-8?q?=20double=20proposes=20one=20tiny,=20passive=20first=20move=20(?= =?UTF-8?q?#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's note beside rubric row 3b (a): the sit-with session must not be a blank page. Today _body_double_candidate names what was deferred and proposes nothing; the co-study persona stays quiet by default, so the learner sits down to a plan title. Eleven tests, all red for the stated reason and none for a fixture error: - engine: metadata["first_move"] is the sentence "Open the material and read for ten minutes, nothing more.", closes the reason as "A first move, if you want one: …", contains no exercise, and adds no top-level JSON key (golden untouched); when the content index resolves the milestone's concepts to a lesson, the lesson is named and the index is read once; a raising lookup degrades to the milestone title with no warning. - CLI: a "First move:" line beneath "Sit with the plan:". - Today card: firstMoveNote(rec) and the body-double-request detail carrying firstMove beside activity and energy (additive; the existing hand-off pin is unchanged). - Body Double view: static pins on components.js (listener reads detail.firstMove, firstMove: '' in state) and index.html (#bd-first-move under the activity; .today-first-move line on the card) — the monolith has no node harness, so the contract is pinned in place. - docs: both guides describe the move as passive and optional. The lookup seam is named decision._lesson_title_for so tests can plant a lesson without an FTS index. A body double always has a deferred milestone to draw on — an eligible milestone would have been synthesised as a plan-related candidate and suppressed it — so the issue's "deferred repair only" row is unreachable and is deliberately not built. --- .../tests/js/today-panel-plan.test.js | 44 +++++++ .../test_docs_plan_integration_contract.py | 25 ++++ .../studyloop/tests/test_now_plan_guidance.py | 112 ++++++++++++++++++ .../tests/test_web_body_double_first_move.py | 70 +++++++++++ 4 files changed, 251 insertions(+) create mode 100644 packages/studyloop/tests/test_web_body_double_first_move.py diff --git a/packages/studyloop/tests/js/today-panel-plan.test.js b/packages/studyloop/tests/js/today-panel-plan.test.js index 5fd7dac99..858c4033c 100644 --- a/packages/studyloop/tests/js/today-panel-plan.test.js +++ b/packages/studyloop/tests/js/today-panel-plan.test.js @@ -424,3 +424,47 @@ test('planNotesLabel: "Your plans" when a plan is involved, "Set aside today" fo assert.equal(panel.planNotesLabel(), 'Set aside today'); assert.equal(panel.hasPlanContext, true); }); + +/* Issue #30: the body-double proposal carries one tiny, passive first move on the + deferred material (`metadata.first_move`, engine-derived). The card shows it as + its own line beside the sit-with door and hands it to the Body Double view with + the plan title, so the session does not open on a blank page. Additive: a + payload without the field renders nothing and the hand-off detail is unchanged. */ +const FIRST_MOVE = 'Open the Frames material and read for ten minutes, nothing more.'; + +test('firstMoveNote: the engine\u2019s first move verbatim, nothing when the payload carries none', () => { + const panel = todayPanel(); + const withMove = { ...DEFERRED_REPAIR_PAYLOAD.primary, + metadata: { plan_id: 'sql-windows', deferred_milestones: 1, deferred_repairs: 1, first_move: FIRST_MOVE } }; + + assert.equal(panel.firstMoveNote(withMove), FIRST_MOVE); + assert.equal(panel.firstMoveNote(DEFERRED_REPAIR_PAYLOAD.primary), ''); + assert.equal(panel.firstMoveNote({ ...withMove, source: 'study_progress' }), '', + 'only a body-double proposal carries a first move'); + assert.equal(panel.firstMoveNote(null), ''); +}); + +test('starting a body-double primary hands the first move to the Body Double view beside the plan', () => { + const events = []; + const savedWindow = globalThis.window; + const savedAlpine = globalThis.Alpine; + const savedEvent = globalThis.CustomEvent; + globalThis.window = { dispatchEvent(e) { events.push(e); } }; + globalThis.CustomEvent = class { constructor(type, init) { this.type = type; this.detail = init && init.detail; } }; + globalThis.Alpine = { store() { return { go() {} }; } }; + try { + const panel = todayPanel(); + panel.plan = { ...DEFERRED_REPAIR_PAYLOAD, + primary: { ...DEFERRED_REPAIR_PAYLOAD.primary, + metadata: { plan_id: 'sql-windows', deferred_milestones: 1, deferred_repairs: 1, first_move: FIRST_MOVE } } }; + + panel.startPrimary(); + + assert.equal(events.length, 1); + assert.deepEqual(events[0].detail, { activity: 'SQL Windows', energy: 'low', firstMove: FIRST_MOVE }); + } finally { + globalThis.window = savedWindow; + globalThis.Alpine = savedAlpine; + globalThis.CustomEvent = savedEvent; + } +}); diff --git a/packages/studyloop/tests/test_docs_plan_integration_contract.py b/packages/studyloop/tests/test_docs_plan_integration_contract.py index bfa96a6da..f0fd051ab 100644 --- a/packages/studyloop/tests/test_docs_plan_integration_contract.py +++ b/packages/studyloop/tests/test_docs_plan_integration_contract.py @@ -394,3 +394,28 @@ def test_uninstall_output_makes_no_capability_claims(runner: CliRunner, tmp_path assert "Removed agent definitions" in output for name in PLAN_TOOL_NAMES: assert name not in output + + +# --- Issue #30: the body double's first move ---------------------------------------- + + +def test_study_plans_doc_describes_the_first_move_in_the_engine_terms() -> None: + """The sit-with paragraph must say what the engine guarantees and no more: the + proposal carries ONE first move on the deferred milestone's material, it is + passive (reading), and it is optional — never a task the session enforces.""" + section = _prose(_section(_read("docs/study-plans.md"), "Plan-aware now")).lower() + assert "first move" in section + assert "read" in section, "the doc must say the move is reading, i.e. passive" + assert "deferred milestone" in section or "material" in section + assert "if you want" in section or "optional" in section or "may ignore" in section, ( + "the doc must present the move as a proposal the learner can ignore" + ) + assert "exercise" not in section.split("first move", 1)[1].split(".", 2)[0], ( + "the sentence about the first move must not promise an exercise" + ) + + +def test_web_ui_guide_body_double_names_the_handed_over_first_move() -> None: + section = _prose(_section(_read("docs/web-ui-guide.md"), "Body Double")).lower() + assert "first move" in section + assert "today" in section, "the guide must say where the move comes from (the Today card)" diff --git a/packages/studyloop/tests/test_now_plan_guidance.py b/packages/studyloop/tests/test_now_plan_guidance.py index 1356effdb..af2d59dac 100644 --- a/packages/studyloop/tests/test_now_plan_guidance.py +++ b/packages/studyloop/tests/test_now_plan_guidance.py @@ -1654,6 +1654,118 @@ def test_body_double_command_preserves_title_as_one_literal_shell_argument( assert not (tmp_path / "pwned").exists(), "the title's substitution must never run" +# --- Issue #30: the body double proposes one tiny, concrete first move ---------------- +# +# Owner note recorded beside rubric row 3b (a) (2026-09-20): "the sit-with session +# must not be a blank page — the body double should propose one tiny, concrete first +# move on the deferred material (e.g. 'open the Frames lesson and read it, nothing +# more'). A goal-less session is the hardest ADHD start." The move is derived from +# facts the engine already holds and is PASSIVE — reading, never an exercise, never +# a Socratic round — because it must be consumable at the capability the day carries. +# +# A body double always has a deferred milestone to draw on: it is synthesised only +# when no plan-related candidate exists, and an eligible next milestone would have +# been synthesised as one (rule 7), so every matchable ready plan behind a body +# double has its next milestone in ``energy_deferred``. The issue's "deferred repair +# only" row is therefore unreachable and is not built (design, issue #30). + + +def test_body_double_carries_one_passive_first_move_on_the_deferred_milestone( + monkeypatch, +) -> None: + """Row 3's world: the move opens the deferred milestone's material and asks for + reading only. It rides in the payload as ``metadata["first_move"]`` (additive, + body-double only, so the no-plan golden is untouched) and closes the reason as a + proposal, not a requirement. Without indexed content it names the milestone.""" + _row3_plan() + _plant_struggles(monkeypatch, _struggle("window function", days_ago=3)) + monkeypatch.setattr(decision, "_lesson_title_for", lambda concepts: None) + + low = build_now_plan(energy="low") + + primary = low.primary + assert primary.source == "body_double" + move = primary.metadata["first_move"] + assert move == "Open the Frames material and read for ten minutes, nothing more." + assert primary.reason.endswith(f"A first move, if you want one: {move}"), primary.reason + for forbidden in ("exercise", "practice", "solve", "?"): + assert forbidden not in move, f"a first move must be passive; found {forbidden!r}" + assert primary.evidence_command == 'studyloop study "SQL Windows" --mode co-study', ( + "the door is unchanged" + ) + payload = low.to_json_dict() + assert payload["primary"]["metadata"]["first_move"] == move + golden_keys = list(json.loads(GOLDEN.read_text(encoding="utf-8"))) + assert list(payload) == [ + *golden_keys, + "active_plans", + "energy_deferred", + "energy_deferred_repairs", + ], "the first move adds no top-level key" + + +def test_body_double_first_move_names_the_lesson_when_the_content_index_resolves_it( + monkeypatch, +) -> None: + """When the deferred milestone's concepts resolve to a lesson in the indexed + content, the move names the lesson — the thing the learner can actually open — + and the index is read once, for that milestone's concepts only.""" + _row3_plan() + _plant_struggles(monkeypatch, _struggle("window function", days_ago=3)) + seen: list[tuple[str, ...]] = [] + + def resolve(concepts): + seen.append(tuple(concepts)) + return "Window Frames and Ranges" + + monkeypatch.setattr(decision, "_lesson_title_for", resolve) + + move = build_now_plan(energy="low").primary.metadata["first_move"] + + assert move == "Open “Window Frames and Ranges” and read for ten minutes, nothing more." + assert seen == [("window frame",)] + + +def test_body_double_first_move_survives_a_broken_content_index(monkeypatch) -> None: + """The content index is a refinement, never a dependency: when the lookup raises, + the move still names the milestone and ``now`` still answers.""" + _row3_plan() + _plant_struggles(monkeypatch, _struggle("window function", days_ago=3)) + + def explode(concepts): + raise RuntimeError("fts index unreadable") + + monkeypatch.setattr(decision, "_lesson_title_for", explode) + + low = build_now_plan(energy="low") + + assert low.primary.source == "body_double" + assert low.primary.metadata["first_move"] == ( + "Open the Frames material and read for ten minutes, nothing more." + ) + assert not any("fts" in w for w in low.warnings), "a failed refinement is not a warning" + + +def test_cli_now_prints_the_first_move_beneath_the_sit_with_door(monkeypatch) -> None: + """The CLI shows the move as its own line under the door, after the door, so the + learner reads where to sit before what to open.""" + from click.testing import CliRunner + + from studyloop.cli import cli + + _row3_plan() + _plant_struggles(monkeypatch, _struggle("window function", days_ago=3)) + monkeypatch.setattr(decision, "_lesson_title_for", lambda concepts: None) + + rich = CliRunner().invoke(cli, ["now", "--energy", "low"]) + + assert rich.exit_code == 0, rich.output + assert "Sit with the plan:" in rich.output + assert "First move:" in rich.output + assert "Frames material" in rich.output + assert rich.output.index("Sit with the plan:") < rich.output.index("First move:") + + def test_cli_milestone_deferral_does_not_promise_live_repair(monkeypatch) -> None: """Council review 7, F5 (astra 🟡): in row 3b's own fixture the milestone line used to end "Plan-related review and repair stay available" one line above the line saying the diff --git a/packages/studyloop/tests/test_web_body_double_first_move.py b/packages/studyloop/tests/test_web_body_double_first_move.py new file mode 100644 index 000000000..cbf90255f --- /dev/null +++ b/packages/studyloop/tests/test_web_body_double_first_move.py @@ -0,0 +1,70 @@ +"""Issue #30 — the first move reaches the Body Double view and the Today card. + +``components.js`` (where ``bodyDoubleSession()`` lives) has no node-test harness, +so its side of the hand-off is pinned statically here: the ``body-double-request`` +listener must read ``detail.firstMove``, the picker must show it beside the +activity, and the Today card must render its own first-move line. These are +markup contracts, not behaviour tests — the behaviour lives in +``tests/js/today-panel-plan.test.js`` (the dispatching side) and in the engine +tests that derive the sentence. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[1] / "src" / "studyloop" / "web" / "static" + + +def _read(name: str) -> str: + return (STATIC / name).read_text(encoding="utf-8") + + +def _listener(js: str) -> str: + start = js.index("window.addEventListener('body-double-request'") + depth = 0 + for i in range(start, len(js)): + if js[i] == "{": + depth += 1 + elif js[i] == "}": + depth -= 1 + if depth == 0: + return js[start:i] + raise AssertionError("unterminated body-double-request listener") + + +def test_body_double_view_takes_the_first_move_from_the_today_hand_off() -> None: + js = _read("components.js") + listener = _listener(js) + assert "detail.firstMove" in listener, "the listener ignores the first move" + assert re.search(r"this\.firstMove\s*=", listener), "the first move is not stored on the view" + # The view's initial state declares the field, so a hand-off without one shows nothing. + session = js[js.index("function bodyDoubleSession()") :] + assert re.search(r"\bfirstMove:\s*''", session[:6000]), ( + "bodyDoubleSession() lacks firstMove: ''" + ) + + +def test_body_double_picker_shows_the_first_move_beside_the_activity() -> None: + html = _read("index.html") + picker_start = html.index('class="bd-card bd-start-picker"') + picker = html[picker_start : html.index("", picker_start)] + match = re.search(r']*id="bd-first-move"[^>]*>', picker) + assert match, "the picker has no #bd-first-move element" + tag = match.group(0) + assert 'x-show="firstMove"' in tag and 'x-text="firstMove"' in tag + assert picker.index('id="bd-activity-input"') < match.start(), ( + "the move sits under the activity" + ) + + +def test_today_card_renders_the_first_move_as_its_own_line() -> None: + html = _read("index.html") + card_start = html.index("Your one next action") + card = html[card_start : html.index("", html.index('class="today-reason"', card_start))] + match = re.search(r']*class="today-first-move"[^>]*>', card) + assert match, "the Today card has no .today-first-move line" + tag = match.group(0) + assert "firstMoveNote(plan?.primary)" in tag or "firstMoveNote(plan.primary)" in tag + assert "x-show=" in tag, "the line must hide when the payload carries no first move" From 4d8d9fd5b10a1d0b0351bd7eeffa29443b6fc9bc Mon Sep 17 00:00:00 2001 From: NetDevAutomate Date: Mon, 21 Sep 2026 10:44:08 +0100 Subject: [PATCH 02/39] feat(now): the body double proposes one tiny, passive first move (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GREEN for f43f7eb2. When the engine proposes sitting with the plan, the proposal now carries one first move on the deferred material — "Open the Frames material and read for ten minutes, nothing more." — derived from the first named plan's deferred next milestone and, when the explorer's FTS resolves the milestone's concepts to an indexed lesson, that lesson's title. It rides as metadata["first_move"] on the body-double recommendation only (the Recommendation dataclass is unchanged, so the no-plan golden ec451ce8 is byte-identical) and closes the reason as "A first move, if you want one: …". Why passive: the move must be consumable at the capability the day carries (design §5, low = 3/10), so it is reading — never an exercise, never a Socratic round — and the co-study persona's silence rule is untouched: the Body Double view shows the text, nobody says it. Why the milestone and only the milestone: a body double is synthesised only when no plan-related candidate exists, and an eligible next milestone would have been synthesised as one, so every plan behind a body double has its next milestone deferred. The issue's "deferred repair only" row cannot occur and is recorded as not built rather than shipped as dead code. The lesson lookup (_lesson_title_for) is a seam and a refinement: lazy import of the explorer FTS so learning does not import web at load, one short query per concept, any failure answers None and the move names the milestone. Cost on the live host: 861 ms cold (the explorer's index refresh), 45–58 ms warm per concept, paid only when a body double is synthesised. Renderers read the field and re-derive nothing: CLI prints "First move:" beneath "Sit with the plan:"; the Today card renders its own line and hands firstMove to the Body Double view in the body-double-request detail beside activity and energy (additive — a payload without it hands over exactly what it did before); the picker shows it beneath the activity. Both guides state it in the engine's terms, pinned by the docs contract. Verification: the eleven RED tests green; 444 passed across the now/decision/ MCP/docs/web subsets; JS 150/150; ruff, pyright and mkdocs --strict clean. The openspec change body-double-first-move carries the spec delta (one ADDED requirement, four scenarios), design and tasks; it is archived once the owner scores rubric row 3c. --- docs/study-plans.md | 7 +- docs/web-ui-guide.md | 3 + .../body-double-first-move/.openspec.yaml | 2 + .../changes/body-double-first-move/design.md | 47 +++++++++++++ .../body-double-first-move/proposal.md | 51 ++++++++++++++ .../specs/active-learning-decisions/spec.md | 53 ++++++++++++++ .../changes/body-double-first-move/tasks.md | 26 +++++++ packages/studyloop/src/studyloop/cli/_now.py | 7 ++ .../src/studyloop/learning/decision.py | 69 ++++++++++++++++++- .../src/studyloop/web/static/components.js | 6 +- .../src/studyloop/web/static/index.html | 8 +++ .../web/static/js/components/today-panel.js | 20 +++++- 12 files changed, 293 insertions(+), 6 deletions(-) create mode 100644 openspec/changes/body-double-first-move/.openspec.yaml create mode 100644 openspec/changes/body-double-first-move/design.md create mode 100644 openspec/changes/body-double-first-move/proposal.md create mode 100644 openspec/changes/body-double-first-move/specs/active-learning-decisions/spec.md create mode 100644 openspec/changes/body-double-first-move/tasks.md diff --git a/docs/study-plans.md b/docs/study-plans.md index 435350ddd..a3b963ad9 100644 --- a/docs/study-plans.md +++ b/docs/study-plans.md @@ -228,7 +228,12 @@ available at any energy; due reviews are never deferred. When nothing plan-related fits the day's energy, the recommendation is to **sit with the plan** — a body-double session: you drive, the companion stays quiet unless you ask — with the recorded progress named first and the deferred items after -it; a real, unrelated action still outranks that proposal +it, then one **first move**, if you want one: open the deferred milestone's +material (the indexed lesson when one matches its concepts, otherwise the +milestone by name) and read for ten minutes, nothing more. It is a proposal +you may ignore — never an exercise, never a question — and the Body Double +picker shows it beneath the plan title when you arrive from the Today card. A +real, unrelated action still outranks the sit-with proposal when one exists. An active plan that is **not ready** — a hand edit removed its mission or its milestones — is listed with a warning naming what to repair; it still biases related work, diff --git a/docs/web-ui-guide.md b/docs/web-ui-guide.md index 1fce8ea2e..d205cddd3 100644 --- a/docs/web-ui-guide.md +++ b/docs/web-ui-guide.md @@ -43,6 +43,9 @@ work rather than learning a new concept. 1. Open **Body Double**. 2. Name the activity in concrete terms, such as “trace one decorator call”. + Arriving from the Today card's sit-with proposal fills this in with the + plan's title and shows a **first move** beneath it — one passive thing to + open and read for ten minutes, if you want one; ignore it freely. 3. Choose an agent and start the Pomodoro timer if a time box would help. 4. Start the body-double session. 5. Use **Focus** for up to three active topics and **Park a thought** for anything diff --git a/openspec/changes/body-double-first-move/.openspec.yaml b/openspec/changes/body-double-first-move/.openspec.yaml new file mode 100644 index 000000000..563fab5ab --- /dev/null +++ b/openspec/changes/body-double-first-move/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-21 diff --git a/openspec/changes/body-double-first-move/design.md b/openspec/changes/body-double-first-move/design.md new file mode 100644 index 000000000..1da110430 --- /dev/null +++ b/openspec/changes/body-double-first-move/design.md @@ -0,0 +1,47 @@ +# Design — the body double's first move (#30) + +Amends design §5 of the archived `plan-integration-followons` change (the +body-doubling floor). Decisions taken here, each verified against the tree at +`fa1b2af3`: + +1. **Derived at synthesis, in `_body_double_candidate`.** The candidate already + holds the named plans and `plans.deferred`; the first move is one more + derived string beside the reason. No new read of plan documents (rule-1 pin: + plans are read once, in `_PlanContext.build`). + +2. **Milestone, always.** A body double implies a deferred next milestone for + every matchable ready plan: `_PlanContext.build` synthesises an eligible next + milestone as a candidate (rule 7), any plan-related candidate suppresses the + body double (`any(plans.is_plan_related(c) for c in candidates)`), and a plan + with no next milestone is a completion action, not a matchable plan. So the + move draws on the first named plan's `DeferredMilestone`; the issue's + "deferred repair only" branch is unreachable and not built. + +3. **The lesson lookup is a seam, and a refinement.** `_lesson_title_for` wraps + the explorer's FTS (`_run_fts_search`, the `search_lessons` MCP path) with a + lazy import so the learning layer does not import the web layer at load; + one short query per concept, first title wins; every failure answers `None`. + The caller guards it again so a replaced seam that raises degrades the same + way. Cost: one FTS round-trip per concept, only on the body-double path + (measured below). + +4. **Additive carriage.** `metadata["first_move"]`, present only on the body + double. The `Recommendation` dataclass is unchanged, so the no-plan golden + (`ec451ce8`) is byte-identical without special-casing; the renderers read the + field and re-derive nothing, so CLI and card agree by construction. + +5. **A proposal, not a requirement.** The reason's lead-in is "A first move, if + you want one"; the Today card's label repeats it; the Body Double view shows + the text and says nothing — the co-study persona's silence rule is untouched. + +## Read cost + +The lookup runs only when a body double is synthesised. Measured 2026-09-21 on +the live host (content base `~/Obsidian/Personal/Study`, explorer FTS index +present) with `decision._lesson_title_for` as shipped: **861 ms cold** — the +explorer's own best-effort index refresh over the vault on the first query of a +process — then **45–58 ms warm per concept** (`("window function",)` → +"Advanced Sql 4H", `("decorators",)` → "Decorators 29M", `("window frame",)` → +`None`). Paid once per `now` that synthesises a body double; a plain `now` pays +nothing. For scale, item 4's completion review costs ~320 ms per fully-checked +plan on the same host. diff --git a/openspec/changes/body-double-first-move/proposal.md b/openspec/changes/body-double-first-move/proposal.md new file mode 100644 index 000000000..f099705fb --- /dev/null +++ b/openspec/changes/body-double-first-move/proposal.md @@ -0,0 +1,51 @@ +# Proposal — the body double proposes one tiny, concrete first move (#30) + +## Why + +Item 5 (D-F, on `main` at `96806feb`) gave a low-energy day a floor: when +nothing plan-related fits the day's energy and an active plan exists, the +engine proposes sitting with the plan — a body-double session, the learner +drives, the companion stays quiet. Scoring rubric row 3b (a) the owner said +**yes** to that floor and recorded one note beside it: *"the sit-with session +must not be a blank page — the body double should propose one tiny, concrete +first move on the deferred material (e.g. 'open the Frames lesson and read it, +nothing more'). A goal-less session is the hardest ADHD start; sharpening the +replacement beats restoring the rejected repair."* Today +`_body_double_candidate` names what was deferred and proposes nothing; the +co-study persona pins "stay quiet by default"; the learner sits down to a +title. Issue #30. + +## What + +One derived field and one sentence, from facts the engine already holds: + +- `_first_move(plan, plans)` reads the first named plan's deferred next + milestone and, when `_lesson_title_for(concepts)` resolves its concepts to an + indexed lesson, the lesson's title, and returns `Open and read for + ten minutes, nothing more.` — passive by construction. +- The body-double recommendation carries it as `metadata["first_move"]` + (additive, body-double only; the no-plan golden is untouched) and its reason + ends `A first move, if you want one: `. +- CLI `now` prints a `First move:` line beneath the door; the Today card + renders its own line and hands `firstMove` to the Body Double view with the + plan title; the picker shows it beneath the activity. The door is unchanged. +- `docs/study-plans.md` ("Plan-aware now") and `docs/web-ui-guide.md` ("Body + Double") describe it in the engine's terms, pinned by the docs contract. + +## What is deliberately not built + +- **A first move for a deferred repair.** A body double is synthesised only + when no plan-related candidate exists; an eligible next milestone would have + been synthesised as one (rule 7). So every matchable ready plan behind a body + double has its next milestone in `energy_deferred`, and the issue's "deferred + repair only" row cannot occur. Recorded here rather than shipped as dead code. +- **Speaking the move.** The Body Double view shows it on screen; the persona's + silence rule is untouched, and the move is never turned into questioning + (PDA sensitivity, `agents/shared/audhd-framework.md`). +- **A second content read per consumer.** The lookup runs once, inside the + engine, only on the body-double path. + +## Rubric + +A D-16 row (3c) plants row 3's world and expects the first move to name Frames; +the owner scores it before the change ships in 0.5.1. diff --git a/openspec/changes/body-double-first-move/specs/active-learning-decisions/spec.md b/openspec/changes/body-double-first-move/specs/active-learning-decisions/spec.md new file mode 100644 index 000000000..0f2edbec1 --- /dev/null +++ b/openspec/changes/body-double-first-move/specs/active-learning-decisions/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: The body-double proposal carries one passive first move +When the `now` engine synthesises the body-double proposal (`source == +"body_double"`, design §5's floor), `learning/decision.py::_first_move` SHALL +derive one first move for it from stored facts only: the first named plan's +deferred next milestone (`_PlanContext.deferred`) and, when +`_lesson_title_for(concepts)` resolves that milestone's concepts to a lesson in +the indexed content (the explorer's FTS, read once per body double), the +lesson's title. The move SHALL be the sentence `Open and read for +ten minutes, nothing more.` where `` is `“”` or `the + material`. It SHALL be passive — reading; never an exercise, +a practice task or a question — because it must be consumable at the +capability the day carries. It SHALL ride as `metadata["first_move"]` on the +body-double recommendation only and close its reason as `A first move, if you +want one: `; it SHALL add no top-level key to the `now` payload, so the +no-plan golden is byte-identical. The content index is a refinement, never a +dependency: a lookup that fails or raises SHALL answer the milestone form with +no warning. A body double always has a deferred milestone to draw on — an +eligible next milestone would have been synthesised as a plan-related +candidate and suppressed the body double — so no other source of a first move +is defined. + +#### Scenario: The move names the deferred milestone's material +- **WHEN** rubric row 3's world is ranked at `low` energy (plan floor 5, + milestone 2 “Frames” deferred, a live struggle deferred) and the content + index resolves nothing +- **THEN** the primary is the body double, `metadata["first_move"] == "Open + the Frames material and read for ten minutes, nothing more."`, the reason + ends with `A first move, if you want one: ` followed by that sentence, the + `evidence_command` is unchanged, and the payload's top-level keys are the + golden's then `active_plans`, `energy_deferred`, `energy_deferred_repairs` + +#### Scenario: The move names the lesson when the index resolves it +- **WHEN** the same world is ranked and the lesson lookup resolves the + milestone's concepts (`("window frame",)`) to “Window Frames and Ranges” +- **THEN** `metadata["first_move"] == "Open “Window Frames and Ranges” and + read for ten minutes, nothing more."` and the lookup was called exactly + once, with that milestone's concepts + +#### Scenario: A broken content index degrades to the milestone, silently +- **WHEN** the lesson lookup raises +- **THEN** the primary is still the body double, the move names the Frames + material, and `warnings` carries nothing about the index + +#### Scenario: Every renderer shows the move beside the door, never instead of it +- **WHEN** the body double is primary +- **THEN** CLI `now` prints a `First move:` line beneath `Sit with the plan:`; + the Today card renders `firstMoveNote(primary)` as its own line and hands + `firstMove` to the Body Double view in the `body-double-request` detail + beside `activity` and `energy`; the Body Double picker shows it beneath the + activity (`#bd-first-move`); a payload without the field renders nothing + and hands over exactly what it did before diff --git a/openspec/changes/body-double-first-move/tasks.md b/openspec/changes/body-double-first-move/tasks.md new file mode 100644 index 000000000..a2c8b67ec --- /dev/null +++ b/openspec/changes/body-double-first-move/tasks.md @@ -0,0 +1,26 @@ +# Implementation Tasks — the body double's first move (#30) + +TDD, as the programme's items were: the RED is committed before the production +edit; each task has a definition of done a reviewer can tick from output. + +- [x] **T1 RED** `f43f7eb2` — eleven tests: engine (`test_body_double_carries_one_passive_first_move_on_the_deferred_milestone`, + `..._names_the_lesson_when_the_content_index_resolves_it`, `..._survives_a_broken_content_index`), + CLI (`test_cli_now_prints_the_first_move_beneath_the_sit_with_door`), Today card + (`tests/js/today-panel-plan.test.js`: `firstMoveNote`, hand-off detail), Body Double view and card + markup (`tests/test_web_body_double_first_move.py`, static pins — `components.js` has no node harness), + docs (`test_docs_plan_integration_contract.py`, two tests). All red for the stated reason. +- [x] **T2 GREEN** — `_lesson_title_for`, `_first_move`, the reason tail and `metadata["first_move"]`; + `cli/_now.py` `First move:` line; `today-panel.js` `firstMoveNote` + `firstMove` in the hand-off; + `components.js` listener + `firstMove: ''`; `index.html` `#bd-first-move` and `.today-first-move`; + both guides. DoD: the eleven RED tests green; `test_now_plan_guidance.py` + `test_learning_decision.py` + green; golden `ec451ce8` unchanged; JS 21/21; ruff, pyright, mkdocs `--strict` clean. +- [x] **T3** Read cost of the lesson lookup measured on the live host with the FTS index present, recorded + in `design.md` (861 ms cold / 45–58 ms warm per concept, body-double path only). +- [ ] ⚖ **T4** Council review (three seats) of the RED and the GREEN; arbitration; one commit per accepted + finding. +- [ ] **T5** Rubric row **3c** added to `docs/architecture/plan-integration/receipts/now-rubric-2026-09-16.md`: + row 3's world emitted from the GREEN tree through both real collectors, the first move shown as + emitted; verdict `PENDING` until the owner scores it. The change is archived only after the row is + scored **yes**. +- [ ] **T6** CHANGELOG `[Unreleased]` entry; PR; CI green; fast-forward `main`; archive this change; + ships in 0.5.1. diff --git a/packages/studyloop/src/studyloop/cli/_now.py b/packages/studyloop/src/studyloop/cli/_now.py index d751a0ed7..739190e16 100644 --- a/packages/studyloop/src/studyloop/cli/_now.py +++ b/packages/studyloop/src/studyloop/cli/_now.py @@ -58,6 +58,13 @@ def _render_plan(plan) -> None: f"Source: [dim]{escape(primary.source)}[/dim]\n" + (f"Plan: [magenta]{escape(plan_line)}[/magenta]\n" if plan_line else "") + f"\n[bold]{door}:[/bold]\n{escape(primary.evidence_command)}" + # Issue #30: the body double's one passive first move, beneath the door — + # where to sit, then what to open. Present only when the engine derived one. + + ( + f"\n[bold]First move:[/bold] {escape(str(first_move))}" + if (first_move := primary.metadata.get("first_move")) + else "" + ) ) console.print(Panel(body, title="Study Now", border_style="cyan")) diff --git a/packages/studyloop/src/studyloop/learning/decision.py b/packages/studyloop/src/studyloop/learning/decision.py index e036446a6..fba780e48 100644 --- a/packages/studyloop/src/studyloop/learning/decision.py +++ b/packages/studyloop/src/studyloop/learning/decision.py @@ -28,6 +28,7 @@ from studyloop.cli._shared import TOPIC_KEYWORDS if TYPE_CHECKING: + from collections.abc import Sequence from datetime import date from studyloop.planning.views import ( @@ -1280,6 +1281,67 @@ def _defer_repairs( return kept, tuple(deferred) +def _lesson_title_for(concepts: Sequence[str]) -> str | None: + """The indexed lesson the deferred milestone's concepts resolve to, or ``None``. + + A refinement of the first move (issue #30), never a dependency of it: read + through the explorer's own FTS helpers — the path MCP ``search_lessons`` + takes — one short query per concept, first hit wins. Any failure (no content + base, no index yet, a locked db) answers ``None`` so the move names the + milestone instead; ``now`` must answer whether or not the content index does. + Imported lazily: the engine does not import the web layer at module load. + Tests plant a lesson by replacing this seam. + """ + try: + from studyloop.settings import load_settings + from studyloop.web.routes.explorer import _fts_db_path, _fts_lock, _run_fts_search + + base = load_settings().content.base_path.expanduser() + with _fts_lock: + for concept in concepts: + query = concept.strip() + if len(query) < 2: + continue + rows = _run_fts_search(_fts_db_path(), base, query, 1) + if rows: + title = str(rows[0].get("title") or "").strip() + if title: + return title + except Exception: + logger.debug("first move: lesson lookup unavailable, naming the milestone", exc_info=True) + return None + + +def _first_move(plan: ActivePlanGuidance, plans: _PlanContext) -> str | None: + """Issue #30: one tiny, passive first move on the deferred material. + + The owner's note beside rubric row 3b (a): a sit-with session must not be a + blank page — "open the Frames lesson and read it, nothing more". Derived + from stored facts only: the plan's deferred next milestone (a body double + is synthesised only when every matchable ready plan's next milestone is + deferred — an eligible one would have been synthesised as a plan-related + candidate and suppressed it — so the milestone is always there to draw on) + and, when the content index resolves the milestone's concepts, the lesson + the learner can actually open. Reading only, at the capability the day + carries: never an exercise, never a Socratic round. ``None`` only when the + plan has no deferred milestone, which the invariant above rules out. + """ + summary = plan.plan + deferred = next((d for d in plans.deferred if d.plan_id == summary.plan_id), None) + if deferred is None: + return None + concepts = tuple(plan.next_milestone.concepts) if plan.next_milestone is not None else () + lesson: str | None = None + if concepts: + try: + lesson = _lesson_title_for(concepts) + except Exception: + # The seam guards itself; a replaced seam may not. Same answer either way. + lesson = None + material = f"“{lesson}”" if lesson else f"the {deferred.title} material" + return f"Open {material} and read for ten minutes, nothing more." + + def _body_double_candidate( plans: _PlanContext, candidates: list[_Candidate], @@ -1302,10 +1364,12 @@ def _body_double_candidate( # An active-but-unready plan is matched but never synthesised (spec rule 8); # the body double is a synthesis, so only ready plans are sat with. The # warning beside it already says "pause or repair". - named = [plan.plan for plan in plans.matchable if plan.readiness.ready] + ready_plans = [plan for plan in plans.matchable if plan.readiness.ready] + named = [plan.plan for plan in ready_plans] if not named: return None first = named[0] + first_move = _first_move(ready_plans[0], plans) titles = " and ".join(plan.title for plan in named) items = [ f"milestone {d.milestone_index + 1} “{d.title}” of {d.plan_title}" for d in plans.deferred @@ -1333,6 +1397,7 @@ def _body_double_candidate( f"{progress_note}Nothing plan-related fits {energy} energy today{deferred_note}. " f"Sit with {titles} instead: a body-double session — you drive; the companion " "stays quiet unless you ask." + + (f" A first move, if you want one: {first_move}" if first_move else "") ), action_type="conversation", estimated_minutes=_estimate_minutes("conversation", time_minutes, 25), @@ -1343,6 +1408,8 @@ def _body_double_candidate( "plan_id": first.plan_id, "deferred_milestones": len(plans.deferred), "deferred_repairs": len(deferred_repairs), + # Issue #30: additive, body-double only — the no-plan golden never sees it. + **({"first_move": first_move} if first_move else {}), }, plan_refs=tuple(PlanRef(plan.plan_id, None) for plan in named), ) diff --git a/packages/studyloop/src/studyloop/web/static/components.js b/packages/studyloop/src/studyloop/web/static/components.js index eb6786930..21e4dbee9 100644 --- a/packages/studyloop/src/studyloop/web/static/components.js +++ b/packages/studyloop/src/studyloop/web/static/components.js @@ -3128,7 +3128,7 @@ function bodyDoubleSession() { slots: [], slotsUsed: 0, maxActive: 3, atCapacity: false, parkingLotCount: 0, focus: { topics: [], is_set: false, is_stale: false }, focusCollapsed: false, captureCollapsed: false, captureTab: 'note', - activity: '', agent: '', transport: 'pty', energy: 5, agents: [], + activity: '', firstMove: '', agent: '', transport: 'pty', energy: 5, agents: [], sessionActive: false, liveActivity: '', confirmingEnd: false, endError: '', // R-70: set when /api/session/end fails; keeps the dialog open starting: false, startError: '', @@ -3196,6 +3196,10 @@ function bodyDoubleSession() { window.addEventListener('body-double-request', (event) => { const detail = (event && event.detail) || {}; if (detail.activity) this.activity = String(detail.activity); + /* Issue #30: the proposal's one passive first move, shown beneath the + activity so the session does not open on a blank page. Cleared when + a hand-off carries none, so a stale move never outlives its plan. */ + this.firstMove = detail.firstMove ? String(detail.firstMove) : ''; const bands = { low: 3, medium: 5, high: 8 }; if (detail.energy && bands[detail.energy]) this.energy = bands[detail.energy]; }); diff --git a/packages/studyloop/src/studyloop/web/static/index.html b/packages/studyloop/src/studyloop/web/static/index.html index f84fc69d3..dfe6fdff9 100644 --- a/packages/studyloop/src/studyloop/web/static/index.html +++ b/packages/studyloop/src/studyloop/web/static/index.html @@ -1092,6 +1092,11 @@

·

Why:

+ +

+ First move, if you want one: +

@@ -1712,6 +1717,9 @@

Body Double

+ +