dsh-diagnostic-tutor 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,9 +11,14 @@ learn interactively, and move to the next best step.
11
11
 
12
12
  ## Status
13
13
 
14
- `v0.1.0` — the first stable release, and the first one meant to be **installed
15
- rather than built**: `dsh plugin --profile web add dsh-diagnostic-tutor`, with
16
- prebuilt output and no clone, no `pnpm install`, no build step.
14
+ `v0.1.1` — the current stable release. Conversation-scoped projects, focus,
15
+ next steps and handoffs keep each learning session separate, while maps and
16
+ lessons remain shared. The Learning UI now offers one next action and reports
17
+ its actual state, with working scroll containers in docked and expanded views.
18
+
19
+ Install the prebuilt package with
20
+ `dsh plugin --profile web add dsh-diagnostic-tutor@0.1.1` — no clone, no
21
+ `pnpm install`, no build step.
17
22
 
18
23
  The learning loop works end to end: a real goal in your own words, a diagnosis
19
24
  map grown one node at a time, a tutor-written lesson, a check answered in the
@@ -126,13 +131,11 @@ library plus fonts and CSS, and the client bundle is a single JavaScript file
126
131
  with nowhere to serve those from. This is the most visible rough edge in any
127
132
  STEM lesson.
128
133
 
129
- **Skill presence cannot always be detected.** The runtime reports whether the
130
- Universal Diagnostic Tutor skill is available, but the skill registry reads the
131
- *global* layer unless it is given a viewing scope, and the standard web profile
132
- mounts skills per agent. From a plugin at the profile root the catalog is
133
- therefore empty whether the skill is installed or not. The runtime says
134
- `cannot tell` rather than guessing, and the panel stays quiet. If lessons never
135
- appear, check the skill first.
134
+ **Skill presence requires a live Agent.** The runtime checks the open
135
+ conversation's Agent scope on each overview, including its workspace. Before an
136
+ Agent exists, or while discovery is empty or incomplete, it reports `cannot tell`
137
+ and the panel stays quiet. Polling picks up later registration and Agent changes.
138
+ If lessons never appear, check the skill first.
136
139
 
137
140
  **Storage is one document.** Every record lives in
138
141
  `<dsh-home>/storages/udt.json`. It is readable, copyable and easy to export, and
@@ -154,6 +157,7 @@ matrix as load-bearing, not decoration.
154
157
 
155
158
  | This plugin | Verified against DSH | Node |
156
159
  | --- | --- | --- |
160
+ | `0.1.1` | `0.2.1-rc.2`, `0.2.0-rc.2` (test suite also on `0.1.7-alpha.2`) | `^22.19.0 \|\| >=24.0.0` |
157
161
  | `0.1.0` | `0.1.7-alpha.2` (also composed under `0.1.5-rc.1`) | `^22.19.0 \|\| >=24.0.0` |
158
162
 
159
163
  Rules this repository enforces mechanically:
@@ -165,7 +169,10 @@ Rules this repository enforces mechanically:
165
169
  second runtime copy can be resolved. Enforced by `verbatimModuleSyntax`, and
166
170
  verified on the built artifact (it contains zero imports).
167
171
  - **No `instanceof` across package boundaries.** Take runtime objects from `ctx`.
168
- - Peer ranges are wide (`<0.2.0`); dev dependencies pin one exact cohort.
172
+ - Peer ranges name the release lines this plugin was verified on. DSH ships
173
+ prereleases, and npm's semver only admits a prerelease when its line is named
174
+ in the range — so the compatibility matrix above, not the range floor, is the
175
+ contract. Dev dependencies pin one exact cohort for the type baseline.
169
176
 
170
177
  ---
171
178
 
@@ -175,7 +182,7 @@ Rules this repository enforces mechanically:
175
182
 
176
183
  | | |
177
184
  | --- | --- |
178
- | [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) | verified against `0.1.7-alpha.2` |
185
+ | [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) | verified against `0.2.1-rc.2` |
179
186
  | the [Universal Diagnostic Tutor](https://github.com/SenmuuuuW/universal-diagnostic-tutor-skill) skill | the teaching brain. Without it the runtime loads, records and displays state, but **no lesson is ever written** |
180
187
  | Node | `^22.19.0 \|\| >=24.0.0` |
181
188
 
@@ -227,7 +234,7 @@ learn?*.
227
234
 
228
235
  ```sh
229
236
  npm pack
230
- dsh plugin --profile web add "$PWD/dsh-diagnostic-tutor-0.1.0-rc.1.tgz"
237
+ dsh plugin --profile web add "$PWD/dsh-diagnostic-tutor-0.1.1.tgz"
231
238
  ```
232
239
 
233
240
  **What gets installed is prebuilt.** The package ships `lib/` — the host half
@@ -258,10 +265,30 @@ With the json backend you get exactly one document, `<storage root>/udt.json`
258
265
 
259
266
  | Slot | Holds |
260
267
  | --- | --- |
261
- | `global` | the learner singleton — preferences, active goal, `initializedAt` |
268
+ | `global` | the learner singleton — preferences, `initializedAt`, and the legacy shared `activeCourseId` marker |
262
269
  | `tables.courses` | one record per learning goal, in the learner's own words |
263
270
  | `tables.nodes` | the diagnosis map: `id`, `courseId`, `title`, `parentId?`, `relation`, `state`, `evidence[]` |
264
271
 
272
+ Courses, maps, lessons and evidence are shared by the learner's conversations.
273
+ Project selection, focus, next step and handoff belong to one conversation,
274
+ identified by its session ID. The legacy `activeCourseId` remains in storage for
275
+ compatibility; it does not select a project for a conversation. A fresh
276
+ conversation starts with the library, even when historical projects exist.
277
+
278
+ | Conversation selection | Project binding |
279
+ | --- | --- |
280
+ | `none` | A new goal, or a validated write explicitly naming a course or node in this conversation, may select its project. Reading shared data does not select it. |
281
+ | `new` | The learner pressed **Start something new**. Only a new goal created here may bind; historical map or lesson writes leave this choice intact. |
282
+ | `project` | The selected course is the default for tools. Creating another goal does not replace it; writes to another course require selecting that course first. |
283
+
284
+ `udt_status` reports this conversation's `selection` and, only for `project`,
285
+ its `courseId`. Goal, map and lesson writes return `projectAdopted` and
286
+ `projectBinding`: `adopted`, `already-selected`, `not-selected`, or
287
+ `missing-session-id`. A choice made while a write is pending takes precedence
288
+ over that write's adoption. Without a reliable session ID, status reports
289
+ `sessionAvailable: false`; explicit shared writes can still be stored, but their
290
+ result is `missing-session-id` and they claim no conversation binding.
291
+
265
292
  Two deliberate rules:
266
293
 
267
294
  - **zod is the contract.** Every record is validated at the durable boundary, so
@@ -294,13 +321,13 @@ route around them:
294
321
 
295
322
  ### Tools
296
323
 
297
- Five, and none of them decides anything about teaching.
324
+ Six, and none of them decides anything about teaching.
298
325
 
299
326
  | Tool | Does |
300
327
  | --- | --- |
301
- | `udt_status` | reports the runtime: domain, version, goals, map, **and the current focus** |
302
- | `udt_goal_create` | records a goal and plants the map root — and nothing else |
303
- | `udt_map_get` | reads the map with each node's relation, state and evidence |
328
+ | `udt_status` | reports shared goals and counts, plus this conversation's selection, focus and pending next step |
329
+ | `udt_goal_create` | records a goal and plants only the map root; may also select it for this conversation |
330
+ | `udt_map_get` | reads the selected project's map, or an explicitly named shared course, with each node's relation, state and evidence |
304
331
  | `udt_map_update` | `add-nodes` · `set-state` · `add-evidence` |
305
332
  | `udt_lesson_update` | writes teaching into the learning surface as blocks |
306
333
  | `udt_decide_next` | records where the learner should go next, and why |
@@ -394,19 +421,20 @@ conversation rather than at load.
394
421
 
395
422
  ### Teaching-brain detection
396
423
 
397
- At load the plugin asks the platform's own skill registry whether the
398
- Universal Diagnostic Tutor skill is installed — no path is hardcoded, no skill
399
- root is assumed, and nothing is copied. A missing catalog, a missing skill and
400
- an unreadable body each degrade to a reported status rather than an error.
424
+ Each overview asks the platform's own Agent and Skill registries whether the
425
+ Universal Diagnostic Tutor skill is available in the requesting session's live
426
+ Agent scope and cwd. No path is hardcoded, no skill root is assumed, and nothing
427
+ is copied. Missing services and incomplete discovery return an inconclusive
428
+ status; complete scoped discovery can establish presence or absence.
401
429
 
402
430
  Compatibility is probed by **capability**, not by a version string: the skill's
403
431
  maintenance contract permits only `name` and `description` in frontmatter, so
404
432
  it cannot declare a version. The result carries a short content digest as a
405
433
  version hint.
406
434
 
407
- Detection results stay **internal** — logged at `debug`, absent from every tool
408
- output. The skill forbids naming its files, versions or repository in
409
- learner-facing text, and this runtime will not be what leaks them.
435
+ Paths, fingerprints and compatibility details stay **internal**, absent from
436
+ every tool output and overview. The overview exposes only availability. The skill
437
+ forbids naming its files, versions or repository in learner-facing text.
410
438
 
411
439
  ### How the two halves agree
412
440
 
@@ -492,18 +520,61 @@ Machine Learning [goal, unconfirmed]
492
520
  └─ Python [prerequisite, unconfirmed]
493
521
  ```
494
522
 
523
+ Two query parameters reach the states that are otherwise awkward to produce on
524
+ demand — `?scenario=` (`first-use`, `map`, `handoff`, `next`, `stay`, `noskill`,
525
+ `long`, `scroll`, `library`, `new`) and `?surface=tab` for the docked right-sidebar
526
+ tab. The scenario switch is in the page's toolbar, and the theme toggle sets the same
527
+ `body[data-ds-dark-theme]` attribute DSH does. The page carries the real DSH
528
+ design tokens for the same reason: with hardcoded fallbacks in their place, a
529
+ token that resolves to near-white in dark mode looks perfectly fine locally.
530
+
495
531
  To capture the panel from a *live* profile instead:
496
532
 
497
533
  ```sh
498
534
  pnpm screenshot "<dsh-url-with-token>" preview/dsh-ui.png
499
535
  ```
500
536
 
537
+ ### Auditing the presentation
538
+
539
+ ```sh
540
+ pnpm preview # in one shell
541
+ pnpm audit:ui http://127.0.0.1:8391/?scenario=next # in another
542
+ pnpm audit:ui "<dsh-url-with-token>" /tmp/audit --surface=tab
543
+ pnpm check:scroll http://127.0.0.1:8391/ /tmp/udt-scroll-check
544
+ ```
545
+
546
+ The scroll regression requires local Chrome and runs against the built bundle.
547
+ The preview uses a clipped, fixed-height slot like DSH, so the whole document
548
+ cannot scroll to mask an unscrollable plugin. The check sends real wheel events
549
+ and verifies bottom content remains visible in the tab, expanded tab, stacked
550
+ panel, all three wide-panel panes, and long library/welcome views, in both themes.
551
+ Only fixture data is used; no live learner state is changed. The ordinary visual
552
+ audit alone does **not** verify scrolling.
553
+
554
+ The visual audit measures, at each width this project targets and in both themes: the contrast
555
+ of every control's own text against its own fill, class names in the DOM that no
556
+ rule matches, how many controls carry primary weight in one view, tap targets
557
+ under 28px, type under 10.5px, and anything spilling or clipped. It prints a
558
+ table, writes `findings.json`, and saves screenshots — it does not fail the run,
559
+ so a human can disagree with it.
560
+
561
+ That is not a substitute for looking. It is what makes looking affordable: the
562
+ docked tab's invisible primary button and the dark-mode 1.05:1 controls were
563
+ both found by it, in a live profile, in seconds.
564
+
565
+ What the surfaces look like now, captured from a live profile:
566
+
567
+ | | light | dark |
568
+ | --- | --- | --- |
569
+ | full panel | ![panel, light](preview/ui-polish/panel-light-1440.png) | ![panel, dark](preview/ui-polish/panel-dark-1440.png) |
570
+ | docked tab | ![tab, light](preview/ui-polish/tab-light-400.png) | ![tab, dark](preview/ui-polish/tab-dark-400.png) |
571
+
501
572
  ## Development
502
573
 
503
574
  ```sh
504
575
  pnpm install
505
576
  pnpm typecheck # tsc --noEmit (host and client)
506
- pnpm test # 253 tests: unit, guard, DOM, render, real composition
577
+ pnpm test # 455 tests: unit, guard, DOM, render, real composition
507
578
  pnpm build # tsc -> lib/ (host) + tsdown -> lib/client.js
508
579
  ```
509
580
 
@@ -571,18 +642,19 @@ panel opens, the map and the lesson surface render, and `GET /export` and
571
642
  `POST /reset` work. What does not happen is teaching — nothing writes a lesson,
572
643
  because nothing is making teaching decisions.
573
644
 
574
- **The plugin cannot always tell you that.** The skill registry reads the
575
- *global* layer unless it is given a viewing scope, and the standard web profile
576
- mounts the filesystem skill provider **inside a per-agent layer**. So a plugin
577
- at the profile root sees an empty skill catalog whether the skill is missing or
578
- merely mounted where it cannot look. The runtime therefore reports three states,
579
- not two:
645
+ **Availability is checked for the conversation you have open.** On every
646
+ `GET /overview?sessionId=…`, the plugin resolves that session's live Agent from
647
+ DSH's Agent registry and asks the Skill registry for a complete snapshot using
648
+ `{ scope: agent, cwd: agent.session.header.cwd }`. A root catalog containing
649
+ Office skills cannot establish whether that Agent has a tutor. Services are
650
+ resolved for each request, so delayed registration, removal and preset changes
651
+ are picked up by the panel's existing polling. The runtime reports three states:
580
652
 
581
653
  | `teachingBrain` | meaning | what the panel does |
582
654
  | --- | --- | --- |
583
- | `true` | found | nothing |
584
- | `false` | not found, **in a catalog that has entries** | says a tutor is needed |
585
- | `null` | cannot tell from this scope | says nothing |
655
+ | `true` | found and model-invocable in the live Agent scope | nothing |
656
+ | `false` | unavailable after complete scoped discovery with entries | says a tutor is needed |
657
+ | `null` | no live Agent, empty/incomplete discovery, or unsupported/unavailable service | says nothing |
586
658
 
587
659
  An earlier revision treated "not in my catalog" as "not installed" and showed a
588
660
  notice on that basis. In a web profile that was a confident, wrong answer — the
package/lib/api.js CHANGED
@@ -102,24 +102,104 @@ function stringField(body, key) {
102
102
  /* Handler */
103
103
  /* -------------------------------------------------------------------------- */
104
104
  /**
105
- * Which course the panel should show: the learner's active goal, else the most
106
- * recently updated one. Read-only — the panel never changes focus implicitly.
105
+ * The focus this conversation is working on, or nothing.
106
+ *
107
+ * Learning state belongs to a conversation, not to a course. The course and its
108
+ * map and its lessons are shared knowledge and stay visible to everyone; where
109
+ * *this* conversation has got to is not. A focus carrying a different
110
+ * conversation's id — or none at all, which means it predates the field — is
111
+ * not this panel's to show, so it reports nothing rather than someone else's
112
+ * lesson under the learner's chat.
113
+ *
114
+ * @param state - the runtime.
115
+ * @param courseId - the course being viewed.
116
+ * @param sessionId - the conversation asking, when it said.
117
+ * @returns the focus record, or undefined.
107
118
  */
108
- function currentCourse(state) {
109
- const activeId = state.readLearner().activeCourseId;
110
- if (activeId !== undefined) {
111
- const active = state.readCourse(activeId);
112
- if (active !== undefined)
113
- return active;
114
- }
119
+ function focusBelongingTo(state, courseId, sessionId) {
120
+ // No conversation named means no conversation's state: guessing is what let
121
+ // one conversation's lesson appear under another's chat.
122
+ if (sessionId === undefined || sessionId.length === 0)
123
+ return undefined;
124
+ return state.readFocus(sessionId, courseId);
125
+ }
126
+ /** The project a conversation has open, if any. */
127
+ function projectFor(state, sessionId) {
128
+ return state.resolveProject(sessionId).course;
129
+ }
130
+ /**
131
+ * Every Learning Project, with the numbers the library shows on a card.
132
+ *
133
+ * Learner-level, like the maps and lessons themselves: this is what the
134
+ * learner has, not what this conversation is doing with it.
135
+ */
136
+ function libraryOf(state) {
115
137
  return state
116
138
  .listCourses()
117
- .slice()
118
- .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
139
+ .map((course) => {
140
+ const nodes = state.listNodes(course.id);
141
+ return {
142
+ id: course.id,
143
+ title: course.title,
144
+ goal: course.goal,
145
+ confirmed: nodes.filter((node) => node.state === 'confirmed').length,
146
+ total: nodes.length,
147
+ updatedAt: course.updatedAt,
148
+ };
149
+ })
150
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
151
+ }
152
+ async function handleOverview(state, deps, sessionId, res) {
153
+ // Availability belongs to the requesting conversation and can change while
154
+ // the plugin stays loaded. A failed observation is inconclusive.
155
+ let teachingBrain = null;
156
+ try {
157
+ teachingBrain = typeof deps.teachingBrain === 'function'
158
+ ? await deps.teachingBrain(sessionId)
159
+ : deps.teachingBrain ?? null;
160
+ }
161
+ catch {
162
+ // The runtime remains readable if discovery is temporarily unavailable.
163
+ }
164
+ const { selection, course } = state.resolveProject(sessionId);
165
+ const library = libraryOf(state);
166
+ if (course === undefined) {
167
+ // No project open in this conversation. That is the library, not an error,
168
+ // and not an empty runtime: the projects it lists are real and shared.
169
+ sendJson(res, 200, {
170
+ ok: true,
171
+ selection,
172
+ course: null,
173
+ nodes: [],
174
+ focus: null,
175
+ nextStep: null,
176
+ handoff: null,
177
+ teachingBrain,
178
+ lessonCount: state.lessonCount(),
179
+ library,
180
+ });
181
+ return;
182
+ }
183
+ const mine = focusBelongingTo(state, course.id, sessionId);
184
+ sendJson(res, 200, {
185
+ ok: true,
186
+ selection,
187
+ course: courseView(course),
188
+ nodes: state.listNodes(course.id).map(nodeView),
189
+ focus: mine === undefined ? null : focusOf(state, sessionId ?? '', course.id),
190
+ // A decision is attached to a focus, so it follows the same rule.
191
+ nextStep: mine === undefined ? null : nextStepOf(state, sessionId ?? '', course.id),
192
+ teachingBrain,
193
+ // The handoff for whichever node is focused, so a reload or a restart
194
+ // rebuilds the progress line instead of showing a blank wait.
195
+ handoff: mine === undefined ? null : handoffFor(state, sessionId ?? '', mine.nodeId, Date.now()),
196
+ lessonCount: state.lessonCount(),
197
+ library,
198
+ });
119
199
  }
120
200
  /** The active focus for one course, joined with its node, as a view. */
121
- function focusOf(state, courseId) {
122
- const focus = state.readFocus(courseId);
201
+ function focusOf(state, sessionId, courseId) {
202
+ const focus = state.readFocus(sessionId, courseId);
123
203
  if (focus === undefined || focus.status !== 'active')
124
204
  return null;
125
205
  const node = state.readNode(focus.nodeId);
@@ -135,11 +215,11 @@ function focusOf(state, courseId) {
135
215
  * fresh record without `nextStepId`, so acting on a recommendation clears it
136
216
  * without anything having to remember that it did.
137
217
  */
138
- function nextStepOf(state, courseId) {
139
- const focus = state.readFocus(courseId);
218
+ function nextStepOf(state, sessionId, courseId) {
219
+ const focus = state.readFocus(sessionId, courseId);
140
220
  if (focus?.nextStepId === undefined)
141
221
  return null;
142
- const step = state.readNextStep(focus.nextStepId);
222
+ const step = state.readNextStep(sessionId, focus.nextStepId);
143
223
  if (step === undefined)
144
224
  return null;
145
225
  const from = state.readNode(step.fromNodeId);
@@ -154,55 +234,63 @@ function nextStepOf(state, courseId) {
154
234
  createdAt: step.createdAt,
155
235
  };
156
236
  }
157
- function handleOverview(state, deps, res) {
158
- const course = currentCourse(state);
159
- if (course === undefined) {
160
- // An empty state is a normal state, not an error: the panel says so. The
161
- // shape must still be complete — an omitted field reads as `undefined`,
162
- // and a surface that checks `teachingBrain === false` would never fire in
163
- // the one state it exists for.
164
- sendJson(res, 200, {
165
- ok: true,
166
- course: null,
167
- nodes: [],
168
- focus: null,
169
- nextStep: null,
170
- handoff: null,
171
- teachingBrain: deps.teachingBrain ?? null,
172
- lessonCount: 0,
173
- });
174
- return;
237
+ /**
238
+ * The focus this conversation is working on, or nothing.
239
+ *
240
+ * Learning state belongs to a conversation, not to a course. The course and its
241
+ * map and its lessons are shared knowledge and stay visible to everyone; where
242
+ * *this* conversation has got to is not. A focus carrying a different
243
+ * conversation's id — or none at all, which means it predates the field — is
244
+ * not this panel's to show, so it reports nothing rather than someone else's
245
+ * lesson under the learner's chat.
246
+ *
247
+ * @param state - the runtime.
248
+ * @param courseId - the course being viewed.
249
+ * @param sessionId - the conversation asking, when it said.
250
+ * @returns the focus record, or undefined.
251
+ */
252
+ /**
253
+ * `POST /project { sessionId, courseId? }` — choose what this conversation learns.
254
+ *
255
+ * The choice is the conversation's, and that is the whole point of the route:
256
+ * the projects are shared, but opening one is a decision a conversation makes
257
+ * and keeps. Sending no `courseId` selects "start something new", which is a
258
+ * deliberate state — it stops the library being shown and stops any project
259
+ * being opened, until the tutor creates one here.
260
+ *
261
+ * Selecting never fabricates execution state. There is no focus until the
262
+ * learner starts a node, so switching projects shows "no learning focus yet"
263
+ * rather than pretending the previous project's position carried over.
264
+ */
265
+ async function handleSelectProject(state, req, res) {
266
+ const body = await readJsonBody(req);
267
+ if (body === undefined)
268
+ return fail(res, 400, 'bad-body');
269
+ const sessionId = stringField(body, 'sessionId');
270
+ if (sessionId === undefined)
271
+ return fail(res, 400, 'missing-session-id');
272
+ const courseId = stringField(body, 'courseId');
273
+ if (courseId !== undefined && state.readCourse(courseId) === undefined) {
274
+ return fail(res, 404, 'no-such-course');
175
275
  }
176
- sendJson(res, 200, {
177
- ok: true,
178
- course: courseView(course),
179
- nodes: state.listNodes(course.id).map(nodeView),
180
- focus: focusOf(state, course.id),
181
- nextStep: nextStepOf(state, course.id),
182
- teachingBrain: deps.teachingBrain ?? null,
183
- // The handoff for whichever node is focused, so a reload or a restart
184
- // rebuilds the progress line instead of showing a blank wait.
185
- handoff: (() => {
186
- const focus = state.readFocus(course.id);
187
- return focus === undefined ? null : handoffFor(state, focus.nodeId, Date.now());
188
- })(),
189
- lessonCount: state.lessonCount(),
190
- });
276
+ const record = await state.selectProject(sessionId, courseId, new Date().toISOString());
277
+ sendJson(res, 200, { ok: true, project: record });
191
278
  }
192
279
  /** `GET /lesson?nodeId=` — the lesson the tutor has written for a node, if any. */
193
- function handleLesson(state, course, nodeId, res) {
194
- if (course === undefined)
195
- return fail(res, 404, 'no-course');
280
+ function handleLesson(state, nodeId, res) {
281
+ // Resolved from the node, not from the caller's project: a lesson belongs to
282
+ // the learner, and asking for one should not depend on what this conversation
283
+ // happens to have open.
196
284
  const node = state.readNode(nodeId);
197
- if (node === undefined || node.courseId !== course.id)
285
+ if (node === undefined || state.readCourse(node.courseId) === undefined) {
198
286
  return fail(res, 404, 'no-such-node');
287
+ }
199
288
  sendJson(res, 200, { ok: true, lesson: state.lessonForNode(nodeId) ?? null });
200
289
  }
201
- function handleNode(state, course, nodeId, res) {
202
- if (course === undefined)
203
- return fail(res, 404, 'no-course');
290
+ function handleNode(state, nodeId, res) {
204
291
  const node = state.readNode(nodeId);
205
- if (node === undefined || node.courseId !== course.id)
292
+ const course = node === undefined ? undefined : state.readCourse(node.courseId);
293
+ if (node === undefined || course === undefined)
206
294
  return fail(res, 404, 'no-such-node');
207
295
  const parent = node.parentId === undefined ? undefined : state.readNode(node.parentId);
208
296
  const children = state
@@ -229,8 +317,8 @@ function handleNode(state, course, nodeId, res) {
229
317
  /** How long a handoff is left alone before another request is treated as a retry. */
230
318
  const HANDOFF_DEDUPE_MS = 3000;
231
319
  /** Build the handoff view, which is what a surface renders. */
232
- function handoffFor(state, nodeId, nowMs) {
233
- const record = state.readHandoff(nodeId);
320
+ function handoffFor(state, sessionId, nodeId, nowMs) {
321
+ const record = state.readHandoff(sessionId, nodeId);
234
322
  if (record === undefined)
235
323
  return null;
236
324
  return handoffView(record, nowMs, state.lessonForNode(nodeId) !== undefined);
@@ -251,9 +339,7 @@ function handoffFor(state, nodeId, nowMs) {
251
339
  * returns it untouched, which is what makes a double click harmless; a request
252
340
  * after it has gone quiet is a retry and bumps the attempt count.
253
341
  */
254
- async function handleStartFocus(state, deps, course, req, res) {
255
- if (course === undefined)
256
- return fail(res, 404, 'no-course');
342
+ async function handleStartFocus(state, deps, req, res) {
257
343
  const body = await readJsonBody(req);
258
344
  if (body === undefined)
259
345
  return fail(res, 400, 'bad-body');
@@ -261,38 +347,51 @@ async function handleStartFocus(state, deps, course, req, res) {
261
347
  if (nodeId === undefined)
262
348
  return fail(res, 400, 'missing-node-id');
263
349
  const sessionId = stringField(body, 'sessionId');
350
+ // Required: a focus that cannot say whose it is cannot be shown to anyone,
351
+ // so accepting one would create state nobody could ever see.
352
+ if (sessionId === undefined)
353
+ return fail(res, 400, 'missing-session-id');
354
+ // The course comes from the NODE, not from what this conversation had open.
355
+ // Starting a node in a project is choosing that project — the other
356
+ // direction is the one that must not happen, and does not: selecting a
357
+ // project never invents a focus.
264
358
  const node = state.readNode(nodeId);
265
- if (node === undefined || node.courseId !== course.id)
359
+ const course = node === undefined ? undefined : state.readCourse(node.courseId);
360
+ if (node === undefined || course === undefined)
266
361
  return fail(res, 404, 'no-such-node');
362
+ const chosen = state.readProject(sessionId);
363
+ if (chosen?.courseId !== course.id) {
364
+ await state.selectProject(sessionId, course.id, new Date().toISOString());
365
+ }
267
366
  const now = new Date();
268
- const previous = state.readHandoff(nodeId);
367
+ const previous = state.readHandoff(sessionId, nodeId);
269
368
  // Double-click protection: the same target, still in flight and recent, is
270
369
  // the same handoff. Not an error — just nothing new to do.
271
370
  if (previous !== undefined &&
272
371
  previous.status !== 'failed' &&
273
372
  now.getTime() - Date.parse(previous.requestedAt) < HANDOFF_DEDUPE_MS) {
274
- const focus = state.readFocus(course.id);
373
+ const focus = state.readFocus(sessionId, course.id);
275
374
  sendJson(res, 200, {
276
375
  ok: true,
277
376
  focus: focus === undefined ? null : focusView(focus, node),
278
377
  prompted: previous.promptedAt !== undefined,
279
- handoff: handoffFor(state, nodeId, now.getTime()),
378
+ handoff: handoffFor(state, sessionId, nodeId, now.getTime()),
280
379
  deduped: true,
281
380
  });
282
381
  return;
283
382
  }
284
383
  let handoff = beginHandoff({
285
384
  courseId: course.id,
286
- fromNodeId: previous?.fromNodeId ?? state.readFocus(course.id)?.nodeId ?? nodeId,
385
+ fromNodeId: previous?.fromNodeId ?? state.readFocus(sessionId, course.id)?.nodeId ?? nodeId,
287
386
  targetNodeId: nodeId,
288
387
  now: now.toISOString(),
289
- ...(sessionId === undefined ? {} : { sessionId }),
388
+ sessionId,
290
389
  ...(previous === undefined ? {} : { previous }),
291
390
  });
292
391
  await state.writeHandoff(handoff);
293
392
  let focus;
294
393
  try {
295
- focus = await state.startFocus(course.id, nodeId, now.toISOString());
394
+ focus = await state.startFocus(course.id, nodeId, now.toISOString(), sessionId);
296
395
  }
297
396
  catch (error) {
298
397
  // Covers an unknown node and a node from another course alike.
@@ -310,7 +409,7 @@ async function handleStartFocus(state, deps, course, req, res) {
310
409
  focus: focusView(focus, node),
311
410
  prompted: outcome.prompted,
312
411
  ...(outcome.reason === undefined ? {} : { promptReason: outcome.reason }),
313
- handoff: handoffFor(state, nodeId, Date.now()),
412
+ handoff: handoffFor(state, sessionId, nodeId, Date.now()),
314
413
  });
315
414
  }
316
415
  /**
@@ -350,11 +449,14 @@ async function handleObserved(state, req, res) {
350
449
  const nodeId = stringField(body, 'nodeId');
351
450
  if (nodeId === undefined)
352
451
  return fail(res, 400, 'missing-node-id');
353
- const record = state.readHandoff(nodeId);
452
+ const sessionId = stringField(body, 'sessionId');
453
+ if (sessionId === undefined)
454
+ return fail(res, 400, 'missing-session-id');
455
+ const record = state.readHandoff(sessionId, nodeId);
354
456
  if (record === undefined)
355
457
  return fail(res, 404, 'no-handoff');
356
458
  await state.writeHandoff(withObserved(record, new Date().toISOString()));
357
- sendJson(res, 200, { ok: true, handoff: handoffFor(state, nodeId, Date.now()) });
459
+ sendJson(res, 200, { ok: true, handoff: handoffFor(state, sessionId, nodeId, Date.now()) });
358
460
  }
359
461
  /**
360
462
  * Build the request handler.
@@ -368,9 +470,15 @@ export function createApiHandler(deps) {
368
470
  const method = req.method ?? 'GET';
369
471
  const url = new URL(req.url ?? '/', 'http://localhost');
370
472
  const route = url.pathname.slice(API_PREFIX.length) || '/';
371
- const course = currentCourse(state);
372
- if (method === 'GET' && route === '/overview')
373
- return handleOverview(state, deps, res);
473
+ // Read per request, not once: a conversation's project is its own, and the
474
+ // route that changes it must not be answered from a value captured before.
475
+ const asker = url.searchParams.get('sessionId') ?? undefined;
476
+ const course = projectFor(state, asker);
477
+ if (method === 'GET' && route === '/overview') {
478
+ return handleOverview(state, deps, url.searchParams.get('sessionId') ?? undefined, res);
479
+ }
480
+ if (method === 'POST' && route === '/project')
481
+ return await handleSelectProject(state, req, res);
374
482
  if (method === 'GET' && route === '/export')
375
483
  return handleExport(state, res);
376
484
  if (method === 'POST' && route === '/reset')
@@ -379,16 +487,16 @@ export function createApiHandler(deps) {
379
487
  const nodeId = url.searchParams.get('id');
380
488
  if (nodeId === null || nodeId.length === 0)
381
489
  return fail(res, 400, 'missing-id');
382
- return handleNode(state, course, nodeId, res);
490
+ return handleNode(state, nodeId, res);
383
491
  }
384
492
  if (method === 'GET' && route === '/lesson') {
385
493
  const nodeId = url.searchParams.get('nodeId');
386
494
  if (nodeId === null || nodeId.length === 0)
387
495
  return fail(res, 400, 'missing-node-id');
388
- return handleLesson(state, course, nodeId, res);
496
+ return handleLesson(state, nodeId, res);
389
497
  }
390
498
  if (method === 'POST' && route === '/focus') {
391
- return handleStartFocus(state, deps, course, req, res);
499
+ return handleStartFocus(state, deps, req, res);
392
500
  }
393
501
  if (method === 'POST' && route === '/handoff/observed') {
394
502
  return handleObserved(state, req, res);