feat(now): the body double proposes one tiny, passive first move (#30) - #32
Merged
Merged
Conversation
#30) 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 <milestone> 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.
GREEN for f43f7eb. 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.
…d9fd, verdict PENDING (#30) Row 3's world through both real collectors on the GREEN tree: the sit-with proposal carries "A first move, if you want one: Open the Frames material and read for ten minutes, nothing more." The lesson lookup answers nothing here, as it does on the owner's own vault for "window frame" (checked). Medium energy is the control: guided repair primary, no body double, no first move. The owner scores the row; council review 8 is recorded as blocked at the model provider on all three seats after the key rotation and is re-run once the gateway authenticates.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Reset stale firstMove state after sessions and add focused lesson-resolution path coverage.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (1)
What changed in this PR
Adds an optional passive first move to plan-aware Body Double proposals across CLI and web interfaces.
Changes:
- Derives lesson- or milestone-based guidance.
- Carries it through Today and Body Double UI.
- Adds tests, documentation, OpenSpec artifacts, and rubric evidence.
| File | Summary |
|---|---|
packages/studyloop/tests/test_web_body_double_first_move.py |
Pins static Body Double UI contracts. |
packages/studyloop/tests/test_now_plan_guidance.py |
Tests engine and CLI guidance. |
packages/studyloop/tests/test_docs_plan_integration_contract.py |
Validates documentation contracts. |
packages/studyloop/tests/js/today-panel-plan.test.js |
Tests UI handoff behavior. |
packages/studyloop/src/studyloop/web/static/js/components/today-panel.js |
Passes first-move guidance to Body Double. |
packages/studyloop/src/studyloop/web/static/index.html |
Adds first-move markup. |
packages/studyloop/src/studyloop/web/static/components.js |
Stores and displays handoff state; reset stale firstMove when the session ends. |
packages/studyloop/src/studyloop/learning/decision.py |
Derives first-move guidance; focused helper-path coverage is requested. |
packages/studyloop/src/studyloop/cli/_now.py |
Renders the CLI guidance. |
openspec/changes/body-double-first-move/tasks.md |
Tracks implementation and release tasks. |
openspec/changes/body-double-first-move/specs/active-learning-decisions/spec.md |
Defines the requirement and scenarios. |
openspec/changes/body-double-first-move/proposal.md |
Describes scope and exclusions. |
openspec/changes/body-double-first-move/design.md |
Records implementation decisions and cost. |
openspec/changes/body-double-first-move/.openspec.yaml |
Declares change metadata. |
docs/web-ui-guide.md |
Documents the Body Double experience. |
docs/study-plans.md |
Documents plan-aware guidance. |
docs/architecture/plan-integration/receipts/now-rubric-2026-09-16.md |
Records rubric row 3c evidence. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| /* 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) : ''; |
…long Owner, 2026-09-21, adopting the steer as his reasoning: his own note made literal; reading sits at zero in the demand classes so 3/10 refuses nothing; 'nothing more' caps the commitment. Two follow-ons: open the material inside StudyLoop's own frame (to reading (d)); read-along mode filed as #33.
… the index holds one (rubric 3c (b)) Owner, 2026-09-21, scoring reading (b): 'A deliberate lesson should always be the case — this stops any decision fatigue and removes that friction.' The seam becomes _resolve_lesson(queries) -> (lesson_id, title) | None, asked once with the fallback chain in order — the milestone's concepts, then its title, then the plan's topics — so a vault that never uses the concept's exact words still yields a specific lesson; the id rides as metadata['first_move_lesson_id'] so a renderer can open it in StudyLoop's own frame. The milestone form remains only for an index that holds nothing relevant. A seam test proves the chain against the explorer's search function faked at its boundary. The two pyright-ignore tags mark attributes the GREEN defines; they come off then.
…e's own concepts, otherwise says why (rubric 3c (c)) Rubric row 3c reading (c), owner 2026-09-21. Building (b)'s "always a deliberate lesson" chain (concepts -> milestone title -> plan topics) and running it against the owner's real vault showed it always names a lesson — the wrong one: "Frames" FTS-hits a PySpark data-frames lab, "sql" hits an SQL bootcamp introduction; only the sibling milestone's "window function" finds Advanced Sql 4H, and only because this plan's two milestones share a lesson. A deliberate-but-wrong lesson spends a 3/10 day's one action on the wrong material and looks certain doing it. Owner: "I suspect I would have doubts/concerns before opening which were confirmed after opening it." So the search is bounded to the deferred milestone's own concepts, and when they match nothing the sentence names the milestone and says why — the information the fix needs (a lesson, or a concept name on the milestone): Open your Frames material and read for ten minutes, nothing more — no indexed lesson mentions “window frame” yet. Seven tests fail against the working tree's chain for the stated reasons: the real-vault replay stops at "Frames" and names the PySpark lab; a concept-less milestone must not be looked up by its title; a searched miss names the missing concept(s); an index that cannot be read makes no claim about itself (the seam raises instead of answering a false miss); the CLI prints the why-clause. The seam test's ignore tags come off at GREEN.
…cepts only, otherwise names the milestone and says why (rubric 3c (b)+(c)) GREEN for fdfe167 (rubric row 3c readings (b) and (c), owner 2026-09-21). _resolve_lesson(concepts) asks the explorer's FTS one short query per concept of the deferred milestone, first hit wins, returning (lesson_id, title) so a renderer can open the lesson in StudyLoop's own frame (metadata.first_move_lesson_id). It no longer swallows failures: None is a searched miss, an unreadable index raises out of the seam, and the caller tells the two apart. _first_move names the lesson when a concept resolves — (b): a deliberate lesson whenever the vault holds one — and otherwise names the milestone AND says why, in one of three honest shapes (design decision 6): searched, no match … nothing more — no indexed lesson mentions “window frame” yet. no concept named … nothing more — this milestone names no concept to look up yet. index unreadable … nothing more. (no claim about an index never read) The (b) chain that also searched the milestone's title and the plan's topics is dropped, not shipped: on the owner's real vault it named a PySpark data-frames lab for "Frames" and an SQL bootcamp intro for "sql" — always a lesson, the wrong one. Recorded in the design with the measurement; tasks T3b/T3c carry the revision. Verification: test_now_plan_guidance.py 82/82 (the seven RED tests green, no other test changed behaviour); JS 150/150 with the fixture sentence updated to the emitted one; docs contract tests, mkdocs --strict, openspec validate clean; ruff, pyright clean; no-plan golden ec451ce8 byte-identical. Emitted for the receipt through both real collectors and the real seam: the seam was asked ("window frame",) only; on the owner's vault "window frame" resolves to nothing today, "window function" to Advanced Sql 4H.
…ts only, otherwise name the milestone and say why; screen re-emitted from 62f3d6f Owner verdicts recorded verbatim, with the real-vault measurement that turned (b)'s chain into (c) and the self-check answer. World, emitted and what-it-does columns amended to the shipped seam (asked the milestone's own concept only) and the three honest no-lesson shapes.
…so closing the reason (rubric 3c (d)) Owner verdict 2026-09-21 on rubric row 3c reading (d1): keep the dedicated 'First move' line, drop the sentence from the reason. As emitted on 62f3d6f the same twenty-odd words appeared twice on a 3/10 screen — closing the Why: paragraph and again as their own line beneath the door — because the first RED specified both. The reason explains the recommendation; the move is an action and belongs beside the door. No consumer reads the reason alone: the CLI, the Today card and MCP get_next_action all carry metadata.first_move. - the engine test requires the reason to end at the co-study guarantee and to contain neither the sentence nor the words 'first move' - the CLI test requires the sentence exactly once on the panel - the real-vault test's wrong-lesson check now reads the whole serialised recommendation, since the reason no longer carries a lesson name at all
…m the reason (rubric 3c (d1)) Owner verdict 2026-09-21, rubric row 3c reading (d1): keep the dedicated 'First move' line, drop the sentence from the reason. The first GREEN (4d8d9fd) closed the body double's reason with 'A first move, if you want one: <move>' AND carried the move as metadata.first_move, so the CLI panel and the Today card each showed the same twenty-odd words twice on a 3/10 screen — the Why: paragraph's tail and the dedicated line two lines below. The reason explains the recommendation; the move is an action and belongs beside the door. Verified before removing the copy: no consumer reads the reason alone — CLI now, the Today card and MCP get_next_action all carry the payload's metadata — so nothing loses the move. - decision.py: the reason tail is gone; metadata['first_move'] is the only carriage (comment records the rule and why) - spec delta: the reason SHALL NOT carry the sentence; each renderer shows it once; scenario 1 pins the reason's ending; scenario 3's wrong-lesson check reads the whole serialised recommendation - design decision 5 records the verdict and the verification - tasks: T3d - docs/study-plans.md: 'beside the session door, once' Re-emitted through both real collectors with the real seam: the low panel carries the sentence exactly once, the medium control zero times; golden ec451ce8 byte-identical; engine + decision 87/87, docs contract and static pins green, JS 150/150, openspec valid, mkdocs --strict clean.
…e reason; screen re-emitted from 75bc9b7 Owner verdict 2026-09-21. The emitted reason now ends at the co-study guarantee; the sentence is counted once on the low panel and zero times on the medium control. (d2)-(d3) and (e) remain to walk.
…on is opened despite the doubt Verbatim: 'Before opening it — but honestly, I would likely still open it in case there was some link that is being enforced between PySpark and SQL'. A named lesson carries implied authority, so a wrong lesson is followed, not merely doubted. Recorded as the constraint on (d2): the sentence must state the evidence for the lesson it names before any control opens it.
…longs to and the concept the match rests on (rubric 3c (d2)) Owner, refining the (c) self-check while (d2) was open (2026-09-21): 'Before opening it — but honestly, I would likely still open it in case there was some link that is being enforced between PySpark and SQL'. A named lesson carries implied authority: a wrong lesson is followed, not merely doubted. So the sentence must let the link be judged from the sentence — and the button (d2) sits behind that. - the lesson sentence becomes: Open “<lesson>” from <Course> — the match is the word/phrase “<concept>” — and read for ten minutes, nothing more. The course is the hit's own course_id humanised exactly as the explorer's course list shows it (stored fact, same function); the concept is the one the FTS matched. - the seam returns (lesson_id, title, course) and skips a hit lacking its course or title: a lesson is named with its evidence or not at all. - metadata.first_move_lesson_title rides beside first_move_lesson_id so a renderer can open the lesson with its name, without parsing the sentence. - replays the owner's real-vault finding through the real seam: a Python plan's 'decorators' resolving to Decorators 29M in The Ultimate TypeScript now reads as a word match at a glance.
…ched
With two concepts the caller cannot know which one hit; the sentence needs
it ('the match is the word X'). The hit is (lesson_id, title, course,
concept) — the concept that matched, not the first one asked.
… to and the concept the match rests on (rubric 3c (d2)) Owner, 2026-09-21, refining the (c) self-check: 'Before opening it — but honestly, I would likely still open it in case there was some link that is being enforced between PySpark and SQL'. A named lesson carries implied authority, so a wrong lesson is followed, not merely doubted. The sentence must let the link be judged from the sentence. - _resolve_lesson returns (lesson_id, title, course, concept): the course is the hit's own course_id humanised by the explorer's _humanise, exactly as its course list shows it; the concept is the one that matched, not the first asked. A hit lacking its course or title is skipped — a lesson is named with its evidence or not at all. - _first_move: Open “<lesson>” from <Course> — the match is the word|phrase “<concept>” — and read for ten minutes, nothing more. Returns the lesson title too; metadata.first_move_lesson_title rides beside the id so a renderer opens the lesson by name without parsing the sentence. - spec delta: requirement text, scenario 2 with evidence, a new scenario for the TypeScript case, the seam scenario's four-part hit and the skipped orphan row; design decision 7 records the owner's words, the mechanism, and why topic scoping was not chosen; docs/study-plans.md. Engine + decision 88/88, docs contract and static pins green, JS 150/150, openspec valid, mkdocs --strict clean, golden ec451ce8 byte-identical.
… in the Course Explorer beside the view (rubric 3c (d2)) Owner verdict 2026-09-21: build the open-the-lesson control, gated behind the evidence sentence (landed in 4459ea3). Eight tests, red for their stated reasons: - Today card (node): firstMoveLesson(rec) exposes {id, title} only for a body-double proposal that resolved a lesson; openFirstMoveLesson() dispatches explorer-open-lesson {lessonId, title} and does NOT navigate — the learner stays on Today, the lesson opens in the aside; startPrimary hands firstMoveLessonId/firstMoveLessonTitle to the Body Double view beside the move (a payload without them hands over exactly what it did). - static pins (components.js has no node harness): the explorer listens for explorer-open-lesson and openLessonById() opens the aside and the lesson; the Body Double listener stores the id and title, the view has openFirstMoveLesson(); the picker has #bd-first-move-open and the Today card data-testid=today-open-first-move-lesson, each shown only when a lesson resolved and placed beside the move. - docs: the Today and Body Double guide sections say the lesson opens in the Course Explorer.
…he Course Explorer beside the view (rubric 3c (d2)) Owner verdict 2026-09-21: build the button, gated behind the evidence sentence (4459ea3). Before this, first_move_lesson_id was carried and consumed by nothing: the card said 'Open “Decorators 29M”' beside a UI that could have opened it and left the finding to the learner. - Course Explorer: listens for explorer-open-lesson {lessonId, title}; openLessonById opens the aside if closed (same store/layout toggle as toggle()), fetches the tree if needed, and calls the existing openLesson with the minimal lesson object openSearchResult builds — no second reader. - Today card: firstMoveLesson(rec) exposes {id, title} only for a body-double proposal that resolved a lesson; openFirstMoveLesson() dispatches the event and navigates nowhere — the learner stays on Today with the lesson in the aside; startPrimary hands firstMoveLessonId/Title beside firstMove. - Body Double view: stores the id and title from the hand-off (cleared with the move); openFirstMoveLesson(); #bd-first-move-open beside the move. - index.html: both controls use the stylesheet's bulk-btn; each x-shows only when a lesson resolved — row 3's Frames world shows no button. - docs/web-ui-guide.md (Today, Body Double); spec scenario; design decision 8 (why the aside, why the in-session pane is #33's); tasks T3e. Static pins + docs contract + engine 123 green, JS 153/153, node --check on both scripts, mkdocs --strict clean, openspec valid, golden ec451ce8 byte-identical. Wide web subset 782 passed with two failures outside this change: the live_kiro dogfood test (opt-in live agent) and a schema-init test that passes alone and 7/7 in its own file.
…ce sentence; real-vault seam probe recorded Owner verdict 2026-09-21. Cycle 1 (2b85a80+089d09ae -> 4459ea3): a named lesson states its course and the matched concept. Cycle 2 (e157627 -> 266afd4): Open the lesson on the Today card and the Body Double picker, opening the Course Explorer aside beside the view. Shipped seam probed on the real index: 'window frame' -> None (no button, honestly); 'decorators' -> Decorators 29M / The Ultimate Typescript. (d3) and (e) remain.
…the first move and its control beneath the activity name (rubric 3c (d3)) Owner 2026-09-21, rubric row 3c reading (d3): carry the move and the button into the live session strip. Why: the first move and Open-the-lesson live only in the picker (x-show="!sessionActive && !starting"). Pressing Start hides the picker and shows a strip with the activity name and End — the sentence is gone at exactly the moment the blank page arrives, and unless the lesson was opened beforehand there is no second chance without ending the session. Four pins, each failing for its stated reason before a line of code changed: - #bd-live-first-move in the strip, x-show/x-text="firstMove", after #bd-live-activity (beneath the activity name); - #bd-live-first-move-open beside it, x-show="firstMoveLessonId", reusing openFirstMoveLesson() — the view's one opener; - confirmEnd() clears firstMove/firstMoveLessonId/firstMoveLessonTitle beside the activity it already clears: the move arrived with the activity in one hand-off and leaves with it, so the strip never shows a stale move beneath the next, unrelated activity; - the Body Double guide says, at the Start step, where the move lives once the session runs and that the companion never says it. The state itself already survives the start — nothing clears the three fields on startSession() — so GREEN is markup reading state the view holds, one end-path line, one CSS rule and one guide sentence. No auto-open at start.
…irst move and its control beneath the activity name (rubric 3c (d3)) Owner 2026-09-21, rubric row 3c reading (d3): carry the move and the button into the live session strip. GREEN for RED 74779bd. What changed: - index.html: #bd-live-first-move inside .bd-live-strip, x-show="firstMove", the same sentence, with #bd-live-first-move-open beside it when a lesson resolved (x-show="firstMoveLessonId", @click="openFirstMoveLesson()" — the view's one opener). The strip is flex-wrap, so the line wraps to its own row beneath the activity name and End. - style.css: one rule, flex-basis 100%, typed like the picker's hint so the sentence reads as the same proposal it was before Start. - components.js: confirmEnd() clears firstMove/firstMoveLessonId/ firstMoveLessonTitle beside the activity it already cleared. The move arrived with the activity in one hand-off and leaves with it — now that the strip shows the move for the whole session, a stale one beneath the next, unrelated activity would be a confidently wrong proposal on the one surface that is always on screen. - docs/web-ui-guide.md: the Start step says where the move lives once the session runs and that the companion never says it. - spec delta: scenario "The move survives the start"; design decision 9 (rejected: auto-open at start); tasks T3f. Nothing the companion says changes — the co-study persona is untouched; the move stays a proposal on screen. No payload change, so the golden is byte-identical. Verification: static pins + docs contract 39/39 (the four RED flipped), engine + decision + web-now + golden 89/89, JS 153/153, node --check on components.js, mkdocs --strict clean, openspec valid, golden ec451ce8 unchanged.
…ve session strip; RED 74779bd → GREEN 5666f14 Owner verdict 2026-09-21 recorded beside (d2) with the evidence put to the owner (both lived only in the picker; Start hid them at the moment the blank page arrived), the steer, the rejected auto-open, the one consequence built (confirmEnd() clears the three first-move fields beside the activity), and the unchanged row-3 screen. (e) remains.
… too; medium control re-emitted from 5666f14; (e1) shape pending Verdict recorded verbatim before any build. Also records the wall-clock leak on the medium screen ("last seen 8 day(s) ago" for a row planted 3 days before the frozen clock): now_world freezes decision.datetime only, history/progress.py reads the real clock. A test-isolation gap to fix separately, not a product defect.
…t move on its own material (rubric 3c (e1))
Owner 2026-09-21, rubric row 3c reading (e): "no, offer the move at medium
energy too", built as (e1) — the warm-up INTO the primary, on the primary's
own material (the owner took that steer over the passive alternative beside
the primary).
Why: the low-energy move is the sit-with's whole action and ends "nothing
more"; printed beneath a task the day CAN carry, that sentence would tell the
learner two contradictory things and the passive one is the easier to take
(the 3b (d) mistake). A ramp ends "then start …" and lowers the first step of
the primary instead of competing with it.
Five tests fail for their stated reason before a line of code changed:
- the row-3 repair at medium (both collectors live) carries
metadata.first_move = Open "Advanced Sql 4H" from Complete Sql Databases
Bootcamp — the match is the phrase "window function" — and read for ten
minutes, then start the repair.; lesson id + title beside it; the resolver
asked ("window function",) only; reason untouched; no top-level key added;
- a plan milestone primary asks the milestone's own concepts, all of them, and
ends "then start the milestone";
- nothing indexed: Open your "window function" material … then start the
repair — no indexed lesson mentions "window function" yet.; an unreadable
index gets the plain ramp and no warning;
- the CLI prints First move: beneath Record evidence: at medium, once;
- both guides say a plan-related active action carries a warm-up ("then
start", "warm"), and that recall never carries one.
The sixth new test is the GUARD, holding today and required to hold at GREEN:
no warm-up on a plan-related recall (the resolver is not asked — reading the
lesson before a retrieval test defeats the test), none on an active primary
off every plan, and the no-plan world byte-identical to the golden.
… on its own material (rubric 3c (e1)) Owner 2026-09-21, rubric row 3c reading (e): "no, offer the move at medium energy too" — built as (e1) the warm-up INTO the primary, on its own material (the owner took that steer over the passive alternative beside the primary). GREEN for RED fa5d76b. Why this shape: the low day's move is the sit-with's whole action and ends "nothing more". Printed beneath a task the day CAN carry, that sentence would tell the learner two contradictory things and the passive one is the easier to take — the 3b (d) mistake. The warm-up ends "then start …" and lowers the first step of the primary instead of competing with it. On the medium screen the window function repair resolves on the owner's vault to Advanced Sql 4H in Complete Sql Databases Bootcamp — the lesson where window functions are taught — so the ramp reads: Open "Advanced Sql 4H" from Complete Sql Databases Bootcamp — the match is the phrase "window function" — and read for ten minutes, then start the repair. What changed (decision.py): - _first_move_sentence: ONE definition builds both moves — (b)'s deliberate lesson, (c)'s three honest no-lesson shapes, (d2)'s evidence sentence — parametrised by material and tail. _first_move (the sit-with) now calls it with tail "nothing more"; its output is unchanged (every earlier first-move test green). - _warm_up: on the primary only, after ranking and the plan-backed guarantee; plan-related (carries a PlanRef), not the body double, action_type in hands-on/conversation/teachback. Tails in test order: a repair (energy_demand in metadata — both collectors mark repairs and nothing else) → "then start the repair"; the plan's eligible next milestone → its own concepts, all of them, "then start the milestone"; any other plan-related active item → "then start on “<concept>”". - Never recall (the resolver is not asked — reading the lesson before a retrieval test defeats the test; row 3b (b) already said familiar recall leads as it is); never visual/audio (already passive); never off a plan, so the no-plan golden ec451ce8 is byte-identical; alternates carry none. - _first_move_metadata: the shared carriage (first_move, lesson id, title), used by the body double too — same keys, same order. - Renderers needed no change: CLI First move:, the Today card's line and its Open-the-lesson control key on metadata.first_move, not on the body double. Also: both guides (study-plans "Plan-aware now", web-ui-guide "Today"); a new spec requirement with three scenarios (moved after the first requirement's scenarios — the validator caught the orphaning); proposal corrected (the reason has not carried the move since (d1); the warm-up is a second carrier); design decision 10; read cost re-measured for the warm-up path (161 ms cold / 45 ms warm); tasks T3g. RED correction made at GREEN, recorded: the medium payload has no energy_deferred* keys (omitted when empty), so its top-level shape is the golden's plus active_plans — the RED's assertion listed all three. One test added at GREEN for the third tail (a plan-related teach-back), labelled so. Verification: now-guidance + learning-decision + web-now + golden + static pins + docs contract 135/135, JS 153/153, ruff + format + pyright clean, mkdocs --strict clean, openspec valid, golden ec451ce8 unchanged.
…material; RED fa5d76b → GREEN dacbea8; row 3c fully scored Owner took the steer over the passive alternative. Records the sentence as the engine test replays it with the vaults real hit, the isolated-world emission with its fixture-index caveat (as at (d2)), the scope rule with each exclusions reason, and the DoD. Council review 8 remains blocked at the model provider.
…r and status bar carry it, the Today card renders it (rubric 3c (e2)) Owner 2026-09-21: "build it now, the (d2)+(d3) shape on the Study view". Two defects behind this RED, both found by reading the code the (e1) warm-up had to travel through: - The Today card's Start → on a study action navigated and handed the Study picker NOTHING — not the warm-up, not even the concept. Only the resume and parked paths (today-resume) carried a topic. The learner retyped the topic from memory and the sentence the card had just shown was thrown away. - firstMoveNote()/firstMoveLesson() on the Today card gate on source === 'body_double', so the card did NOT render the (e1) warm-up at all. The (e1) GREEN (dacbea8) claimed "renderers needed no change" — true of the CLI, which keys on the field, false of the Today card: I read the field names, not the two method bodies. Corrected here; recorded in the receipt and the design at GREEN. Twelve tests fail for their stated reasons (the flashcards guard holds): - today-panel-plan.test.js: the two source-gate assertions flipped (the card reads the field wherever the engine put it); Start on a study-session primary dispatches today-resume {topic, energy, firstMove, firstMoveLessonId, firstMoveLessonTitle}; without a move, {topic, energy} only; a flashcards primary still hands nothing and navigates. - session-timer.test.js: today-resume carries the move and lesson beside the topic and a hand-off without one clears them; openFirstMoveLesson dispatches explorer-open-lesson (nothing to open → nothing dispatched); confirmEndSession clears the move with the topic; startPlanning carries no warm-up. - test_web_study_first_move.py (new): #study-first-move beneath #topic-input with #study-first-move-open; #study-live-first-move beneath the status bar with #study-live-first-move-open. - docs contract: the Study Session guide names the first move, the status bar and the control, and where the move comes from; Today says Start hands the action to Study Session.
… status bar carry it, the Today card renders it (rubric 3c (e2)) Owner 2026-09-21: "build it now, the (d2)+(d3) shape on the Study view". GREEN for RED eb3be21. Two defects fixed: - today-panel.js startAction(): Start on a study action navigated and handed the Study picker NOTHING — not the (e1) warm-up, not even the concept. It now dispatches today-resume {topic: concept, energy, firstMove?, firstMoveLessonId?, firstMoveLessonTitle?} — the existing resume/parked event, no new one — then navigates. The Body Double hand-off is unchanged and shares one _firstMoveDetail builder. - today-panel.js firstMoveNote()/firstMoveLesson(): the source === 'body_double' gate comes off; the card reads the field wherever the engine put it. This gate hid the (e1) warm-up, so dacbea8's claim that the Today card "followed without change" was false — true of the CLI only. Recorded in design decision 11 and the receipt; decision 10 corrected in place. session-timer.js: firstMove/firstMoveLessonId/firstMoveLessonTitle state; the today-resume listener sets them from EACH hand-off (a resume or parked pick-up carries none and clears one left by an earlier Start); openFirstMoveLesson() dispatches explorer-open-lesson (nothing to open → nothing dispatched); confirmEndSession() clears them with the topic; startPlanning() clears them (the architect interview is not a repair). index.html: #study-first-move + #study-first-move-open beneath the topic input in the picker; #study-live-first-move + #study-live-first-move-open beneath the status bar in the live layout, for the whole session. style.css: one block. docs/web-ui-guide.md: Study Session steps 2 and the status-bar paragraph; Today's Start sentence. Spec: requirement text corrected, scenario "The warm-up follows Start into the Study view". Tasks T3h. Proposal. Test-side changes recorded: two JS assertions flipped (the card renders the move for any recommendation that carries one); the review-7 F7 test's "an ordinary action dispatches nothing" updated to the new hand-off; the (e2) harness keeps its stubs until an async run settles (first version restored them in a synchronous finally). Verification: new pins + Body Double pins + docs contract + now-guidance + web-now + golden 134/134; web unit suites reading the markup 144 passed (1 skipped, 5 deselected); JS 160/160; node --check on both scripts; mkdocs --strict clean; openspec valid; golden ec451ce8 unchanged.
…view; (e1) renderer claim corrected in place; RED eb3be21 → GREEN 25886ff Records the two defects (Start handed the Study picker nothing; the Today card gated the move on the body double), the owner instruction, the build, and the correction of the (e1) record: the Today card did not follow without change — the claim was asserted from field names, not method bodies.
…earched miss; a malformed top row does not hide the hit beneath it Two accepted findings from council review 8 (seats openai.gpt-6-astra, grok-4.6, qwen3-coder; brief docs/architecture/plan-integration/council/ brief-review8-2026-09-21.md), verified against the tree before acceptance: - astra F3 (🟡): _resolve_lesson skips queries under two characters (the explorer's search refuses them) but the caller then said "no indexed lesson mentions “C” yet" — a searched-miss claim about a search that never ran. None must stay a SEARCHED miss: a too-short concept is named as too short, and a miss names only the concepts that were searched. - grok 🔵 / astra refutation 3: the seam fetched ONE row per concept and, when that row lacked its course or title, skipped the whole concept — so a malformed top row hid a well-formed lesson ranked beneath it. "A hit lacking its course or its title SHALL be skipped" means skip the ROW. Both fail for their stated reason before a line of code changed.
… a searched miss; the first WELL-FORMED hit wins GREEN for RED a33249d. Two findings, verified against the tree before acceptance: - astra F3 (🟡): the explorer refuses queries under two characters and the seam skipped them, yet the sentence said "no indexed lesson mentions “C” yet" about a search that never ran — a false content-gap claim in the one clause whose whole point is honesty. _first_move_sentence now separates searchable from too-short concepts (one shared constant, _MIN_QUERY_CHARS): too-short ones are named as "too short for the index to look up" and never sent to the resolver; a miss names only the concepts that were searched. - grok 🔵 / astra refutation 3: the seam fetched ONE row per concept and, when it lacked its course or title, gave up on the concept — a malformed top row hid a well-formed lesson beneath it. It now fetches _FTS_ROWS_PER_CONCEPT (3) and names the first well-formed row; a concept whose only rows are malformed still answers None. Spec delta amended: first WELL-FORMED hit wins; rows are skipped, not concepts; the too-short clause added; the resolver scenario states the skipped-row case. RED fixture corrected at GREEN and recorded: the plan store refuses a second write of one id, so the mixed-concepts case became its own test with its own plan id (assertions unchanged); both tests re-run red against the pre-fix source before this commit. Verification: now-guidance + learning-decision + web-now + golden + docs contract green, ruff/format/pyright clean, openspec valid, golden ec451ce8 unchanged.
…arrived beside GREEN for RED a33249d's sibling on disk: the one finding all three seats converged on (astra F1/F2 🔴, grok 🟡, qwen 🟡). Decisions 9 and 11 cleared the move on two transitions — end of session, hand-off without a move — and claimed "a stale move never outlives its plan". Three transitions were unguarded, each putting a material-specific proposal beneath DIFFERENT material: - the learner edits the material in the picker (#topic-input, #bd-activity-input) or chooses another target from a picker select or the target-kind select — the Frames sentence and its Open-the-lesson control stayed beneath whatever was typed and rode into the session; - a second hand-off arrives while a session is live or starting — both listeners wrote the three fields unconditionally, and the same fields feed the LIVE strip, so a request for B installed B's move (or nothing) under A's running session; - reattachConflictSession() adopts a session that is not the hand-off's, and a pending picker move sat beneath it for the whole session. Each view now has one writer for the empty state, clearFirstMove(), and every material-changing transition calls it: onTopicEdited()/onActivityEdited() on the inputs (the Study input keeps its job of un-selecting the suggestion), selectOption() for every picker select, @change on #target-kind-select, the end paths (routed through the helper; the existing static pin accepts either form), both reattaches. Both listeners return before the move fields when sessionActive || starting, still pre-filling the picker's topic/activity and energy as before. Kept against grok's counter (a typo-fix in the plan title loses a correct move): cheaper than a confidently wrong proposal on the surface that is always on screen. Harness fact: reattachConflictSession() arms the real one-second tick and the RED test did not destroy() it, so `node --test` never exited the file — the run hung to the timeout rather than failing. Fixed in the test. Spec: the lifetime rule and a scenario. Design decision 12. Guide sentence. Verification: JS 164/164 (15 s, was a 120 s hang), pins + plan-integration docs contract + now-guidance + learning-decision + web-now 146/146, web unit suites reading the markup 30 passed, node --check both scripts, openspec valid, mkdocs --strict clean, golden ec451ce8 unchanged.
…ove is body-double only astra F4 (🔵): design decision 3 said the lookup cost is paid "only on the body-double path", decision 4 that the metadata is "present only on the body double", and the Today card comment that the line is "hidden for every other action" — all three true when written at (a) and false since the (e1) warm-up. Each is annotated as history amended by decisions 10–11 (kept as history, not rewritten), and the markup comment says what the gate removal made true. No code change; pins, docs contract, web unit suite and openspec re-run green.
…cept carries no warm-up grok 🔵 (and astra, "requested checks"): _warm_up builds the repair and generic tails from (primary.concept,) whatever the concept is. A candidate is plan-related by concept, topic OR course (_candidate_keys), and the struggle collector does not guard row["concept"], so a blank-concept active item can be the plan-related primary — and the warm-up minted is "Open your “” material and read for ten minutes, then start on “” — “” is too short for the index to look up." (verified: that exact string is what this RED prints). Nothing to name, nothing to search: no warm-up, resolver not asked. Fails for its stated reason before a line of code changed.
…arries no warm-up GREEN for RED 79761a9 (grok 🔵; astra's "requested checks" named the same path as UNVERIFIED). _warm_up now orders its tails so that the two which name and search primary.concept — the repair and the generic ramp — require one, the milestone tail still uses the milestone's own concepts through its ref, and a blank concept that reaches neither returns None: no first_move, no lesson, resolver not asked. Precedence for a named concept is unchanged (repair, then milestone, then generic), so every earlier warm-up test holds. Reachable, not hypothetical: _candidate_keys matches a plan on concept, topic OR course, and the struggle collector does not guard row["concept"], so the RED printed the exact sentence the finding predicted — "Open your “” material … — “” is too short for the index to look up." Spec sentence added beside the third tail. Verification: now-guidance + learning-decision + web-now 99/99 (golden ec451ce8 unchanged), ruff/format clean, pyright 0, openspec valid.
…epair Coordinator finding while verifying qwen's recall-exclusion claim (council review 8, recorded as my own, not a seat's): every struggle row carries energy_demand, so _warm_up gave a learning row's teach-back the repair tail — "then start the repair" beneath a reason that says "Recorded as learning; a gentle review keeps it fresh". Row 3b (c) removed exactly that contradiction from the reason. The warm-up must speak the row's own vocabulary: learning is a review, only struggling is a repair. Fails for its stated reason before a line of code changed (the RED prints the repair tail verbatim).
GREEN for RED 024373b (coordinator finding at council review 8, recorded as my own). The demand-marked tail chooses its noun from the row: "then start the review" when metadata.confidence is "learning", "then start the repair" otherwise — the same vocabulary the row's reason already uses since row 3b (c) ("Recorded as learning; a gentle review keeps it fresh"). Precedence and every other tail unchanged. Design decision 10's "both collectors mark repairs and nothing else" was false — every struggle row carries energy_demand — and is corrected in place (kept as history, annotated), with the docstring and the spec's first tail. Verification: now-guidance + learning-decision + web-now 100/100 (golden ec451ce8 unchanged), ruff/format clean, pyright 0, openspec valid.
…fest, arbitration; rubric row 3c and T4 record it The council ran on 2026-09-21 (tree c83ebd7) once the owner rotated the gateway's provider credential, but the coordinating session was interrupted mid-GREEN and its continuations timed out, so nothing of the run reached the repo. The seat outputs survived only in a gitignored run directory on the main checkout; they are copied here (review8/seat-*.md; the pre-commit whitespace hooks normalised trailing whitespace and final newlines, git diff -w against the originals is empty, originals' sha256 in the manifest), with the brief actually sent (93.9 KB — the 44 KB draft written before readings (b)–(e2) existed is dropped) and a manifest built from the run's own spend.json and run.log. review-8-arbitration-2026-09-21.md: seats and verdicts (astra and grok ACCEPT-WITH-CORRECTIONS, qwen ACCEPT); every finding's disposition with its commit and test — accepted: the lifetime class (c5065cf), too-short concept + first well-formed hit (a33249d→d029b0d6), F4 record (357ee25), blank concept (79761a9→193b541d), and my own learning-row tail (024373b→ f8783a7); refuted with evidence: deferred-vs-next milestone, planning launch, recall exclusion, plans-view hand-off; rejected: 0.5.1 pin, .picker-hint reuse; owner's: the high-energy ramp; separate: the clock gap. GATE ACCEPT for f8783a7. Process findings: copy seat outputs into the repo the turn they arrive; a hanging JS suite reads as a timeout. Receipt row 3c: the "still blocked" clauses become the result, with the effect on the scored screens stated (none — the pinned low and medium sentences pass unchanged on f8783a7). tasks.md T4 ticked with the evidence. Verification on f8783a7: JS 164/164; pins + docs contract + engine + web-now 148 passed; markup-reading web suites 30 passed; ruff/format clean; mkdocs --strict clean; openspec valid; golden ec451ce8 unchanged.
…he whole chain T6, first item, written in the same session as the work rather than deferred: one entry covering the sit-with move, the deliberate lesson with its evidence sentence, the honest no-lesson shapes, the warm-up into a plan-related primary, the renderers and the Open-the-lesson control, the Start hand-off the Study picker used to lack, and the lifetime rule from council review 8. tasks.md T6 records what of it is done (entry, PR, CI on the reviewed head) and what waits (CI on the council head, fast-forward, archive).
…ain spec Every task is ticked and every artifact done, and the archive condition the change set for itself — rubric row 3c scored by the owner — is met: walked interactively on 2026-09-21, one reading per turn, (a) through (e2), each verdict and self-check answer recorded in the receipt. T5 and T6 are ticked with what actually happened (T6: the fast-forward of main and its push are the owner's step, after which #30 closes with the merge sha). Archived with the CLI's own merge: +2 requirements added, 0 modified, 0 removed, to active-learning-decisions — the body double's passive first move and the plan-related primary's warm-up — verified afterwards against the delta: both requirements present, the delta's body a verbatim block of the main spec, scenarios 66 -> 81 (+15, the delta's own count), no other spec touched. `openspec validate --specs --all` 24/24; `--archived` validates the new archive (the one archived failure, 2026-07-12-complete-e2e-harness-and-desktop-mcp with four open tasks, predates this branch and fails identically on main). This is the release gate's expectation: a change with commits since v0.5.0 must be archived or deferred, and #30 ships in 0.5.1.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Issue #30 — the owner's note beside rubric row 3b (a): the sit-with session must not be a blank page. When the
nowengine proposes sitting with the plan (item 5's body-doubling floor, v0.5.0), the proposal carries one tiny, passive first move on the deferred material — and, after rubric row 3c was scored reading by reading with the owner (2026-09-21), the move became a property of the recommendation, not only of the sit-with.The two shapes it takes, from one definition (
_first_move_sentence):Base build
f43f7eb2RED →4d8d9fd5GREEN —decision._first_move+ the lesson-lookup seam;metadata["first_move"]; CLIFirst move:line; Today card line +firstMoveinbody-double-request; Body Double picker#bd-first-move; both guides; openspec changebody-double-first-move.Rubric row 3c, one reading per turn (receipt:
docs/architecture/plan-integration/receipts/now-rubric-2026-09-16.md)2e1c7ea7. Building it against the owner's real vault showed the concept→title→topic fallback chain always names a lesson — the wrong one (Frames → a PySpark lab) — which reopened (c).fdfe1672→ GREEN62f3d6f8: the seam searches the deferred milestone's own concepts and nothing else, raises on an unreadable index; three honest no-lesson shapes.First moveline, drop it from the reason. RED630cac72→ GREEN75bc9b7a: the reason ends at the co-study guarantee;metadata.first_moveis the only carriage.2b85a80b+089d09ae→ GREEN4459ea38: the seam returns(lesson_id, title, course, concept)and the sentence states its evidence. Cycle 2 REDe1576276→ GREEN266afd4f: Open the lesson on the Today card and the Body Double picker opens the Course Explorer aside beside the view viaexplorer-open-lesson/openLessonById.74779bda→ GREEN5666f141:#bd-live-first-movebeneath the activity name for the whole session, the control beside it;confirmEnd()clears the move with the activity it arrived beside. Rejected: auto-open at start.fa5d76be→ GREENdacbea87:_warm_upon the primary only — plan-related, not the body double,action_typein hands-on/conversation/teachback — three tails (repair → then start the repair; eligible milestone → its own concepts, then start the milestone; other → then start on “”); never recall (the resolver is not asked), never off a plan, never an alternate; at any energy. The CLI needed no change (it keys onmetadata.first_move); the Today card did not — see (e2).firstMoveNote()/firstMoveLesson()gated onsource === 'body_double', so the card did not render the warm-up at all —dacbea87's "renderers unchanged" was true of the CLI and false of the card; corrected in design decision 11 and the receipt. REDeb3be21a→ GREEN25886ff0: the gate comes off; Start hands{topic, energy, firstMove?, lesson?}over the existingtoday-resume; the Study picker shows the move beneath the topic (#study-first-move,#study-first-move-open) and the live layout beneath the status bar for the whole session (#study-live-first-move,#study-live-first-move-open); cleared with the topic on end and on a planning launch; behaviour-tested on thesession-timer.js/today-panel.jsnode harnesses.Deliberately not built: a first move for a deferred repair (unreachable — design decision 2); speaking the move (the co-study persona is untouched; #33 holds the read-along idea); a reader pane inside the live session (#33's).
Cost: lesson lookup 861 ms cold (the explorer's index refresh) / 45–58 ms warm per concept; warm-up path re-measured 161 ms cold / 45 ms warm. Paid once per
nowwhose primary carries a move; anowwith no active plan pays nothing.Recorded, not fixed here: the medium screen's "last seen 8 day(s) ago" for a row planted 3 days before the frozen clock —
now_worldfreezesdecision.datetimeonly whilehistory/progress.pyreads the wall clock. A test-isolation gap, not a product defect; its own small change.Council review 8 (2026-09-21) — findings landed one commit each
clearFirstMove()writer per view, guards at every material-changing transition, behaviour-tested on the Study harness, pinned statically on Body Double (c5065cf0, design decision 12). Kept against grok's counter (a typo-fix in the plan title loses a correct move): the move is one Start away.a33249da→d029b0d6.357ee258.79761a94→193b541d.learningrow's warm-up ends then start the review, not the repair — my own finding while verifying qwen's recall claim; the same contradiction row 3b (c) removed from the reason:024373ba→f8783a73..picker-hintreuse (CI green on the suites that query it). Owner's call, unchanged: the high-energy ramp (decision 10). Separate change: the frozen-clock gap.review8/manifest.json. A RED test that armed the real tick withoutdestroy()madenode --testhang to its timeout; fixed inc5065cf0.Gates before merge
b64afd02).c83ebd75once the gateway's provider credential was rotated (astra ACCEPT-WITH-CORRECTIONS, grok ACCEPT-WITH-CORRECTIONS, qwen ACCEPT); GATE ACCEPT forf8783a73indocs/architecture/plan-integration/council/review-8-arbitration-2026-09-21.md, seat transcripts incouncil/review8/. No finding changed a scored screen.[Unreleased]entry (865e686a).openspec archive body-double-first-movebefore the 0.5.1 cut.main.Tested
At
f8783a73/865e686a(after council review 8): JS 164/164 (15 s); first-move pins + plan-integration docs contract + now-guidance + learning-decision + web-now 148 passed; the web unit suites that read the markup 30 passed; ruff, ruff format, pyright 0;mkdocs build --strictclean;openspec validatevalid (the lifetime scenario and two sentences added); goldenec451ce8byte-identical. Fullpyteston the tree: 7314 passed, 31 failed, 14 errors — the 44 failing/erroring ids underpackages/studyloopare exactly the committed sandbox-environmental set (receipts/full-suite-control-item4-2026-09-18.md; set difference ∅ both ways, item 4's seven then-REDs green), plus one order-dependent id outside this branch's files (packages/agent-session-tools/tests/test_sync_conversation_integrity.py::test_concatenated_remote_dump_with_existing_archive, red in the whole-tree run, green alone, green with its file, green with its whole package 2115/2115; the branch touches nothing underpackages/agent-session-tools).At
25886ff0/c83ebd75: new Study pins + Body Double pins + docs contract + now-guidance + web-now + golden 134/134; the web unit suites that read the markup 144 passed (1 skipped, 5 deselected); JS 160/160;node --checkon the edited scripts; goldennow_plan_no_active.jsonbyte-identical (ec451ce8) throughout; ruff, ruff format, pyright (0 errors);mkdocs build --strictclean;openspec validate body-double-first-movevalid (two requirements, fifteen scenarios). Every RED committed failing for its stated reason before its GREEN; the RED expectations corrected at GREEN (the medium payload's key shape indacbea87; the F7 "dispatches nothing" assertion and the async harness in25886ff0) are recorded in those commits. Real-vault probes of the shipped seam:window frame→ none;window function→ Advanced Sql 4H / Complete Sql Databases Bootcamp;decorators→ Decorators 29M / The Ultimate Typescript.